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 let stream_chunk = agent_stream_chunk(&event);
10923 self.publish(NodeEvent::Control {
10924 address: address.clone(),
10925 event,
10926 });
10927 if let Some(chunk) = stream_chunk {
10928 self.publish_agent_stream_chunk(address.clone(), chunk);
10929 }
10930 if revokes_harness_mcp {
10931 SessionLifecycleHook::on_session_ended(self, &address);
10932 }
10933 }
10934
10935 fn reconcile_managed_record(
10936 &self,
10937 record_id: &SessionRecordId,
10938 address: &SessionAddress,
10939 event: &ControlEvent,
10940 identity_admitted: bool,
10941 resume_command_rejected: bool,
10942 ) {
10943 let _transaction = self
10944 .state_transaction
10945 .lock()
10946 .unwrap_or_else(|poisoned| poisoned.into_inner());
10947 let observed_identity = match &event.event {
10948 ControlEventKind::ProviderEvent {
10949 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10950 ..
10951 } if identity_admitted => Some(identity.clone()),
10952 _ => None,
10953 };
10954 let identity_was_observed = observed_identity.is_some();
10955 let replacement_id = observed_identity
10956 .as_ref()
10957 .and_then(|_| self.allocate_record_id().ok());
10958 let mut upserts = Vec::new();
10959 let mut removals = Vec::new();
10960 let mut rebind = None;
10961 let original_records;
10962 let original_materializations = self
10963 .materializations
10964 .lock()
10965 .unwrap_or_else(|poisoned| poisoned.into_inner())
10966 .clone();
10967 {
10968 let mut records = self
10969 .session_records
10970 .lock()
10971 .unwrap_or_else(|poisoned| poisoned.into_inner());
10972 original_records = records.records.clone();
10973 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10974 return;
10975 };
10976 if let Some(identity) = observed_identity {
10977 let existing_id = records
10978 .records
10979 .values()
10980 .find(|record| {
10981 record.record_id != *record_id
10982 && record.provider_session.as_ref().is_some_and(|record_identity| {
10983 session_registry::same_provider_session(
10984 &record.provider,
10985 record_identity,
10986 ¤t_snapshot.provider,
10987 &identity,
10988 )
10989 })
10990 })
10991 .map(|record| record.record_id.clone());
10992 if let Some(existing_id) = existing_id {
10993 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10994 record.mode == current_snapshot.mode
10995 && record.workspace_id == current_snapshot.workspace_id
10996 && record.environment_profile == current_snapshot.environment_profile
10997 && record.bundle == current_snapshot.bundle
10998 && record.context_id == current_snapshot.context_id
10999 && record.context == current_snapshot.context
11000 && record
11001 .canonical_root
11002 .as_utf8()
11003 .zip(current_snapshot.canonical_root.as_utf8())
11004 .is_some_and(|(left, right)| platform::roots_equal(left, right))
11005 });
11006 if !scope_matches {
11007 if let Some(current) = records.records.get_mut(record_id) {
11008 current.state = ManagedSessionState::Unavailable;
11009 current.last_error =
11010 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11011 current.updated_at_unix_ms = unix_time_ms();
11012 upserts.push(current.clone());
11013 }
11014 } else {
11015 let existing_is_active = records
11016 .records
11017 .get(&existing_id)
11018 .is_some_and(|record| record.active_session.is_some());
11019 if existing_is_active {
11020 if let Some(current) = records.records.get_mut(record_id) {
11021 current.state = ManagedSessionState::Unavailable;
11022 current.last_error =
11023 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11024 current.updated_at_unix_ms = unix_time_ms();
11025 upserts.push(current.clone());
11026 }
11027 } else {
11028 if current_snapshot.provider_session.is_some() {
11029 if let Some(current) = records.records.get_mut(record_id) {
11030 current.active_session = None;
11031 current.state = ManagedSessionState::Dormant;
11032 current.updated_at_unix_ms = unix_time_ms();
11033 upserts.push(current.clone());
11034 }
11035 } else {
11036 records.records.remove(record_id);
11037 removals.push(record_id.clone());
11038 }
11039 if let Some(existing) = records.records.get_mut(&existing_id) {
11040 existing.provider_session = Some(identity.clone());
11041 existing.active_session = Some(address.clone());
11042 existing.state = ManagedSessionState::Live;
11043 existing.updated_at_unix_ms = unix_time_ms();
11044 existing.last_error = None;
11045 upserts.push(existing.clone());
11046 }
11047 rebind = Some(existing_id);
11048 }
11049 }
11050 } else if current_snapshot
11051 .provider_session
11052 .as_ref()
11053 .is_some_and(|current| {
11054 !session_registry::same_provider_session(
11055 ¤t_snapshot.provider,
11056 current,
11057 ¤t_snapshot.provider,
11058 &identity,
11059 )
11060 })
11061 {
11062 if let Some(current) = records.records.get_mut(record_id) {
11063 current.active_session = None;
11064 if current.bundle.is_some() || current.context.is_some() {
11065 let had_bundle = current.bundle.is_some();
11066 current.provider_session = None;
11067 current.bundle = None;
11068 current.context_id = None;
11069 current.context = None;
11070 current.state = ManagedSessionState::Unavailable;
11071 current.last_error = Some(if had_bundle {
11072 "bundle-unavailable".to_owned()
11073 } else {
11074 "context-unavailable".to_owned()
11075 });
11076 } else {
11077 current.state = ManagedSessionState::Dormant;
11078 }
11079 current.updated_at_unix_ms = unix_time_ms();
11080 upserts.push(current.clone());
11081 }
11082 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11083 if let Some(current) = records.records.get_mut(record_id) {
11084 current.state = ManagedSessionState::Unavailable;
11085 current.last_error =
11086 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11087 current.updated_at_unix_ms = unix_time_ms();
11088 upserts.push(current.clone());
11089 }
11090 } else if let Some(new_record_id) = replacement_id {
11091 let now = unix_time_ms();
11092 let replacement = ManagedSessionRecord {
11093 record_id: new_record_id.clone(),
11094 display_name: format!(
11095 "{} #{}",
11096 current_snapshot.provider.as_str(),
11097 records
11098 .records
11099 .values()
11100 .filter(|record| {
11101 record.provider == current_snapshot.provider
11102 })
11103 .count()
11104 .saturating_add(1),
11105 ),
11106 provider: current_snapshot.provider.clone(),
11107 mode: current_snapshot.mode,
11108 state: ManagedSessionState::Live,
11109 workspace_id: current_snapshot.workspace_id,
11110 canonical_root: current_snapshot.canonical_root,
11111 provider_session: Some(identity),
11112 active_session: Some(address.clone()),
11113 environment_profile: current_snapshot.environment_profile,
11114 bundle: current_snapshot.bundle,
11115 context_id: current_snapshot.context_id,
11116 context: current_snapshot.context,
11117 exported_context: None,
11123 task_binding: current_snapshot.task_binding,
11124 created_at_unix_ms: now,
11125 updated_at_unix_ms: now,
11126 last_error: None,
11127 };
11128 records
11129 .records
11130 .insert(new_record_id.clone(), replacement.clone());
11131 upserts.push(replacement);
11132 rebind = Some(new_record_id);
11133 } else if let Some(current) = records.records.get_mut(record_id) {
11134 current.state = ManagedSessionState::Unavailable;
11135 current.last_error =
11136 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11137 upserts.push(current.clone());
11138 }
11139 } else if let Some(current) = records.records.get_mut(record_id) {
11140 current.provider_session = Some(identity);
11141 current.active_session = Some(address.clone());
11142 current.state = ManagedSessionState::Live;
11143 current.updated_at_unix_ms = unix_time_ms();
11144 current.last_error = None;
11145 upserts.push(current.clone());
11146 }
11147 } else if let Some(current) = records.records.get_mut(record_id) {
11148 if resume_command_rejected {
11149 current.active_session = None;
11150 current.state = detached_record_state(current);
11151 current.updated_at_unix_ms = unix_time_ms();
11152 current.last_error =
11153 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11154 upserts.push(current.clone());
11155 } else {
11156 match &event.event {
11157 ControlEventKind::Running { .. }
11158 | ControlEventKind::ResumeAuthorized { .. }
11159 | ControlEventKind::Resumed { .. } => {
11160 current.active_session = Some(address.clone());
11161 current.state = active_record_state(
11162 identity_admitted,
11163 current.provider_session.is_some(),
11164 );
11165 current.updated_at_unix_ms = unix_time_ms();
11166 current.last_error = None;
11167 upserts.push(current.clone());
11168 }
11169 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11170 current.active_session = None;
11171 current.state = detached_record_state(current);
11172 current.updated_at_unix_ms = unix_time_ms();
11173 upserts.push(current.clone());
11174 }
11175 ControlEventKind::Failed { message }
11176 | ControlEventKind::ResumeFailed { message } => {
11177 current.active_session = None;
11178 current.state = detached_record_state(current);
11179 current.updated_at_unix_ms = unix_time_ms();
11180 current.last_error = Some(
11181 session_registry::sanitized_record_error_summary(message),
11182 );
11183 upserts.push(current.clone());
11184 }
11185 ControlEventKind::ResumeDenied { reason } => {
11186 current.active_session = None;
11187 current.state = detached_record_state(current);
11188 current.updated_at_unix_ms = unix_time_ms();
11189 current.last_error = Some(
11190 session_registry::sanitized_record_error_summary(reason),
11191 );
11192 upserts.push(current.clone());
11193 }
11194 _ => {}
11195 }
11196 }
11197 }
11198 }
11199 let materialization_owner_record = identity_was_observed.then(|| {
11200 rebind.clone().unwrap_or_else(|| record_id.clone())
11201 }).and_then(|candidate| {
11202 self.session_records
11203 .lock()
11204 .unwrap_or_else(|poisoned| poisoned.into_inner())
11205 .records
11206 .get(&candidate)
11207 .filter(|record| {
11208 record.provider_session.is_some()
11209 && record.active_session.as_ref() == Some(address)
11210 })
11211 .map(|_| candidate)
11212 });
11213 if let Some(owner_record_id) = materialization_owner_record {
11214 let materialization_id = self
11215 .session_bindings
11216 .lock()
11217 .unwrap_or_else(|poisoned| poisoned.into_inner())
11218 .get(&address.session.instance_id)
11219 .and_then(|binding| binding.materialization_id.clone());
11220 if let Some(materialization_id) = materialization_id {
11221 let mut transfer_failed = false;
11222 {
11223 let mut materializations = self
11224 .materializations
11225 .lock()
11226 .unwrap_or_else(|poisoned| poisoned.into_inner());
11227 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11228 match ownership.owner() {
11229 MaterializationOwner::Session {
11230 incarnation_id,
11231 instance_id,
11232 generation,
11233 } if *incarnation_id == self.incarnation_id
11234 && *instance_id == address.session.instance_id
11235 && *generation == address.session.generation =>
11236 {
11237 transfer_failed = ownership
11238 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11239 .is_err();
11240 }
11241 MaterializationOwner::Record { record_id }
11242 if record_id == &owner_record_id => {}
11243 MaterializationOwner::Record { record_id } => {
11244 let expected = record_id.clone();
11245 transfer_failed = ownership
11246 .transfer_record_owner(
11247 &expected,
11248 owner_record_id.clone(),
11249 unix_time_ms(),
11250 )
11251 .is_err();
11252 }
11253 _ => transfer_failed = true,
11254 }
11255 if transfer_failed {
11256 let _ = ownership.mark_recovery_required(unix_time_ms());
11257 }
11258 } else {
11259 transfer_failed = true;
11260 }
11261 }
11262 if transfer_failed {
11263 if let Some(record) = self
11264 .session_records
11265 .lock()
11266 .unwrap_or_else(|poisoned| poisoned.into_inner())
11267 .records
11268 .get_mut(&owner_record_id)
11269 {
11270 record.state = ManagedSessionState::Unavailable;
11271 record.last_error = Some("environment-profile-unavailable".to_owned());
11272 record.updated_at_unix_ms = unix_time_ms();
11273 upserts.push(record.clone());
11274 }
11275 }
11276 }
11277 }
11278 if let Some(new_record_id) = rebind.clone() {
11279 if let Some(binding) = self
11280 .session_bindings
11281 .lock()
11282 .unwrap_or_else(|poisoned| poisoned.into_inner())
11283 .get_mut(&address.session.instance_id)
11284 {
11285 binding.record_id = Some(new_record_id);
11286 }
11287 }
11288 if upserts.is_empty() && removals.is_empty() {
11289 return;
11290 }
11291 if self.persist_state_locked().is_err() {
11292 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11293 let mut records = self
11294 .session_records
11295 .lock()
11296 .unwrap_or_else(|poisoned| poisoned.into_inner());
11297 records.records = original_records;
11298 *self
11299 .materializations
11300 .lock()
11301 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11302 if let Some(current) = records.records.get_mut(record_id) {
11303 current.state = ManagedSessionState::Unavailable;
11304 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11305 current.updated_at_unix_ms = unix_time_ms();
11306 upserts = vec![current.clone()];
11307 } else {
11308 upserts.clear();
11309 }
11310 removals.clear();
11311 drop(records);
11312 if let Some(binding) = self
11313 .session_bindings
11314 .lock()
11315 .unwrap_or_else(|poisoned| poisoned.into_inner())
11316 .get_mut(&address.session.instance_id)
11317 {
11318 binding.record_id = Some(record_id.clone());
11319 }
11320 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11321 }
11322 for record in upserts {
11323 self.publish_record(record);
11324 }
11325 for record_id in removals {
11326 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11327 }
11328 }
11329
11330 fn snapshot(&self) -> NodeSnapshot {
11331 let control = self.handle.snapshot();
11332 let bindings = self
11333 .session_bindings
11334 .lock()
11335 .unwrap_or_else(|poisoned| poisoned.into_inner())
11336 .clone();
11337 let workspace_roots = self
11338 .workspaces
11339 .read()
11340 .unwrap_or_else(|poisoned| poisoned.into_inner())
11341 .clone();
11342 let environment_profiles = self
11343 .environment_profiles
11344 .read()
11345 .unwrap_or_else(|poisoned| poisoned.into_inner());
11346 let mut workspaces = workspace_roots
11347 .iter()
11348 .map(|(workspace_id, canonical_root)| {
11349 (
11350 workspace_id.clone(),
11351 WorkspaceSnapshot {
11352 workspace_id: workspace_id.clone(),
11353 canonical_root: opaque_windows_path(canonical_root.clone()),
11354 sessions: Vec::new(),
11355 worktree_service_mode: Some(
11356 self.worktree_service_modes
11357 .get(workspace_id)
11358 .copied()
11359 .unwrap_or(WorktreeServiceMode::Manual),
11360 ),
11361 managed_worktree_profiles: Some(WorktreeProfileInventory {
11362 profiles: self
11363 .managed_worktree_profiles
11364 .get(workspace_id)
11365 .into_iter()
11366 .flat_map(BTreeMap::values)
11367 .map(|profile| ManagedWorktreeProfileSummary {
11368 id: profile.profile_id().clone(),
11369 revision: profile.revision().clone(),
11370 retention: profile.retention(),
11371 })
11372 .collect(),
11373 }),
11374 },
11375 )
11376 })
11377 .collect::<BTreeMap<_, _>>();
11378 let mut agent_progress = Vec::new();
11379 for session in &control.sessions {
11380 let Some(binding) = bindings.get(&session.instance_id) else {
11381 continue;
11382 };
11383 if binding.generation != session.generation {
11384 continue;
11385 }
11386 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11387 let address = SessionAddress {
11388 workspace_id: binding.workspace_id.clone(),
11389 session: SessionKey {
11390 instance_id: session.instance_id,
11391 generation: session.generation,
11392 },
11393 };
11394 if let Some(progress) =
11395 agent_progress_from_provider_snapshot(address, &session.provider)
11396 {
11397 agent_progress.push(progress);
11398 }
11399 workspace.sessions.push(session.clone());
11400 }
11401 }
11402 NodeSnapshot {
11403 node_id: self.node_id.clone(),
11404 enabled_providers: self.enabled_providers.clone(),
11405 provider_runtime_statuses: {
11406 let updates = self
11407 .provider_runtime_status_updates
11408 .read()
11409 .unwrap_or_else(|poisoned| poisoned.into_inner());
11410 ProviderRuntimeStatuses::new(
11411 self.provider_runtime_statuses
11412 .iter()
11413 .filter(|status| !updates.contains_key(status.provider()))
11414 .cloned()
11415 .chain(updates.values().cloned()),
11416 )
11417 .expect("refreshed provider runtime inventory remains bounded and unique")
11418 },
11419 workspaces: workspaces.into_values().collect(),
11420 session_records: self
11421 .session_records
11422 .lock()
11423 .unwrap_or_else(|poisoned| poisoned.into_inner())
11424 .records
11425 .values()
11426 .cloned()
11427 .collect(),
11428 managed_worktrees: self.managed_worktrees
11429 .lock()
11430 .unwrap_or_else(|poisoned| poisoned.into_inner())
11431 .snapshots(),
11432 launch_inventory: Some(LaunchInventory {
11433 spawn_profiles: Some(
11434 self.spawn_profiles
11435 .iter()
11436 .filter_map(|profile| {
11437 resolved_spawn_profile_summary(profile, &environment_profiles)
11438 })
11439 .collect(),
11440 ),
11441 bundles: Some(
11442 self.bundle_catalog
11443 .read()
11444 .unwrap_or_else(|poisoned| poisoned.into_inner())
11445 .iter()
11446 .map(NodeBundle::receipt)
11447 .collect(),
11448 ),
11449 network_allowlists: {
11452 let catalog = self
11453 .network_allowlist_catalog
11454 .read()
11455 .unwrap_or_else(|poisoned| poisoned.into_inner());
11456 if catalog.is_empty() {
11457 None
11458 } else {
11459 Some(catalog.ids().cloned().collect())
11460 }
11461 },
11462 }),
11463 agent_progress,
11464 }
11465 }
11466
11467 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11468 assert!(
11469 !matches!(
11470 &event,
11471 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11472 ),
11473 "terminal frame and agent stream events must use their own dedicated live channel",
11474 );
11475 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11476 let sequence = history
11477 .last_sequence
11478 .checked_add(1)
11479 .expect("node event sequence exhausted");
11480 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11481 if let Some(evicted) = history.events.pop_front() {
11482 history.replay_floor_sequence = evicted
11483 .sequence
11484 .checked_add(1)
11485 .expect("node replay floor sequence exhausted");
11486 history.removed_record_providers.remove(&evicted.sequence);
11487 }
11488 }
11489 match &event {
11490 NodeEvent::HarnessMcpReadCall { .. } => {
11491 panic!("transient harness MCP calls must not enter durable event history")
11492 }
11493 NodeEvent::SessionRecordUpserted { record } => {
11494 history
11495 .record_providers
11496 .insert(record.record_id.clone(), record.provider.clone());
11497 }
11498 NodeEvent::SessionRecordRemoved { record_id } => {
11499 if let Some(provider) = history.record_providers.remove(record_id) {
11500 history.removed_record_providers.insert(sequence, provider);
11501 }
11502 }
11503 NodeEvent::ManagedWorktreeUpserted { .. }
11504 | NodeEvent::ManagedWorktreeRemoved { .. }
11505 | NodeEvent::Control { .. }
11506 | NodeEvent::ControllerChanged { .. }
11507 | NodeEvent::SessionRecordHistorySummarized { .. }
11508 | NodeEvent::WorkspaceAdded { .. }
11509 | NodeEvent::WorkspaceRemoved { .. }
11510 | NodeEvent::ResyncRequired { .. }
11511 | NodeEvent::AgentStream { .. }
11512 | NodeEvent::TerminalFrame { .. } => {}
11513 }
11514 let envelope = NodeEventEnvelope { sequence, event };
11515 history.events.push_back(envelope.clone());
11516 history.last_sequence = sequence;
11517 let _ = self.event_tx.send(envelope.clone());
11518 drop(history);
11519 envelope
11520 }
11521
11522 fn publish_transient(&self, event: NodeEvent) -> bool {
11533 assert!(event.requires_harness_mcp_proxy_capability());
11534 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11535 let envelope = NodeEventEnvelope { sequence: 0, event };
11536 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11537 Ok(_) => true,
11538 Err(broadcast::error::SendError(undelivered)) => {
11539 if let Some(NodeEventEnvelope {
11540 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11541 ..
11542 }) = undelivered.first() {
11543 tracing::warn!(
11549 call_id = call_id.as_str(),
11550 content_type = ?request.content_type,
11551 cause = "no-subscribers",
11552 "harness MCP proxy event could not be published",
11553 );
11554 }
11555 false
11556 }
11557 }
11558 }
11559
11560 fn publish_terminal_frames(&self) {
11561 let snapshot = self.handle.snapshot();
11562 let bindings = self
11563 .session_bindings
11564 .lock()
11565 .unwrap_or_else(|poisoned| poisoned.into_inner());
11566 let candidates = snapshot
11567 .sessions
11568 .iter()
11569 .filter_map(|session| {
11570 let binding = bindings.get(&session.instance_id)?;
11571 if binding.generation != session.generation {
11572 return None;
11573 }
11574 let frame = session.terminal_frame.clone()?;
11575 crate::session_signals::feed(
11576 binding.workspace_id.as_str(),
11577 session.instance_id.0,
11578 session.agent_id.as_str(),
11579 &frame.contents,
11580 );
11581 crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11582 crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11583 Some((
11584 SessionAddress {
11585 workspace_id: binding.workspace_id.clone(),
11586 session: SessionKey {
11587 instance_id: session.instance_id,
11588 generation: session.generation,
11589 },
11590 },
11591 frame,
11592 ))
11593 })
11594 .collect();
11595 drop(bindings);
11596 self.publish_terminal_frame_candidates(candidates);
11597 }
11598
11599 fn publish_terminal_frame_candidates(
11600 &self,
11601 candidates: Vec<(SessionAddress, TerminalFrame)>,
11602 ) {
11603 let mut watermarks = self
11604 .terminal_frame_watermarks
11605 .lock()
11606 .unwrap_or_else(|poisoned| poisoned.into_inner());
11607 let mut events = Vec::with_capacity(candidates.len());
11608 for (address, frame) in candidates {
11609 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11610 let advanced = watermarks
11611 .get(&address.session.instance_id)
11612 .map_or(true, |(current_address, sequence)| {
11613 current_address != &address || frame.sequence > *sequence
11614 });
11615 if !advanced {
11616 continue;
11617 }
11618 if is_first_frame {
11619 if self.controller_state().is_none() {
11633 tracing::warn!(
11634 node_id = %self.node_id,
11635 workspace_id = %address.workspace_id,
11636 session = ?address.session,
11637 terminal_size = ?frame.size,
11638 "session producing output with no controller attached",
11639 );
11640 } else {
11641 tracing::info!(
11642 node_id = %self.node_id,
11643 workspace_id = %address.workspace_id,
11644 session = ?address.session,
11645 terminal_size = ?frame.size,
11646 "session produced its first output",
11647 );
11648 }
11649 }
11650 watermarks.insert(
11651 address.session.instance_id,
11652 (address.clone(), frame.sequence),
11653 );
11654 if let Some((at, reported)) = self
11655 .pty_input_marks
11656 .lock()
11657 .unwrap_or_else(|poisoned| poisoned.into_inner())
11658 .get_mut(&address.session.instance_id)
11659 {
11660 if !*reported && at.elapsed() < Duration::from_secs(5) {
11662 *reported = true;
11663 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");
11664 }
11665 }
11666 events.push(NodeEvent::TerminalFrame { address, frame });
11667 }
11668 drop(watermarks);
11669 if events.is_empty() {
11670 return;
11671 }
11672 let mut history = self
11673 .history
11674 .lock()
11675 .unwrap_or_else(|poisoned| poisoned.into_inner());
11676 let mut envelopes = Vec::with_capacity(events.len());
11677 for event in events {
11678 let sequence = history
11679 .last_sequence
11680 .checked_add(1)
11681 .expect("node event sequence exhausted");
11682 history.last_sequence = sequence;
11683 envelopes.push(NodeEventEnvelope { sequence, event });
11684 }
11685 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11686 drop(history);
11687 }
11688
11689 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11698 let mut history = self
11699 .history
11700 .lock()
11701 .unwrap_or_else(|poisoned| poisoned.into_inner());
11702 let sequence = history
11703 .last_sequence
11704 .checked_add(1)
11705 .expect("node event sequence exhausted");
11706 history.last_sequence = sequence;
11707 drop(history);
11708 tracing::debug!(
11716 source_sequence = chunk.source_sequence,
11717 kind = agent_stream_chunk_kind_label(&chunk.kind),
11718 envelope_sequence = sequence,
11719 "publishing agent stream chunk",
11720 );
11721 let envelope = NodeEventEnvelope {
11722 sequence,
11723 event: NodeEvent::AgentStream { address, chunk },
11724 };
11725 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11726 }
11727
11728 fn current_sequence(&self) -> u64 {
11729 self.history
11730 .lock()
11731 .unwrap_or_else(|poisoned| poisoned.into_inner())
11732 .last_sequence
11733 }
11734
11735 fn controller_state(&self) -> Option<ControllerState> {
11736 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11737 let lease = controller.as_ref().copied()?;
11738 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11739 if remaining.is_zero() {
11740 *controller = None;
11741 return None;
11742 }
11743 Some(ControllerState {
11744 connection_id: lease.connection_id,
11745 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11746 })
11747 }
11748
11749 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11750 if role != ClientRole::Operator {
11751 tracing::warn!(
11752 node_id = %self.node_id,
11753 connection_id,
11754 "controller lease rejected: observer connections cannot acquire control",
11755 );
11756 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11757 }
11758 let lease_ms = if lease_ms == 0 {
11759 DEFAULT_CONTROLLER_LEASE_MS
11760 } else {
11761 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11762 };
11763 let now = Instant::now();
11764 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11765 if let Some(active) = controller.as_ref() {
11766 if active.connection_id != connection_id && active.expires_at > now {
11767 tracing::warn!(
11768 node_id = %self.node_id,
11769 connection_id,
11770 holder_connection_id = active.connection_id,
11771 "controller lease rejected: another operator holds it",
11772 );
11773 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11774 }
11775 }
11776 let is_new_attach = match controller.as_ref() {
11783 Some(active) => active.connection_id != connection_id,
11784 None => true,
11785 };
11786 *controller = Some(ControllerLease {
11787 connection_id,
11788 expires_at: now + Duration::from_millis(lease_ms),
11789 });
11790 drop(controller);
11791 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11792 if is_new_attach {
11793 tracing::info!(
11794 node_id = %self.node_id,
11795 connection_id,
11796 lease_ms,
11797 "controller lease acquired",
11798 );
11799 } else {
11800 tracing::debug!(
11801 node_id = %self.node_id,
11802 connection_id,
11803 lease_ms,
11804 "controller lease renewed",
11805 );
11806 }
11807 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11808 Ok(state)
11809 }
11810
11811 fn release_controller(&self, connection_id: u64) -> bool {
11812 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11813 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11814 if released {
11815 *controller = None;
11816 }
11817 drop(controller);
11818 if released {
11819 tracing::info!(
11820 node_id = %self.node_id,
11821 connection_id,
11822 "controller lease released",
11823 );
11824 self.publish(NodeEvent::ControllerChanged { controller: None });
11825 }
11826 released
11827 }
11828
11829 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11838 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11839 return;
11840 };
11841 let id = address.session.instance_id;
11842 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11843 return;
11844 };
11845 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11846 .iter()
11847 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11848 .collect();
11849 let (writes, set_model, effort_gap) =
11850 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11851 if set_model {
11852 if let Some(model_id) = model.clone() {
11853 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11854 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11855 }
11856 }
11857 }
11858 for (option_id, value_json) in writes {
11859 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11860 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11861 }
11862 }
11863 if effort_gap {
11864 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11865 }
11866 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11867 }
11868
11869 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11872 let id = address.session.instance_id;
11873 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11874 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11875 crate::auto_permit::forget_acp(id.0);
11876 return;
11877 }
11878 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11879 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11880 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11881 }
11882 match provider_event {
11883 ProviderEvent::ModeChanged { mode_id, available } => {
11884 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11885 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11886 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11887 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11888 }
11889 }
11890 }
11891 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11892 match self.dispatch(ControlCommand::ResolveInteraction {
11893 instance_id: id,
11894 generation: address.session.generation,
11895 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11896 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11897 }) {
11898 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11899 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11900 }
11901 }
11902 _ => {}
11905 }
11906 }
11907
11908 fn mark_spawn_provisional(&self, address: SessionAddress) {
11909 let id = address.session.instance_id;
11910 self.provisional_spawns
11911 .lock()
11912 .unwrap_or_else(|poisoned| poisoned.into_inner())
11913 .insert(id, (address, Instant::now()));
11914 tracing::info!(
11915 node_id = %self.node_id,
11916 instance_id = id.0,
11917 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11918 "draft spawn; waiting for HQ view",
11919 );
11920 }
11921
11922 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11923 let removed = self
11924 .provisional_spawns
11925 .lock()
11926 .unwrap_or_else(|poisoned| poisoned.into_inner())
11927 .remove(&instance_id);
11928 if removed.is_some() {
11929 tracing::info!(
11930 node_id = %self.node_id,
11931 instance_id = instance_id.0,
11932 "draft spawn claimed by HQ view",
11933 );
11934 }
11935 }
11936
11937 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11938 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11939 let mut map = self
11940 .provisional_spawns
11941 .lock()
11942 .unwrap_or_else(|poisoned| poisoned.into_inner());
11943 let due: Vec<AgentInstanceId> = map
11944 .iter()
11945 .filter(|(_, (_, at))| at.elapsed() >= grace)
11946 .map(|(id, _)| *id)
11947 .collect();
11948 due.into_iter()
11949 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11950 .collect()
11951 }
11952
11953 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11954 let instance_id = address.session.instance_id;
11955 tracing::warn!(
11956 node_id = %self.node_id,
11957 instance_id = instance_id.0,
11958 workspace = %address.workspace_id,
11959 reason = "attach_never_arrived",
11960 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11961 "draft spawn never viewed; stopping PTY",
11962 );
11963 let _ = self.dispatch(ControlCommand::Stop {
11964 instance_id,
11965 force: true,
11966 });
11967 let _ = self.remove_session(&address).await;
11968 }
11969
11970 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11971 if role != ClientRole::Operator {
11972 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11973 }
11974 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11975 let now = Instant::now();
11976 match controller.as_ref().copied() {
11977 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11978 Some(active) if active.expires_at <= now => {
11979 *controller = None;
11980 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11981 }
11982 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11983 }
11984 }
11985
11986 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11987 if !self
11988 .workspaces
11989 .read()
11990 .unwrap_or_else(|poisoned| poisoned.into_inner())
11991 .contains_key(&address.workspace_id)
11992 {
11993 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11994 }
11995 let binding = self
11996 .session_bindings
11997 .lock()
11998 .unwrap_or_else(|poisoned| poisoned.into_inner())
11999 .get(&address.session.instance_id)
12000 .cloned()
12001 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12002 if binding.workspace_id != address.workspace_id {
12003 return Err(failure(
12004 NodeFailureCode::SessionWorkspaceMismatch,
12005 "session belongs to another workspace",
12006 ));
12007 }
12008 if binding.generation != address.session.generation {
12009 return Err(failure(
12010 NodeFailureCode::StaleGeneration,
12011 &format!(
12012 "stale session generation: expected {}, received {}",
12013 binding.generation.0,
12014 address.session.generation.0,
12015 ),
12016 ));
12017 }
12018 let snapshot = self.handle.snapshot();
12019 let Some(current) = snapshot
12020 .sessions
12021 .iter()
12022 .find(|item| item.instance_id == address.session.instance_id)
12023 else {
12024 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12025 };
12026 if current.generation != address.session.generation {
12027 return Err(failure(
12028 NodeFailureCode::StaleGeneration,
12029 &format!(
12030 "stale session generation: expected {}, received {}",
12031 current.generation.0,
12032 address.session.generation.0,
12033 ),
12034 ));
12035 }
12036 Ok(current.agent_id.clone())
12037 }
12038
12039 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12040 self.dispatch_envelope(self.prepare_command(command))
12041 }
12042
12043 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12044 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12045 CommandEnvelope {
12046 id: CommandId(id),
12047 command,
12048 }
12049 }
12050
12051 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12052 let command_id = envelope.id;
12053 self.handle
12054 .dispatch(envelope)
12055 .map_err(|error| match error {
12056 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12057 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12058 })?;
12059 Ok(command_id)
12060 }
12061
12062 fn record_rejected_command(
12071 &self,
12072 command_id: CommandId,
12073 message: String,
12074 unsupported_transport: bool,
12075 ) {
12076 let now = Instant::now();
12077 let mut reasons = self.rejected_command_reasons.lock()
12078 .unwrap_or_else(|poisoned| poisoned.into_inner());
12079 reasons.retain(|_, reason| reason.expires_at > now);
12080 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12081 if let Some(&oldest) = reasons.keys().next() {
12082 reasons.remove(&oldest);
12083 }
12084 }
12085 reasons.insert(
12086 command_id,
12087 RejectedCommandReason {
12088 message,
12089 unsupported_transport,
12090 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12091 },
12092 );
12093 }
12094
12095 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12102 let now = Instant::now();
12103 let mut reasons = self.rejected_command_reasons.lock()
12104 .unwrap_or_else(|poisoned| poisoned.into_inner());
12105 match reasons.remove(&command_id) {
12106 Some(reason) if reason.expires_at > now => Some(reason),
12107 _ => None,
12108 }
12109 }
12110
12111 async fn dispatch_bounded(
12112 &self,
12113 command: ControlCommand,
12114 timeout_duration: Duration,
12115 ) -> Result<CommandId, NodeFailure> {
12116 let deadline = Instant::now() + timeout_duration;
12117 loop {
12118 match self.dispatch(command.clone()) {
12119 Ok(command_id) => return Ok(command_id),
12120 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12121 sleep(Duration::from_millis(2)).await;
12122 }
12123 Err(error) => return Err(error),
12124 }
12125 }
12126 }
12127
12128 fn internal_session_snapshot(
12129 &self,
12130 address: &SessionAddress,
12131 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12132 self.validate_address(address)?;
12133 self.handle
12134 .snapshot()
12135 .sessions
12136 .clone()
12137 .into_iter()
12138 .find(|session| {
12139 session.instance_id == address.session.instance_id
12140 && session.generation == address.session.generation
12141 })
12142 .ok_or_else(|| {
12143 failure(
12144 NodeFailureCode::UnknownSession,
12145 "session snapshot is unavailable",
12146 )
12147 })
12148 }
12149
12150 fn require_acp_session(
12158 &self,
12159 address: &SessionAddress,
12160 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12161 let snapshot = self.internal_session_snapshot(address)?;
12162 if snapshot.transport != TransportKind::Acp {
12163 return Err(failure(
12164 NodeFailureCode::UnsupportedTransport,
12165 "this verb requires a session spawned over the ACP transport",
12166 ));
12167 }
12168 Ok(snapshot)
12169 }
12170
12171 async fn dispatch_history_bounded(
12172 &self,
12173 address: &SessionAddress,
12174 command: ControlCommand,
12175 operation: HistoryOperation,
12176 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12177 self.validate_address(address)?;
12178 let started_after = self.current_sequence();
12179 let command_id = self
12180 .dispatch_bounded(
12181 command,
12182 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12183 )
12184 .await?;
12185 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12186 let mut scan_after = started_after;
12187 let mut accepted_after = None;
12188 loop {
12189 let events = self
12190 .history
12191 .lock()
12192 .unwrap_or_else(|poisoned| poisoned.into_inner())
12193 .events
12194 .iter()
12195 .filter(|envelope| envelope.sequence > scan_after)
12196 .cloned()
12197 .collect::<Vec<_>>();
12198 for envelope in events {
12199 scan_after = scan_after.max(envelope.sequence);
12200 let NodeEvent::Control {
12201 address: event_address,
12202 event,
12203 } = envelope.event
12204 else {
12205 continue;
12206 };
12207 if event_address != *address {
12208 continue;
12209 }
12210 if event.command_id == Some(command_id) {
12211 match &event.event {
12212 ControlEventKind::CommandRejected { .. } => {
12213 return Err(failure(
12214 NodeFailureCode::InvalidRequest,
12215 "history request was rejected",
12216 ));
12217 }
12218 ControlEventKind::HistoryRequested {
12219 operation: requested,
12220 ..
12221 } if requested == &operation => {
12222 accepted_after = Some(envelope.sequence);
12223 continue;
12224 }
12225 _ => {}
12226 }
12227 }
12228 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12229 match (&operation, &event.event) {
12230 (
12231 HistoryOperation::Discover { .. },
12232 ControlEventKind::HistoryDiscovered { .. },
12233 )
12234 | (
12235 HistoryOperation::Load { .. },
12236 ControlEventKind::HistoryLoaded { .. },
12237 ) => return self.internal_session_snapshot(address),
12238 (_, ControlEventKind::HistoryFailed { .. }) => {
12239 return Err(failure(
12240 NodeFailureCode::BackendOperationFailed,
12241 "history operation failed",
12242 ));
12243 }
12244 _ => {}
12245 }
12246 }
12247 }
12248 if Instant::now() >= deadline {
12249 return Err(failure(
12250 NodeFailureCode::BackendBusy,
12251 if accepted_after.is_some() {
12252 "history operation did not settle before the bounded deadline"
12253 } else {
12254 "history operation was not accepted before the bounded deadline"
12255 },
12256 ));
12257 }
12258 sleep(Duration::from_millis(2)).await;
12259 }
12260 }
12261
12262 async fn discover_history(
12263 &self,
12264 address: &SessionAddress,
12265 limit: u16,
12266 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12267 let query = HistoryQuery {
12268 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12269 limit,
12270 };
12271 query
12272 .validate()
12273 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12274 let snapshot = self
12275 .dispatch_history_bounded(
12276 address,
12277 ControlCommand::DiscoverHistory {
12278 instance_id: address.session.instance_id,
12279 query: query.clone(),
12280 },
12281 HistoryOperation::Discover { query },
12282 )
12283 .await?;
12284 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12285 return Err(failure(
12286 NodeFailureCode::BackendOperationFailed,
12287 "history discovery settled into an invalid state",
12288 ));
12289 }
12290 Ok(snapshot.history.candidates)
12291 }
12292
12293 async fn load_history(
12294 &self,
12295 address: &SessionAddress,
12296 candidate_id: String,
12297 ) -> Result<HistorySessionRecord, NodeFailure> {
12298 validate_candidate_id(&candidate_id)
12299 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12300 let snapshot = self
12301 .dispatch_history_bounded(
12302 address,
12303 ControlCommand::LoadHistory {
12304 instance_id: address.session.instance_id,
12305 candidate_id: candidate_id.clone(),
12306 },
12307 HistoryOperation::Load {
12308 candidate_id: candidate_id.clone(),
12309 },
12310 )
12311 .await?;
12312 if snapshot.history.pending.is_some()
12313 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12314 {
12315 return Err(failure(
12316 NodeFailureCode::BackendOperationFailed,
12317 "history load settled into an invalid state",
12318 ));
12319 }
12320 snapshot.history.loaded.ok_or_else(|| {
12321 failure(
12322 NodeFailureCode::BackendOperationFailed,
12323 "history load produced no bounded session",
12324 )
12325 })
12326 }
12327
12328 async fn export_context_pack_for_session_record(
12335 &self,
12336 record_id: &SessionRecordId,
12337 session: &SessionAddress,
12338 allow_clean_detachment: bool,
12339 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12340 let outcome = self
12346 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12347 .await;
12348 if let Err(failure) = &outcome {
12349 tracing::warn!(
12350 record = %record_id,
12351 code = ?failure.code,
12352 reason = %failure.message,
12353 "session-record context-pack export refused",
12354 );
12355 }
12356 outcome
12357 }
12358
12359 async fn export_context_pack_for_session_record_inner(
12360 &self,
12361 record_id: &SessionRecordId,
12362 session: &SessionAddress,
12363 allow_clean_detachment: bool,
12364 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12365 let step = |name: &'static str| move |failure: NodeFailure| {
12370 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12371 failure
12372 };
12373 let record = self.record(record_id).map_err(step("record-lookup"))?;
12374 let identity = match record.provider_session.clone() {
12390 Some(identity) => {
12391 if identity.key != ProviderSessionKey::SessionId {
12392 return Err(step("provider-identity-key")(failure(
12393 NodeFailureCode::BackendOperationFailed,
12394 "managed session provider identity cannot bind history exactly",
12395 )));
12396 }
12397 Some(identity)
12398 }
12399 None if record.state == ManagedSessionState::IdentityPending => {
12400 return Err(step("provider-session-identity")(failure(
12401 NodeFailureCode::BackendOperationFailed,
12402 "managed session has not yet observed its provider session identity",
12403 )));
12404 }
12405 None => None,
12406 };
12407 self.revalidate_session_record_context_export(
12408 &record,
12409 identity.as_ref(),
12410 session,
12411 allow_clean_detachment,
12412 ).map_err(step("revalidate-before-discovery"))?;
12413 let candidates = self
12414 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12415 .await
12416 .map_err(step("discover-history"))?;
12417 let candidate_id = select_history_candidate(
12422 &candidates,
12423 identity.as_ref().map(|identity| identity.id.as_str()),
12424 ).map_err(step("unique-candidate"))?;
12425 let loaded = self.load_history(session, candidate_id.clone()).await
12426 .map_err(step("load-history"))?;
12427 if let Some(identity) = identity.as_ref() {
12428 if loaded.session_id != identity.id {
12429 return Err(failure(
12430 NodeFailureCode::SessionRecordConflict,
12431 "loaded history identity does not match the managed session record",
12432 ));
12433 }
12434 }
12435 self.revalidate_session_record_context_export(
12436 &record,
12437 identity.as_ref(),
12438 session,
12439 allow_clean_detachment,
12440 )?;
12441 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12442 .map_err(step("revalidate-loaded-history"))?;
12443 let pack = self.materialize_context_pack(session).await
12444 .map_err(step("materialize-context-pack"))?;
12445 self.commit_context_pack_for_session_record(
12446 &record,
12447 identity.as_ref(),
12448 session,
12449 &candidate_id,
12450 &loaded,
12451 pack,
12452 allow_clean_detachment,
12453 )
12454 }
12455
12456 async fn export_context_pack(
12457 &self,
12458 address: &SessionAddress,
12459 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12460 let pack = self.materialize_context_pack(address).await?;
12461 self.commit_context_pack(pack)
12462 }
12463
12464 async fn materialize_context_pack(
12465 &self,
12466 address: &SessionAddress,
12467 ) -> Result<NodeContextPack, NodeFailure> {
12468 let snapshot = self.internal_session_snapshot(address)?;
12469 if !context_pack_source_status_is_usable(&snapshot.status) {
12470 return Err(failure(
12471 NodeFailureCode::InvalidRequest,
12472 "context export requires a running or successfully exited source session",
12473 ));
12474 }
12475 if snapshot.history.pending.is_some()
12476 || snapshot.history.loaded_candidate_id.is_none()
12477 {
12478 return Err(failure(
12479 NodeFailureCode::InvalidRequest,
12480 "context export requires settled loaded history",
12481 ));
12482 }
12483 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12484 failure(
12485 NodeFailureCode::InvalidRequest,
12486 "context export requires settled loaded history",
12487 )
12488 })?;
12489 let source_provider = snapshot.agent_id.clone();
12490 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12491 let repository = self
12492 .context_pack_repository(&address.workspace_id)
12493 .await?;
12494 let current = self.internal_session_snapshot(address)?;
12495 if !context_pack_source_status_is_usable(¤t.status)
12496 || current.agent_id != source_provider
12497 || current.history.pending.is_some()
12498 || current.history.loaded_candidate_id != loaded_candidate_id
12499 || current.history.loaded.as_ref() != Some(&loaded)
12500 {
12501 return Err(failure(
12502 NodeFailureCode::ContextPackMaterializationFailed,
12503 "context source changed during bounded repository capture",
12504 ));
12505 }
12506 NodeContextPack::export_with_repository(
12507 ContextPackLineageReceipt {
12508 source_node_id: self.node_id.clone(),
12509 source_session: address.clone(),
12510 source_provider,
12511 },
12512 &loaded,
12513 Some(repository),
12514 )
12515 .map_err(|_| {
12516 failure(
12517 NodeFailureCode::ContextPackMaterializationFailed,
12518 "bounded context pack export failed",
12519 )
12520 })
12521 }
12522
12523 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12529 let mut store = self
12530 .context_pack_store
12531 .lock()
12532 .unwrap_or_else(|poisoned| poisoned.into_inner());
12533 let Some(store) = store.as_mut() else {
12534 return Ok(());
12535 };
12536 store.commit(pack).map_err(|_| {
12537 failure(
12538 NodeFailureCode::ContextPackMaterializationFailed,
12539 "durable context pack commit failed",
12540 )
12541 })
12542 }
12543
12544 fn commit_context_pack(
12545 &self,
12546 pack: NodeContextPack,
12547 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12548 self.commit_context_pack_durably(&pack)?;
12549 self.context_catalog
12550 .write()
12551 .unwrap_or_else(|poisoned| poisoned.into_inner())
12552 .insert(pack)
12553 .map_err(|_| {
12554 failure(
12555 NodeFailureCode::ContextPackMaterializationFailed,
12556 "context pack catalog is full",
12557 )
12558 })
12559 }
12560
12561 fn commit_context_pack_for_session_record(
12562 &self,
12563 expected: &ManagedSessionRecord,
12564 identity: Option<&ProviderSessionIdentity>,
12565 session: &SessionAddress,
12566 candidate_id: &str,
12567 loaded: &HistorySessionRecord,
12568 pack: NodeContextPack,
12569 allow_clean_detachment: bool,
12570 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12571 let current_root = self.workspace_root(&expected.workspace_id)?;
12572 let _transaction = self
12573 .state_transaction
12574 .lock()
12575 .unwrap_or_else(|poisoned| poisoned.into_inner());
12576 let records = self
12577 .session_records
12578 .lock()
12579 .unwrap_or_else(|poisoned| poisoned.into_inner());
12580 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12581 failure(
12582 NodeFailureCode::SessionRecordConflict,
12583 "managed session record disappeared during context export",
12584 )
12585 })?;
12586 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12587 return Err(failure(
12588 NodeFailureCode::SessionRecordConflict,
12589 "managed session record changed during context export",
12590 ));
12591 }
12592 let bindings = self
12593 .session_bindings
12594 .lock()
12595 .unwrap_or_else(|poisoned| poisoned.into_inner());
12596 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12597 failure(
12598 NodeFailureCode::SessionRecordConflict,
12599 "managed session runtime binding disappeared during context export",
12600 )
12601 })?;
12602 let runtime = self
12603 .handle
12604 .snapshot()
12605 .sessions
12606 .iter()
12607 .find(|snapshot| {
12608 snapshot.instance_id == session.session.instance_id
12609 && snapshot.generation == session.session.generation
12610 })
12611 .cloned()
12612 .ok_or_else(|| {
12613 failure(
12614 NodeFailureCode::SessionRecordConflict,
12615 "managed session runtime disappeared during context export",
12616 )
12617 })?;
12618 let receipt = pack.receipt();
12619 if !session_record_context_export_binding_is_exact(
12620 expected,
12621 current,
12622 identity,
12623 session,
12624 binding,
12625 &runtime.agent_id,
12626 ¤t_root,
12627 allow_clean_detachment,
12628 ) || !session_record_context_export_source_is_usable(
12629 current,
12630 &runtime.status,
12631 allow_clean_detachment,
12632 ) || runtime.history.pending.is_some()
12633 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12634 || runtime.history.loaded.as_ref() != Some(loaded)
12635 || receipt.lineage.source_node_id != self.node_id
12636 || receipt.lineage.source_session != *session
12637 || receipt.lineage.source_provider != current.provider
12638 || receipt.source_message_count != loaded.message_count
12639 {
12640 return Err(failure(
12641 NodeFailureCode::SessionRecordConflict,
12642 "managed session source changed before context catalog commit",
12643 ));
12644 }
12645 self.commit_context_pack_durably(&pack)?;
12646 self.context_catalog
12647 .write()
12648 .unwrap_or_else(|poisoned| poisoned.into_inner())
12649 .insert(pack)
12650 .map_err(|_| {
12651 failure(
12652 NodeFailureCode::ContextPackMaterializationFailed,
12653 "context pack catalog is full",
12654 )
12655 })
12656 }
12657
12658 async fn context_pack_repository(
12659 &self,
12660 workspace_id: &WorkspaceId,
12661 ) -> Result<ContextPackRepository, NodeFailure> {
12662 let first = self
12663 .context_pack_repository_observation(workspace_id)
12664 .await?;
12665 let second = self
12666 .context_pack_repository_observation(workspace_id)
12667 .await?;
12668 stable_context_pack_repository(first, second)
12669 }
12670
12671 async fn context_pack_repository_observation(
12672 &self,
12673 workspace_id: &WorkspaceId,
12674 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12675 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12676 let head = if git.is_repository {
12677 let root = self.workspace_root(workspace_id)?;
12678 match resolve_base_commit_with_timeout(
12679 &root,
12680 "HEAD",
12681 GIT_COMMAND_TIMEOUT_MS,
12682 )
12683 .await
12684 {
12685 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12686 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12687 ContextPackRepositoryHead::Unborn
12688 }
12689 Err(_) => {
12690 return Err(failure(
12691 NodeFailureCode::ContextPackMaterializationFailed,
12692 "context pack HEAD observation failed",
12693 ));
12694 }
12695 }
12696 } else {
12697 ContextPackRepositoryHead::NotRepository
12698 };
12699 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12700 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12701 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12702 failure(
12703 NodeFailureCode::InvalidRepositoryPath,
12704 "context pack selected file path is invalid",
12705 )
12706 })?;
12707 match self.read_workspace_file(workspace_id.clone(), path).await {
12708 Ok(WorkspaceFileRead {
12709 content: WorkspaceFileContent::Utf8 { text, byte_len },
12710 ..
12711 }) => files.push(ContextPackRepositoryFileSource::utf8(
12712 selected_path,
12713 text,
12714 byte_len,
12715 )),
12716 Ok(WorkspaceFileRead {
12717 content: WorkspaceFileContent::NonUtf8 { byte_len },
12718 ..
12719 }) => files.push(ContextPackRepositoryFileSource::skipped(
12720 selected_path,
12721 ContextPackSelectedFileSkipReason::NonUtf8,
12722 Some(byte_len),
12723 )),
12724 Ok(WorkspaceFileRead {
12725 content: WorkspaceFileContent::TooLarge { .. },
12726 ..
12727 }) => files.push(ContextPackRepositoryFileSource::skipped(
12728 selected_path,
12729 ContextPackSelectedFileSkipReason::TooLarge,
12730 None,
12731 )),
12732 Err(error) => {
12733 let reason = if matches!(
12734 error.code,
12735 NodeFailureCode::InvalidRepositoryPath
12736 | NodeFailureCode::RepositoryFileNotRegular
12737 | NodeFailureCode::RepositoryPathUnsafe
12738 ) {
12739 ContextPackSelectedFileSkipReason::Unsafe
12740 } else {
12741 ContextPackSelectedFileSkipReason::Unavailable
12742 };
12743 files.push(ContextPackRepositoryFileSource::skipped(
12744 selected_path,
12745 reason,
12746 None,
12747 ));
12748 }
12749 }
12750 }
12751 Ok(ContextPackRepositoryObservation { head, git, files })
12752 }
12753
12754 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12755 let referenced_by_binding = self
12756 .session_bindings
12757 .lock()
12758 .unwrap_or_else(|poisoned| poisoned.into_inner())
12759 .values()
12760 .any(|binding| {
12761 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12762 });
12763 let referenced_by_record = self
12764 .session_records
12765 .lock()
12766 .unwrap_or_else(|poisoned| poisoned.into_inner())
12767 .records
12768 .values()
12769 .any(|record| record.context_id.as_ref() == Some(context_id));
12770 let referenced_by_materialization = self
12771 .materializations
12772 .lock()
12773 .unwrap_or_else(|poisoned| poisoned.into_inner())
12774 .values()
12775 .any(|ownership| {
12776 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12777 });
12778 if referenced_by_binding
12779 || referenced_by_record
12780 || referenced_by_materialization
12781 {
12782 return Err(failure(
12783 NodeFailureCode::ContextPackBusy,
12784 "context pack is still referenced",
12785 ));
12786 }
12787 let mut catalog = self.context_catalog
12788 .write()
12789 .unwrap_or_else(|poisoned| poisoned.into_inner());
12790 if catalog.get(context_id).is_none() {
12791 return Err(failure(
12792 NodeFailureCode::UnknownContextPack,
12793 "context pack is unavailable",
12794 ));
12795 }
12796 self.spawn_idempotency
12797 .lock()
12798 .unwrap_or_else(|poisoned| poisoned.into_inner())
12799 .entries
12800 .retain(|_, entry| !entry.value.references_context(context_id));
12801 let removed = catalog.remove(context_id);
12802 debug_assert!(removed.is_some(), "validated context pack remains present");
12803 Ok(())
12804 }
12805
12806 async fn dispatch_input_bounded(
12807 &self,
12808 address: &SessionAddress,
12809 action: InputAction,
12810 ) -> Result<(), NodeFailure> {
12811 let started_after = self.current_sequence();
12812 self.pty_input_marks
12813 .lock()
12814 .unwrap_or_else(|poisoned| poisoned.into_inner())
12815 .insert(address.session.instance_id, (Instant::now(), false));
12816 self.pty_input_wake.notify_one();
12817 let command_id = self
12818 .dispatch_bounded(
12819 ControlCommand::SendInput {
12820 instance_id: address.session.instance_id,
12821 action,
12822 },
12823 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12824 )
12825 .await?;
12826 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12830 let mut scan_after = started_after;
12831 let mut accepted_after = None;
12832 loop {
12833 let events = self
12834 .history
12835 .lock()
12836 .unwrap_or_else(|poisoned| poisoned.into_inner())
12837 .events
12838 .iter()
12839 .filter(|envelope| envelope.sequence > scan_after)
12840 .cloned()
12841 .collect::<Vec<_>>();
12842 for envelope in events {
12843 scan_after = scan_after.max(envelope.sequence);
12844 let NodeEvent::Control {
12845 address: event_address,
12846 event,
12847 } = envelope.event
12848 else {
12849 continue;
12850 };
12851 if event_address != *address {
12852 continue;
12853 }
12854 if event.command_id == Some(command_id) {
12855 match &event.event {
12856 ControlEventKind::CommandRejected { message } => {
12857 return Err(failure(NodeFailureCode::InvalidRequest, message));
12858 }
12859 ControlEventKind::InputRequested { .. } => {
12860 accepted_after = Some(envelope.sequence);
12861 continue;
12862 }
12863 _ => {}
12864 }
12865 }
12866 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12867 match &event.event {
12868 ControlEventKind::InputCompleted { .. } => return Ok(()),
12869 ControlEventKind::InputFailed { message, .. } => {
12870 return Err(failure(
12871 NodeFailureCode::BackendOperationFailed,
12872 message,
12873 ));
12874 }
12875 _ => {}
12876 }
12877 }
12878 }
12879 if Instant::now() >= deadline {
12880 return Err(failure(
12881 NodeFailureCode::BackendBusy,
12882 if accepted_after.is_some() {
12883 "input was accepted but did not settle before the bounded deadline"
12884 } else {
12885 "input was not accepted before the bounded deadline"
12886 },
12887 ));
12888 }
12889 sleep(Duration::from_millis(2)).await;
12890 }
12891 }
12892
12893 async fn dispatch_resize_bounded(
12894 &self,
12895 address: &SessionAddress,
12896 size: gate4agent_types::TerminalSize,
12897 ) -> Result<(), NodeFailure> {
12898 let started_after = self.current_sequence();
12899 let command_id = self
12900 .dispatch_bounded(
12901 ControlCommand::Resize {
12902 instance_id: address.session.instance_id,
12903 size,
12904 },
12905 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12906 )
12907 .await?;
12908 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12909 let mut scan_after = started_after;
12910 let mut accepted_after = None;
12911 loop {
12912 let events = self
12913 .history
12914 .lock()
12915 .unwrap_or_else(|poisoned| poisoned.into_inner())
12916 .events
12917 .iter()
12918 .filter(|envelope| envelope.sequence > scan_after)
12919 .cloned()
12920 .collect::<Vec<_>>();
12921 for envelope in events {
12922 scan_after = scan_after.max(envelope.sequence);
12923 let NodeEvent::Control {
12924 address: event_address,
12925 event,
12926 } = envelope.event
12927 else {
12928 continue;
12929 };
12930 if event_address != *address {
12931 continue;
12932 }
12933 if event.command_id == Some(command_id) {
12934 match &event.event {
12935 ControlEventKind::CommandRejected { message } => {
12936 return Err(failure(NodeFailureCode::InvalidRequest, message));
12937 }
12938 ControlEventKind::ResizeRequested {
12939 size: requested_size,
12940 ..
12941 } if *requested_size == size => {
12942 accepted_after = Some(envelope.sequence);
12943 continue;
12944 }
12945 _ => {}
12946 }
12947 }
12948 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12949 match &event.event {
12950 ControlEventKind::Resized { size: settled_size }
12951 if *settled_size == size =>
12952 {
12953 return Ok(());
12954 }
12955 ControlEventKind::ResizeFailed { message } => {
12956 return Err(failure(
12957 NodeFailureCode::BackendOperationFailed,
12958 message,
12959 ));
12960 }
12961 _ => {}
12962 }
12963 }
12964 }
12965 if Instant::now() >= deadline {
12966 return Err(failure(
12967 NodeFailureCode::BackendBusy,
12968 if accepted_after.is_some() {
12969 "resize was accepted but did not settle before the bounded deadline"
12970 } else {
12971 "resize was not accepted before the bounded deadline"
12972 },
12973 ));
12974 }
12975 sleep(Duration::from_millis(2)).await;
12976 }
12977 }
12978
12979 async fn spawn_session(
12980 &self,
12981 workspace_id: WorkspaceId,
12982 provider: AgentId,
12983 mode: SessionMode,
12984 terminal_size: gate4agent_types::TerminalSize,
12985 initial_prompt: Option<String>,
12986 ) -> Result<SessionAddress, NodeFailure> {
12987 if self.managed_worktrees.lock()
12988 .unwrap_or_else(|poisoned| poisoned.into_inner())
12989 .lease_for_workspace(&workspace_id)
12990 .is_some()
12991 {
12992 return Err(failure(
12993 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12994 "managed worktree targets may only be spawned through their lease request",
12995 ));
12996 }
12997 self
12998 .spawn_session_with_deadline(
12999 workspace_id,
13000 provider.clone(),
13001 mode,
13002 terminal_size,
13003 initial_prompt,
13004 None,
13005 None,
13006 None,
13007 None,
13008 None,
13009 SpawnRecordPolicy::ProviderIdentityOnly,
13010 None,
13011 &[],
13012 None,
13013 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13020 None,
13021 None,
13022 )
13023 .await
13024 .map(|(session, _runtime_policy)| session)
13025 }
13026
13027 async fn spawn_session_with_deadline(
13028 &self,
13029 workspace_id: WorkspaceId,
13030 provider: AgentId,
13031 mode: SessionMode,
13032 terminal_size: gate4agent_types::TerminalSize,
13033 initial_prompt: Option<String>,
13034 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13035 bundle: Option<ResolvedBundleReceipt>,
13036 context: Option<ResolvedContextPackReceipt>,
13037 managed_authority: Option<ManagedWorktreeLeaseId>,
13038 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13039 record_policy: SpawnRecordPolicy,
13040 deadline: Option<Instant>,
13041 required_capabilities: &[ProviderRuntimeCapability],
13042 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13043 approval_level: ApprovalLevel,
13044 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13045 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13051 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13052 let reserved_lease = self.managed_worktrees.lock()
13053 .unwrap_or_else(|poisoned| poisoned.into_inner())
13054 .lease_for_workspace(&workspace_id);
13055 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13056 (Some(expected), Some(provided)) if expected == provided => {}
13057 (None, None) => {}
13058 _ => {
13059 return Err(failure(
13060 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13061 "spawn lacks exact managed worktree lease authority",
13062 ));
13063 }
13064 }
13065 self.ensure_workspace_environment_profile_compatible(
13069 &workspace_id,
13070 environment_profile.as_ref(),
13071 )?;
13072 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13073 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13074 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13075 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13076 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13077 };
13078 let transport = match mode {
13084 SessionMode::Pty => TransportKind::Pty,
13085 SessionMode::Inline => TransportKind::Pipe,
13086 SessionMode::Acp => TransportKind::Acp,
13087 };
13088 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13089 let acp_transport = self.provider_supports_acp_transport(&provider);
13090 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13091 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13092 NodeFailureCode::UnsupportedSpawnCapability,
13093 "pre-admitted provider runtime no longer satisfies spawn requirements",
13094 ))?;
13095 runtime_policy
13096 } else if let Some(deadline) = deadline {
13097 let remaining = spawn_deadline_remaining(deadline)?;
13098 timeout(
13099 remaining,
13100 self.admit_provider_runtime(&provider, runtime_requirement),
13101 )
13102 .await
13103 .map_err(|_| {
13104 failure(
13105 NodeFailureCode::SpawnDeadlineExceeded,
13106 "provider admission exceeded the spawn deadline",
13107 )
13108 })??
13109 } else {
13110 self.admit_provider_runtime(&provider, runtime_requirement).await?
13111 };
13112 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13113 let runtime_policy =
13125 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13126 if required_capabilities
13127 .iter()
13128 .any(|capability| !runtime_policy.admits(*capability))
13129 {
13130 return Err(failure(
13131 NodeFailureCode::UnsupportedSpawnCapability,
13132 "provider runtime does not admit a required spawn capability",
13133 ));
13134 }
13135 if let Some(deadline) = deadline {
13136 spawn_deadline_remaining(deadline)?;
13137 }
13138 self.ensure_binding_capacity()?;
13139 let mut working_directory = self.workspace_root(&workspace_id)?;
13140 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13141 let session = SessionKey {
13142 instance_id,
13143 generation: SessionGeneration(1),
13144 };
13145 let address = SessionAddress {
13146 workspace_id: workspace_id.clone(),
13147 session,
13148 };
13149 tracing::info!(
13157 node_id = %self.node_id,
13158 workspace_id = %workspace_id,
13159 provider = %provider,
13160 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13161 session = ?address.session,
13162 "session spawn request accepted",
13163 );
13164 let mut prepared_spawn = SpawnPreparer::prepare(
13165 self,
13166 SpawnPrepareRequest {
13167 instance_id,
13168 address: &address,
13169 provider: &provider,
13170 mode,
13171 environment_profile: environment_profile.as_ref(),
13172 bundle: bundle.as_ref(),
13173 context: context.as_ref(),
13174 managed_authority: managed_authority.as_ref(),
13175 harness_mcp,
13176 approval_level,
13177 network_allowlist: network_allowlist.as_ref(),
13178 browser_profile_id,
13179 },
13180 )?;
13181 let materialization_id = prepared_spawn.materialization_id.take();
13182 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13183 let record_id = self.bind_spawn_session_with_materialization(
13184 &address,
13185 provider.clone(),
13186 mode,
13187 runtime_policy,
13188 record_policy,
13189 environment_profile,
13190 bundle,
13191 context,
13192 materialization_id,
13193 )?;
13194 prepared_spawn.retain_session_guards();
13195 let session_provider = provider.clone();
13199 let agent_id = provider;
13200 let register_command_id = match self.dispatch_bounded(
13201 ControlCommand::Register {
13202 instance_id,
13203 agent_id,
13204 transport,
13205 },
13206 dispatch_timeout,
13207 )
13208 .await {
13209 Ok(command_id) => command_id,
13210 Err(error) => {
13211 if let Some(binding) = self.remove_binding(&address) {
13212 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13213 }
13214 if let Some(record_id) = record_id.as_ref() {
13215 self.discard_record(record_id)?;
13216 }
13217 return Err(spawn_dispatch_error(error, deadline));
13218 }
13219 };
13220 let start_timeout = match spawn_dispatch_timeout(deadline) {
13221 Ok(timeout) => timeout,
13222 Err(error) => {
13223 let recovery = self
13224 .rollback_spawn(
13225 &address,
13226 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13227 )
13228 .await;
13229 return match recovery {
13230 Ok(()) => {
13231 if let Some(record_id) = record_id.as_ref() {
13232 self.discard_record(record_id)?;
13233 }
13234 Err(error)
13235 }
13236 Err(recovery_error) => {
13237 if let Some(record_id) = record_id.as_ref() {
13238 self.mark_record_error(
13239 record_id,
13240 "spawn deadline elapsed and registration recovery failed",
13241 )?;
13242 }
13243 Err(recovery_error)
13244 }
13245 };
13246 }
13247 };
13248 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13249 if let Err(recovery_error) = self
13250 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13251 .await
13252 {
13253 return Err(recovery_error);
13254 }
13255 if let Some(record_id) = record_id.as_ref() {
13256 self.discard_record(record_id)?;
13257 }
13258 return Err(start_error);
13259 }
13260 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13262 working_directory = dir.to_string_lossy().into_owned();
13263 }
13264 let start_result = self
13265 .dispatch_bounded(
13266 ControlCommand::Start {
13267 instance_id,
13268 runtime_policy,
13269 request: StartRequest {
13270 working_directory,
13271 terminal_size,
13272 initial_prompt,
13273 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13274 approval_level,
13275 },
13276 },
13277 start_timeout,
13278 )
13279 .await;
13280 let start_command_id = match start_result {
13281 Ok(command_id) => command_id,
13282 Err(start_error) => {
13283 let start_error = spawn_dispatch_error(start_error, deadline);
13284 let recovery = self
13285 .rollback_spawn(
13286 &address,
13287 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13288 )
13289 .await;
13290 return match recovery {
13291 Ok(()) => {
13292 if let Some(record_id) = record_id.as_ref() {
13293 self.discard_record(record_id)?;
13294 }
13295 Err(start_error)
13296 }
13297 Err(recovery_error) => {
13298 let diagnostic = format!(
13299 "start failed and registration recovery failed: {}",
13300 recovery_error.message,
13301 );
13302 if let Some(record_id) = record_id.as_ref() {
13303 self.mark_record_error(record_id, &diagnostic)?;
13304 }
13305 Err(failure(recovery_error.code, &diagnostic))
13306 }
13307 };
13308 }
13309 };
13310
13311 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13312 loop {
13313 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13322 .or_else(|| self.take_rejected_command_reason(start_command_id))
13323 {
13324 self.rollback_spawn(
13325 &address,
13326 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13327 ).await.map_err(|error| {
13328 failure(
13329 error.code,
13330 &format!(
13331 "spawn was rejected ({}) and registration recovery failed: {}",
13332 reason.message, error.message
13333 ),
13334 )
13335 })?;
13336 if let Some(record_id) = record_id.as_ref() {
13337 self.discard_record(record_id)?;
13338 }
13339 let code = if reason.unsupported_transport {
13340 NodeFailureCode::UnsupportedTransport
13341 } else {
13342 NodeFailureCode::BackendOperationFailed
13343 };
13344 return Err(failure(code, &reason.message));
13345 }
13346 if let Some(current) = self
13347 .handle
13348 .snapshot()
13349 .sessions
13350 .iter()
13351 .find(|current| current.instance_id == instance_id)
13352 {
13353 if current.generation != session.generation {
13354 let divergence = failure(
13355 NodeFailureCode::StaleGeneration,
13356 "spawn generation diverged",
13357 );
13358 return match self
13359 .rollback_spawn(
13360 &address,
13361 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13362 )
13363 .await
13364 {
13365 Ok(()) => {
13366 if let Some(record_id) = record_id.as_ref() {
13367 self.discard_record(record_id)?;
13368 }
13369 Err(divergence)
13370 }
13371 Err(recovery_error) => {
13372 if let Some(record_id) = record_id.as_ref() {
13373 self.mark_record_error(
13374 record_id,
13375 "spawn generation diverged and recovery failed",
13376 )?;
13377 }
13378 Err(recovery_error)
13379 }
13380 };
13381 }
13382 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13383 prepared_spawn.retain_launch_guards();
13384 return Ok((address, runtime_policy));
13385 }
13386 }
13387 if Instant::now() >= commit_deadline {
13388 self.rollback_spawn(
13389 &address,
13390 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13391 ).await.map_err(|error| {
13392 failure(
13393 error.code,
13394 &format!(
13395 "start did not commit and registration recovery failed: {}",
13396 error.message
13397 ),
13398 )
13399 })?;
13400 if let Some(record_id) = record_id.as_ref() {
13401 self.discard_record(record_id)?;
13402 }
13403 return Err(failure(
13404 if deadline.is_some() {
13405 NodeFailureCode::SpawnDeadlineExceeded
13406 } else {
13407 NodeFailureCode::BackendBusy
13408 },
13409 "start did not commit before the bounded deadline; registration was removed",
13410 ));
13411 }
13412 sleep(Duration::from_millis(2)).await;
13413 }
13414 }
13415
13416 fn effective_spawn_runtime_policy(
13417 &self,
13418 runtime_policy: ProviderRuntimePolicy,
13419 ) -> ProviderRuntimePolicy {
13420 #[cfg(feature = "fixture")]
13421 let runtime_policy = if self.fixture_semantic_hook_policy {
13422 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13423 .expect("monitoring Hook fixture policy is internally valid")
13424 } else {
13425 runtime_policy
13426 };
13427 runtime_policy
13428 }
13429
13430 fn verify_harness_mcp_before_start(
13431 &self,
13432 prepared: Option<&PreparedHarnessMcpSpawn>,
13433 ) -> Result<(), NodeFailure> {
13434 prepared
13435 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13436 .transpose()
13437 .map(|_| ())
13438 .map_err(harness_mcp_failure)
13439 }
13440
13441 async fn rollback_spawn(
13442 &self,
13443 address: &SessionAddress,
13444 commit_timeout: Duration,
13445 ) -> Result<(), NodeFailure> {
13446 self.dispatch_bounded(
13447 ControlCommand::Remove {
13448 instance_id: address.session.instance_id,
13449 },
13450 commit_timeout,
13451 )
13452 .await?;
13453 self.wait_until_removed(address, commit_timeout).await?;
13454 if let Some(binding) = self.remove_binding(address) {
13455 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13456 }
13457 Ok(())
13458 }
13459
13460 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13461 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13462 self.dispatch_bounded(
13463 ControlCommand::Remove {
13464 instance_id: address.session.instance_id,
13465 },
13466 commit_timeout,
13467 )
13468 .await?;
13469 self.wait_until_removed(address, commit_timeout).await?;
13470 let removed = self.remove_binding(address);
13471 if let Some(binding) = removed.as_ref() {
13472 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13473 }
13474 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13475 let snapshot = self.managed_worktrees.lock()
13476 .unwrap_or_else(|poisoned| poisoned.into_inner())
13477 .release_session(
13478 &lease_id,
13479 self.incarnation_id,
13480 address.session.instance_id,
13481 address.session.generation,
13482 unix_time_ms(),
13483 );
13484 if self.persist_state().is_err() {
13485 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13486 }
13487 if let Some(snapshot) = snapshot {
13488 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13489 }
13490 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13491 }
13492 Ok(())
13493 }
13494
13495 async fn wait_until_removed(
13541 &self,
13542 address: &SessionAddress,
13543 commit_timeout: Duration,
13544 ) -> Result<(), NodeFailure> {
13545 let deadline = Instant::now() + commit_timeout;
13546 let mut next_dispatch = Instant::now();
13547 loop {
13548 let snapshot = self.handle.snapshot();
13549 let status = snapshot
13550 .sessions
13551 .iter()
13552 .find(|session| session.instance_id == address.session.instance_id)
13553 .map(|session| session.status.clone());
13554 let Some(status) = status else {
13555 return Ok(());
13556 };
13557 let now = Instant::now();
13558 if now >= deadline {
13559 return Err(failure(
13560 NodeFailureCode::BackendBusy,
13561 "remove did not commit before the bounded deadline; session binding was retained",
13562 ));
13563 }
13564 if status.allows_remove() && now >= next_dispatch {
13565 let _ = self.dispatch(ControlCommand::Remove {
13566 instance_id: address.session.instance_id,
13567 });
13568 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13569 }
13570 sleep(Duration::from_millis(2)).await;
13571 }
13572 }
13573
13574 fn resync(&self, after_sequence: u64) -> NodeResponse {
13575 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13576 let event_sequence = history.last_sequence;
13577 let oldest_available_sequence = history.replay_floor_sequence;
13578 let events = history
13579 .events
13580 .iter()
13581 .filter(|event| event.sequence > after_sequence)
13582 .cloned()
13583 .collect();
13584 drop(history);
13585 NodeResponse::Resync {
13586 event_sequence,
13587 oldest_available_sequence,
13588 snapshot: self.snapshot(),
13589 events,
13590 }
13591 }
13592}
13593
13594fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13599 match kind {
13600 AgentStreamChunkKindV1::Text { .. } => "text",
13601 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13602 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13603 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13604 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13605 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13606 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13607 }
13608}
13609
13610async fn accept_connections(
13624 endpoint: &str,
13625 call_home: Option<std::net::SocketAddr>,
13626 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13627 shared: Arc<NodeShared>,
13628) -> Result<(), NodeServerError> {
13629 let mut connections = JoinSet::new();
13630 let result = match call_home {
13631 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13632 Some(relay) => {
13633 let listener =
13634 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13635 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13636 tokio::pin!(listener);
13637 tokio::pin!(dialler);
13638 tokio::select! {
13639 result = &mut listener => result,
13640 result = &mut dialler => result,
13641 }
13642 }
13643 };
13644 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13645 while !connections.is_empty() {
13646 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13647 if remaining.is_zero()
13648 || timeout(remaining, connections.join_next()).await.is_err()
13649 {
13650 break;
13651 }
13652 }
13653 connections.abort_all();
13654 while connections.join_next().await.is_some() {}
13655 result
13656}
13657
13658const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13662
13663async fn call_home_loop(
13677 relay: std::net::SocketAddr,
13678 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13679 shared: Arc<NodeShared>,
13680) -> Result<(), NodeServerError> {
13681 let mut failures = 0_usize;
13682 loop {
13683 if shared.shutdown.load(Ordering::Acquire) {
13684 return Ok(());
13685 }
13686 let permit = tokio::select! {
13687 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13688 permit.map_err(|_| NodeServerError::PreauthClosed)?
13689 }
13690 _ = shared.shutdown_notify.notified() => return Ok(()),
13691 };
13692 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13693 Ok(()) => {
13694 failures = 0;
13698 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13699 }
13700 Err(error) => {
13701 failures = failures.saturating_add(1);
13702 tracing::warn!(
13703 node_id = %shared.node_id,
13704 %relay,
13705 cause = %error,
13706 attempt = failures,
13707 "call-home connection failed",
13708 );
13709 let backoff = call_home_backoff(failures);
13710 tokio::select! {
13711 _ = sleep(backoff) => {}
13712 _ = shared.shutdown_notify.notified() => return Ok(()),
13713 }
13714 }
13715 }
13716 }
13717}
13718
13719const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13721const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13723
13724fn call_home_keepalive() -> socket2::TcpKeepalive {
13725 let keepalive = socket2::TcpKeepalive::new()
13726 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13727 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13728 #[cfg(not(windows))]
13729 let keepalive = keepalive.with_retries(3);
13730 keepalive
13731}
13732
13733fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13734 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13735}
13736
13737pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13739 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13740}
13741
13742fn call_home_backoff(attempt: usize) -> Duration {
13744 Duration::from_millis(
13745 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13746 )
13747}
13748
13749
13750async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13753 #[cfg(feature = "wireguard")]
13754 {
13755 let std_stream = tokio::task::spawn_blocking(move || {
13756 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13757 })
13758 .await
13759 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13760 .map_err(NodeServerError::CallHomeTransport)?;
13761 std_stream
13762 .set_nonblocking(true)
13763 .map_err(NodeServerError::CallHomeTransport)?;
13764 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13765 }
13766 #[cfg(not(feature = "wireguard"))]
13767 {
13768 tokio::net::TcpStream::connect(relay)
13769 .await
13770 .map_err(NodeServerError::CallHomeTransport)
13771 }
13772}
13773
13774async fn dial_relay_and_serve(
13776 relay: std::net::SocketAddr,
13777 identity: Option<&hatchery_enroll::NodeIdentity>,
13778 shared: Arc<NodeShared>,
13779 preauth_permit: OwnedSemaphorePermit,
13780) -> Result<(), NodeServerError> {
13781 let mut stream = dial_call_home(relay).await?;
13782 let _ = stream.set_nodelay(true);
13786 if let Err(error) = enable_call_home_keepalive(&stream) {
13792 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13793 }
13794 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13795 .await
13796 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13797 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13798 let mut session_token = None;
13799 if let Some(identity) = identity {
13800 let mut magic = [0_u8; 4];
13804 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13805 .await
13806 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13807 .map_err(NodeServerError::CallHomeTransport)?;
13808 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13809 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13810 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13811 .await
13812 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13813 .map_err(NodeServerError::CallHomeTransport)?;
13814 let (answer, token) = identity
13815 .answer_challenge(shared.node_id.as_str(), &frame)
13816 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13817 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13818 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13819 session_token = Some(token);
13820 }
13821 }
13822 serve_connection(stream, shared, session_token, preauth_permit).await
13823}
13824
13825async fn accept_connections_inner(
13826 endpoint: &str,
13827 shared: Arc<NodeShared>,
13828 connections: &mut JoinSet<Result<(), NodeServerError>>,
13829) -> Result<(), NodeServerError> {
13830 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13831 loop {
13832 while connections.try_join_next().is_some() {}
13833 if shared.shutdown.load(Ordering::Acquire) {
13834 return Ok(());
13835 }
13836 let preauth_permit = tokio::select! {
13837 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13838 permit.map_err(|_| NodeServerError::PreauthClosed)?
13839 }
13840 _ = shared.shutdown_notify.notified() => return Ok(()),
13841 };
13842 let server = tokio::select! {
13843 result = listener.accept() => result?,
13844 _ = shared.shutdown_notify.notified() => return Ok(()),
13845 };
13846 let connection_shared = Arc::clone(&shared);
13847 connections.spawn(async move {
13848 serve_connection(server, connection_shared, None, preauth_permit).await
13849 });
13850 if shared.shutdown.load(Ordering::Acquire) {
13851 return Ok(());
13852 }
13853 }
13854}
13855
13856async fn serve_connection<S>(
13864 mut pipe: S,
13865 shared: Arc<NodeShared>,
13866 session_token: Option<String>,
13867 preauth_permit: OwnedSemaphorePermit,
13868) -> Result<(), NodeServerError>
13869where
13870 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13871{
13872 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13874 let hello_frame = timeout(
13875 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13876 read_json_frame_limited_body_timeout(
13877 &mut pipe,
13878 MAX_NODE_HELLO_FRAME_BYTES,
13879 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13880 ),
13881 )
13882 .await
13883 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13884 let ClientFrame::Hello(hello) = hello_frame else {
13885 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13886 };
13887 if hello.build_stamp != BUILD_STAMP {
13888 return Err(NodeServerError::Handshake(format!(
13889 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13890 hello.build_stamp,
13891 )));
13892 }
13893 let compatibility = match hello.compatibility.as_ref() {
13894 Some(offer) => {
13895 let mut selected = node_compatibility_support(&shared)?
13896 .negotiate(offer)
13897 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13898 project_negotiated_provider_ids(&mut selected);
13899 Some(selected)
13900 }
13901 None => None,
13902 };
13903 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13904 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13905 (Some(offer), Some(selected)) => negotiated_auth_proof(
13906 auth_token,
13907 AuthDirection::Server,
13908 hello.role,
13909 &hello.client_nonce,
13910 &server_nonce,
13911 offer,
13912 selected,
13913 ),
13914 (None, None) => auth_proof(
13915 auth_token,
13916 AuthDirection::Server,
13917 hello.role,
13918 &hello.client_nonce,
13919 &server_nonce,
13920 ),
13921 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13922 }
13923 .map_err(NodeServerError::Authentication)?;
13924 write_json_frame_limited(
13925 &mut pipe,
13926 &ServerFrame::Challenge(ServerChallenge {
13927 build_stamp: BUILD_STAMP.to_owned(),
13928 server_nonce,
13929 server_proof,
13930 compatibility: compatibility.clone(),
13931 }),
13932 MAX_NODE_HELLO_FRAME_BYTES,
13933 )
13934 .await?;
13935 let authentication = timeout(
13936 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13937 read_json_frame_limited_body_timeout(
13938 &mut pipe,
13939 MAX_NODE_HELLO_FRAME_BYTES,
13940 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13941 ),
13942 )
13943 .await
13944 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13945 let ClientFrame::Authenticate(authentication) = authentication else {
13946 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13947 };
13948 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13949 (Some(offer), Some(selected)) => negotiated_auth_proof(
13950 auth_token,
13951 AuthDirection::Client,
13952 hello.role,
13953 &hello.client_nonce,
13954 &server_nonce,
13955 offer,
13956 selected,
13957 ),
13958 (None, None) => auth_proof(
13959 auth_token,
13960 AuthDirection::Client,
13961 hello.role,
13962 &hello.client_nonce,
13963 &server_nonce,
13964 ),
13965 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13966 }
13967 .map_err(NodeServerError::Authentication)?;
13968 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13969 return Err(NodeServerError::Handshake("access denied".to_owned()));
13970 }
13971 let selected_capabilities = compatibility
13972 .as_ref()
13973 .map(|selected| selected.capabilities.clone())
13974 .unwrap_or_default();
13975 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13976 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13977 });
13978 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13979 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13980 });
13981 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13982 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13983 });
13984 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13985 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13986 });
13987 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13988 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13989 });
13990 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13991 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13992 }) && selected_capabilities.iter().any(|capability| {
13993 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13994 });
13995 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13996 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13997 });
13998 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13999 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14000 });
14001 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14002 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14003 });
14004 let include_agent_progress = selected_capabilities.iter().any(|capability| {
14005 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14006 });
14007 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14008 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14009 });
14010 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14011 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14012 });
14013 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14014 .try_acquire_owned()
14015 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14016 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14017 let _release_guard = ControllerReleaseGuard {
14018 shared: Arc::clone(&shared),
14019 connection_id,
14020 };
14021 drop(preauth_permit);
14022 let _authenticated_permit = authenticated_permit;
14023 let mut event_rx = shared.event_tx.subscribe();
14024 let mut terminal_event_rx = terminal_event_subscription(
14025 &shared,
14026 include_terminal_frame_events,
14027 );
14028 let mut agent_stream_event_rx = agent_stream_event_subscription(
14029 &shared,
14030 include_agent_stream_events,
14031 );
14032 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14033 &shared,
14034 include_harness_mcp_proxy,
14035 );
14036 write_json_frame(
14037 &mut pipe,
14038 &ServerFrame::Hello(NodeHello {
14039 build_stamp: BUILD_STAMP.to_owned(),
14040 incarnation_id: shared.incarnation_id,
14041 connection_id,
14042 role: hello.role,
14043 event_sequence: shared.current_sequence(),
14044 controller: shared.controller_state(),
14045 snapshot: snapshot_for_wire(
14046 &shared,
14047 include_provider_runtime_status,
14048 include_open_provider_ids,
14049 include_spawn_profiles,
14050 include_managed_worktrees,
14051 include_child_environment_profiles,
14052 include_session_bundles,
14053 include_history_context_packs,
14054 include_agent_progress,
14055 include_session_task_correlation,
14056 ),
14057 compatibility,
14058 }),
14059 )
14060 .await?;
14061 let (mut reader, mut writer) = tokio::io::split(pipe);
14062 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14063 let reader_task = tokio::spawn(async move {
14064 loop {
14065 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14066 &mut reader,
14067 MAX_NODE_CLIENT_FRAME_BYTES,
14068 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14069 )
14070 .await;
14071 let terminal = frame.is_err();
14072 if frame_tx.send(frame).await.is_err() || terminal {
14073 break;
14074 }
14075 }
14076 });
14077 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14078 let mut pending_events = Vec::new();
14079 let mut resync_required = false;
14080 let mut discard_events_through = 0_u64;
14081 loop {
14082 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14087 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14088 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14089 for _ in 0..durable_drain_budget {
14090 match event_rx.try_recv() {
14091 Ok(event) => queue_connection_event(
14092 &mut pending_events,
14093 event,
14094 discard_events_through,
14095 ),
14096 Err(broadcast::error::TryRecvError::Empty) => break,
14097 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14098 resync_required = true;
14099 break;
14100 }
14101 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14102 }
14103 }
14104 if let Some(receiver) = terminal_event_rx.as_mut() {
14105 match receiver.try_recv() {
14106 Ok(events) => queue_connection_event_batch(
14107 &mut pending_events,
14108 &events,
14109 discard_events_through,
14110 ),
14111 Err(broadcast::error::TryRecvError::Empty) => {}
14112 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14113 resync_required = true;
14114 }
14115 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14116 }
14117 }
14118 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14119 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14129 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14130 for _ in 0..agent_stream_drain_budget {
14131 match receiver.try_recv() {
14132 Ok(events) => queue_connection_event_batch(
14133 &mut pending_events,
14134 &events,
14135 discard_events_through,
14136 ),
14137 Err(broadcast::error::TryRecvError::Empty) => break,
14138 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14139 tracing::warn!(
14140 connection_id,
14141 dropped,
14142 "agent stream event subscription lagged; forcing a resync",
14143 );
14144 resync_required = true;
14145 break;
14146 }
14147 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14148 }
14149 }
14150 }
14151 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14152 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14158 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14159 for _ in 0..harness_mcp_proxy_drain_budget {
14160 match receiver.try_recv() {
14161 Ok(events) => queue_connection_event_batch(
14162 &mut pending_events,
14163 &events,
14164 discard_events_through,
14165 ),
14166 Err(broadcast::error::TryRecvError::Empty) => break,
14167 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14168 tracing::warn!(
14169 connection_id,
14170 dropped,
14171 "harness MCP proxy event subscription lagged; forcing a resync",
14172 );
14173 resync_required = true;
14174 break;
14175 }
14176 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14177 }
14178 }
14179 }
14180 if resync_required {
14181 let event = resync_required_event(&shared);
14182 apply_connection_resync_watermark(
14183 &mut pending_events,
14184 &mut discard_events_through,
14185 event.sequence,
14186 );
14187 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14188 resync_required = false;
14189 continue;
14190 }
14191 if !pending_events.is_empty() {
14192 pending_events.sort_unstable_by_key(|event| event.sequence);
14193 let burst_len = connection_event_burst_len(pending_events.len());
14194 shared
14195 .connection_events_sent
14196 .fetch_add(burst_len as u64, Ordering::Relaxed);
14197 for event in pending_events.drain(..burst_len) {
14198 let event = if include_managed_worktrees {
14199 Some(event)
14200 } else {
14201 project_event_without_managed_worktrees(event)
14202 };
14203 let event = event.and_then(|event| if include_open_provider_ids {
14204 Some(event)
14205 } else {
14206 project_event_legacy_provider_ids(&shared, event)
14207 });
14208 let event = event.map(|event| if include_child_environment_profiles {
14209 event
14210 } else {
14211 project_event_without_child_environment_profile(event)
14212 });
14213 let event = event.map(|event| if include_session_bundles {
14214 event
14215 } else {
14216 project_event_without_session_bundle(event)
14217 });
14218 let event = event.map(|event| {
14219 project_event_history_for_wire(event, include_history_context_packs)
14220 });
14221 let event = event.map(|event| if include_history_context_packs {
14222 event
14223 } else {
14224 project_event_without_context_pack(event)
14225 });
14226 let event = event.map(|event| if include_session_task_correlation {
14227 event
14228 } else {
14229 project_event_without_session_task_binding(event)
14230 });
14231 let exact_harness_controller = hello.role == ClientRole::Operator
14232 && shared.controller_state().is_some_and(|controller| {
14233 controller.connection_id == connection_id
14234 });
14235 let event = event.and_then(|event| {
14236 if include_harness_mcp_proxy && exact_harness_controller {
14237 Some(event)
14238 } else {
14239 project_event_without_harness_mcp_proxy(event)
14240 }
14241 });
14242 if let Some(event) = event {
14243 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14244 }
14245 }
14246 }
14247 tokio::select! {
14248 biased;
14249 frame = frame_rx.recv() => {
14250 let frame = match frame {
14251 Some(frame) => frame,
14252 None => break,
14253 };
14254 let frame = match frame {
14255 Ok(frame) => frame,
14256 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14257 Err(error) => return Err(error.into()),
14258 };
14259 let ClientFrame::Request(request) = frame else {
14260 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14261 };
14262 #[cfg(feature = "fixture")]
14263 let probe_spawn_managed_worktree_v2 = matches!(
14264 &request.request,
14265 NodeRequest::SpawnManagedWorktreeV2 { .. }
14266 );
14267 let requires_open_provider_ids =
14268 request_requires_open_provider_ids(&shared, &request.request);
14269 let requires_child_environment_profile =
14270 request_requires_child_environment_profile(&shared, &request.request);
14271 let requires_session_bundle =
14272 request_requires_session_bundle(&shared, &request.request);
14273 let requires_history_context_pack =
14274 request_requires_history_context_pack(&shared, &request.request);
14275 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14276 ResponseEnvelope {
14277 request_id: request.request_id,
14278 result: Err(failure(
14279 NodeFailureCode::UnsupportedCapability,
14280 "open provider IDs were not negotiated",
14281 )),
14282 }
14283 } else if requires_child_environment_profile
14284 && !include_child_environment_profiles
14285 {
14286 ResponseEnvelope {
14287 request_id: request.request_id,
14288 result: Err(failure(
14289 NodeFailureCode::UnsupportedCapability,
14290 "child environment profiles were not negotiated",
14291 )),
14292 }
14293 } else if requires_session_bundle && !include_session_bundles {
14294 ResponseEnvelope {
14295 request_id: request.request_id,
14296 result: Err(failure(
14297 NodeFailureCode::UnsupportedCapability,
14298 "session bundles were not negotiated",
14299 )),
14300 }
14301 } else if requires_history_context_pack && !include_history_context_packs {
14302 ResponseEnvelope {
14303 request_id: request.request_id,
14304 result: Err(failure(
14305 NodeFailureCode::UnsupportedCapability,
14306 "history context packs were not negotiated",
14307 )),
14308 }
14309 } else if request_uses_unnegotiated_capability(
14310 &request.request,
14311 &selected_capabilities,
14312 )
14313 {
14314 ResponseEnvelope {
14315 request_id: request.request_id,
14316 result: Err(failure(
14317 NodeFailureCode::UnsupportedCapability,
14318 "request capability was not negotiated",
14319 )),
14320 }
14321 } else {
14322 process_request(&shared, connection_id, hello.role, request).await
14323 };
14324 #[cfg(feature = "fixture")]
14325 if probe_spawn_managed_worktree_v2 {
14326 shared
14327 .fixture_spawn_managed_worktree_v2_failure_probe
14328 .record(reply.result.as_ref().err().map(|failure| failure.code));
14329 }
14330 if !include_provider_runtime_status {
14331 clear_response_provider_runtime_status(&mut reply);
14332 }
14333 if !include_managed_worktrees {
14334 project_response_without_managed_worktrees(&mut reply);
14335 }
14336 if !include_open_provider_ids {
14337 project_response_legacy_provider_ids(&shared, &mut reply);
14338 }
14339 if !include_child_environment_profiles {
14340 project_response_without_child_environment_profile(&mut reply);
14341 }
14342 if !include_session_bundles {
14343 project_response_without_session_bundle(&mut reply);
14344 }
14345 project_response_history_for_wire(
14346 &mut reply,
14347 include_history_context_packs,
14348 );
14349 if !include_history_context_packs {
14350 project_response_without_context_pack(&mut reply);
14351 }
14352 if !include_agent_progress {
14353 project_response_without_agent_progress(&mut reply);
14354 }
14355 if !include_session_task_correlation {
14356 project_response_without_session_task_binding(&mut reply);
14357 }
14358 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14359 }
14360 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14361 event = event_rx.recv() => {
14362 match event {
14363 Ok(event) => queue_connection_event(
14364 &mut pending_events,
14365 event,
14366 discard_events_through,
14367 ),
14368 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14369 Err(broadcast::error::RecvError::Closed) => break,
14370 }
14371 }
14372 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14373 match events {
14374 Ok(events) => queue_connection_event_batch(
14375 &mut pending_events,
14376 &events,
14377 discard_events_through,
14378 ),
14379 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14380 Err(broadcast::error::RecvError::Closed) => break,
14381 }
14382 }
14383 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14384 match events {
14385 Ok(events) => queue_connection_event_batch(
14386 &mut pending_events,
14387 &events,
14388 discard_events_through,
14389 ),
14390 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14391 Err(broadcast::error::RecvError::Closed) => break,
14392 }
14393 }
14394 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14395 match events {
14396 Ok(events) => queue_connection_event_batch(
14397 &mut pending_events,
14398 &events,
14399 discard_events_through,
14400 ),
14401 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14402 Err(broadcast::error::RecvError::Closed) => break,
14403 }
14404 }
14405 _ = shared.shutdown_notify.notified() => break,
14406 }
14407 }
14408 Ok(())
14409}
14410
14411async fn receive_terminal_event_batch(
14412 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14413) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14414 match receiver {
14415 Some(receiver) => receiver.recv().await,
14416 None => std::future::pending().await,
14417 }
14418}
14419
14420fn terminal_event_subscription(
14421 shared: &NodeShared,
14422 enabled: bool,
14423) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14424 enabled.then(|| shared.terminal_event_tx.subscribe())
14425}
14426
14427fn agent_stream_event_subscription(
14431 shared: &NodeShared,
14432 enabled: bool,
14433) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14434 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14435}
14436
14437fn harness_mcp_proxy_event_subscription(
14442 shared: &NodeShared,
14443 enabled: bool,
14444) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14445 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14446}
14447
14448fn queue_connection_event(
14449 pending: &mut Vec<NodeEventEnvelope>,
14450 event: NodeEventEnvelope,
14451 discard_through: u64,
14452) {
14453 if event.sequence <= discard_through
14454 && !event.event.requires_harness_mcp_proxy_capability()
14455 {
14456 return;
14457 }
14458 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14459 if let Some(index) = pending.iter().position(|current| {
14460 matches!(
14461 ¤t.event,
14462 NodeEvent::TerminalFrame {
14463 address: current_address,
14464 ..
14465 } if current_address == address
14466 )
14467 }) {
14468 pending.remove(index);
14469 }
14470 }
14471 pending.push(event);
14472}
14473
14474fn project_event_without_harness_mcp_proxy(
14475 envelope: NodeEventEnvelope,
14476) -> Option<NodeEventEnvelope> {
14477 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14478}
14479
14480fn queue_connection_event_batch(
14481 pending: &mut Vec<NodeEventEnvelope>,
14482 events: &[NodeEventEnvelope],
14483 discard_through: u64,
14484) {
14485 for event in events {
14486 queue_connection_event(pending, event.clone(), discard_through);
14487 }
14488}
14489
14490fn apply_connection_resync_watermark(
14491 pending: &mut Vec<NodeEventEnvelope>,
14492 discard_through: &mut u64,
14493 marker_sequence: u64,
14494) {
14495 *discard_through = (*discard_through).max(marker_sequence);
14496 pending.retain(|event| event.sequence > *discard_through);
14497}
14498
14499fn connection_event_burst_len(pending_len: usize) -> usize {
14500 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14501}
14502
14503fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14504 let history = shared
14505 .history
14506 .lock()
14507 .unwrap_or_else(|poisoned| poisoned.into_inner());
14508 let sequence = history.last_sequence;
14509 let oldest_available_sequence = history.replay_floor_sequence;
14510 NodeEventEnvelope {
14511 sequence,
14512 event: NodeEvent::ResyncRequired {
14513 oldest_available_sequence,
14514 },
14515 }
14516}
14517
14518fn node_compatibility_support(
14519 shared: &NodeShared,
14520) -> Result<NodeCompatibilitySupport, NodeServerError> {
14521 let mut support = node_compatibility_support_for_manifest(
14522 &shared.provider_contracts,
14523 &shared.provider_adapter_contracts,
14524 )?;
14525 if shared
14526 .delivery_store
14527 .lock()
14528 .unwrap_or_else(|poisoned| poisoned.into_inner())
14529 .is_some()
14530 {
14531 support.capabilities.push(
14532 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14533 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14534 );
14535 }
14536 if shared.harness_mcp_registry.is_some() {
14537 support.capabilities.push(
14538 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14539 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14540 );
14541 }
14542 Ok(support)
14543}
14544
14545fn node_compatibility_support_for_manifest(
14546 provider_contracts: &[ProviderContractSupport],
14547 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14548) -> Result<NodeCompatibilitySupport, NodeServerError> {
14549 Ok(NodeCompatibilitySupport {
14550 build_stamp: BUILD_STAMP.to_owned(),
14551 capabilities: baseline_capabilities()?,
14552 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14553 path_semantics: platform::path_semantics(),
14554 local_transport: platform::local_transport(),
14555 state_schema: StateSchemaSupport {
14556 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14557 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14558 },
14559 provider_contracts: provider_contracts.to_vec(),
14560 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14561 })
14562}
14563
14564fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14565 let capabilities = [
14566 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14567 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14568 NODE_REPOSITORY_PATH_CAPABILITY,
14569 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14570 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14571 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14572 NODE_GIT_READ_CAPABILITY,
14573 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14574 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14575 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14576 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14577 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14578 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14579 NODE_ACP_CONTROL_CAPABILITY,
14580 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14581 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14582 NODE_WORKTREE_SELECTION_CAPABILITY,
14583 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14584 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14585 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14586 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14587 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14588 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14589 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14590 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14591 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14592 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14593 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14594 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14595 ]
14596 .into_iter()
14597 .map(|capability| {
14598 CapabilityId::new(capability)
14599 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14600 })
14601 .collect::<Result<Vec<_>, _>>()?;
14602 #[cfg(windows)]
14603 let capabilities = {
14604 let mut capabilities = capabilities;
14605 capabilities.push(
14606 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14607 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14608 );
14609 capabilities
14610 };
14611 Ok(capabilities)
14612}
14613
14614fn request_uses_unnegotiated_capability(
14615 request: &NodeRequest,
14616 selected_capabilities: &[CapabilityId],
14617) -> bool {
14618 let selected = |required: &str| {
14619 selected_capabilities
14620 .iter()
14621 .any(|capability| capability.as_str() == required)
14622 };
14623 request
14624 .required_capability()
14625 .is_some_and(|required| !selected(required))
14626 || (request.requires_worktree_selection_capability()
14627 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14628 || (request.requires_spawn_spec_defaults_overrides_capability()
14629 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14630 || (request.requires_spawn_profile_revision_capability()
14631 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14632 || (request.requires_child_environment_profile_capability()
14633 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14634 || (request.requires_session_bundle_materialization_capability()
14635 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14636 || (request.requires_history_context_pack_capability()
14637 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14638}
14639
14640fn snapshot_for_wire(
14641 shared: &NodeShared,
14642 include_provider_runtime_status: bool,
14643 include_open_provider_ids: bool,
14644 include_spawn_profiles: bool,
14645 include_managed_worktrees: bool,
14646 include_child_environment_profiles: bool,
14647 include_session_bundles: bool,
14648 include_history_context_packs: bool,
14649 include_agent_progress: bool,
14650 include_session_task_correlation: bool,
14651) -> NodeSnapshot {
14652 let mut snapshot = shared.snapshot();
14653 if !include_provider_runtime_status {
14654 snapshot.provider_runtime_statuses.clear();
14655 }
14656 if !include_open_provider_ids {
14657 project_snapshot_legacy_provider_ids(&mut snapshot);
14658 }
14659 if !include_managed_worktrees {
14660 snapshot.managed_worktrees.clear();
14661 for workspace in &mut snapshot.workspaces {
14662 workspace.managed_worktree_profiles = None;
14663 }
14664 }
14665 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14666 if !include_spawn_profiles {
14667 inventory.spawn_profiles = None;
14668 }
14669 if !include_session_bundles {
14670 inventory.bundles = None;
14671 }
14672 if inventory.spawn_profiles.is_none()
14673 && inventory.bundles.is_none()
14674 && inventory.network_allowlists.is_none()
14675 {
14676 snapshot.launch_inventory = None;
14677 }
14678 }
14679 if !include_child_environment_profiles {
14680 clear_snapshot_child_environment_profiles(&mut snapshot);
14681 }
14682 if !include_session_bundles {
14683 clear_snapshot_session_bundles(&mut snapshot);
14684 }
14685 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14686 if !include_history_context_packs {
14687 clear_snapshot_context_packs(&mut snapshot);
14688 }
14689 if !include_agent_progress {
14690 snapshot.agent_progress.clear();
14691 }
14692 if !include_session_task_correlation {
14693 clear_snapshot_session_task_bindings(&mut snapshot);
14694 }
14695 snapshot
14696}
14697
14698fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14699 for record in &mut snapshot.session_records {
14700 record.task_binding = None;
14701 }
14702}
14703
14704fn project_event_without_session_task_binding(
14705 mut envelope: NodeEventEnvelope,
14706) -> NodeEventEnvelope {
14707 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14708 record.task_binding = None;
14709 }
14710 envelope
14711}
14712
14713fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14714 let Ok(response) = reply.result.as_mut() else { return };
14715 match response {
14716 NodeResponse::Snapshot { snapshot, .. } => {
14717 clear_snapshot_session_task_bindings(snapshot);
14718 }
14719 NodeResponse::Resync { snapshot, events, .. } => {
14720 clear_snapshot_session_task_bindings(snapshot);
14721 for event in events {
14722 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14723 record.task_binding = None;
14724 }
14725 }
14726 }
14727 NodeResponse::SessionRecordUpdated { record }
14728 | NodeResponse::ProviderSessionIndexed { record }
14729 | NodeResponse::NativeSessionIndexed { record, .. }
14730 | NodeResponse::SessionRecordResumed { record, .. } => {
14731 record.task_binding = None;
14732 }
14733 _ => {}
14734 }
14735}
14736
14737fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14738 let Ok(response) = reply.result.as_mut() else { return };
14739 match response {
14740 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14741 snapshot.agent_progress.clear();
14742 }
14743 _ => {}
14744 }
14745}
14746
14747fn project_event_without_managed_worktrees(
14748 mut envelope: NodeEventEnvelope,
14749) -> Option<NodeEventEnvelope> {
14750 if matches!(
14751 envelope.event,
14752 NodeEvent::ManagedWorktreeUpserted { .. }
14753 | NodeEvent::ManagedWorktreeRemoved { .. }
14754 ) {
14755 return None;
14756 }
14757 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14758 workspace.managed_worktree_profiles = None;
14759 }
14760 Some(envelope)
14761}
14762
14763fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14764 if matches!(
14765 reply.result,
14766 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14767 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14768 ) {
14769 reply.result = Err(failure(
14770 NodeFailureCode::UnsupportedCapability,
14771 "managed worktree capability was not negotiated",
14772 ));
14773 return;
14774 }
14775 let Ok(response) = reply.result.as_mut() else { return };
14776 match response {
14777 NodeResponse::Snapshot { snapshot, .. } => {
14778 snapshot.managed_worktrees.clear();
14779 for workspace in &mut snapshot.workspaces {
14780 workspace.managed_worktree_profiles = None;
14781 }
14782 }
14783 NodeResponse::Resync { snapshot, events, .. } => {
14784 snapshot.managed_worktrees.clear();
14785 for workspace in &mut snapshot.workspaces {
14786 workspace.managed_worktree_profiles = None;
14787 }
14788 *events = std::mem::take(events)
14789 .into_iter()
14790 .filter_map(project_event_without_managed_worktrees)
14791 .collect();
14792 }
14793 NodeResponse::WorkspaceRegistered { workspace }
14794 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14795 | NodeResponse::WorktreeCreated { workspace, .. } => {
14796 workspace.managed_worktree_profiles = None;
14797 }
14798 NodeResponse::WorkspaceInspected { inspection } => {
14799 inspection.git.managed_worktree = None;
14800 }
14801 _ => {}
14802 }
14803}
14804
14805fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14806 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14807 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14808 });
14809 if includes_open_provider_ids {
14810 return;
14811 }
14812 compatibility
14813 .provider_contracts
14814 .retain(|contract| provider_id_is_legacy(&contract.provider));
14815 compatibility
14816 .provider_adapter_contracts
14817 .retain(|contract| provider_id_is_legacy(&contract.provider));
14818}
14819
14820fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14821 if let Some(spec) = match request {
14822 NodeRequest::SpawnSpec { spec }
14823 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14824 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14825 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14826 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14827 _ => None,
14828 } {
14829 return shared
14830 .resolve_spawn_spec(spec)
14831 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14832 }
14833 request_requires_open_provider_ids_with(
14834 request,
14835 |session| shared.validate_address(session).ok(),
14836 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14837 )
14838}
14839
14840fn request_requires_child_environment_profile(
14841 shared: &NodeShared,
14842 request: &NodeRequest,
14843) -> bool {
14844 if let Some(spec) = match request {
14845 NodeRequest::SpawnSpec { spec }
14846 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14847 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14848 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14849 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14850 _ => None,
14851 } {
14852 return shared
14853 .resolve_spawn_spec(spec)
14854 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14855 }
14856 match request {
14857 NodeRequest::Resume { session, .. }
14858 | NodeRequest::Prompt { session, .. }
14859 | NodeRequest::Paste { session, .. }
14860 | NodeRequest::Input { session, .. }
14861 | NodeRequest::TerminalBytes { session, .. }
14862 | NodeRequest::TerminalControl { session, .. }
14863 | NodeRequest::Resize { session, .. }
14864 | NodeRequest::Interrupt { session }
14865 | NodeRequest::Stop { session, .. }
14866 | NodeRequest::Remove { session }
14867 | NodeRequest::ResolveInteraction { session, .. }
14868 | NodeRequest::SetSessionMode { session, .. }
14869 | NodeRequest::SetSessionConfigOption { session, .. }
14870 | NodeRequest::SetSessionModel { session, .. }
14871 | NodeRequest::DiscoverHistory { session, .. }
14872 | NodeRequest::LoadHistory { session, .. }
14873 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14874 | NodeRequest::ExportContextPack { session } => shared
14875 .session_bindings
14876 .lock()
14877 .unwrap_or_else(|poisoned| poisoned.into_inner())
14878 .get(&session.session.instance_id)
14879 .filter(|binding| {
14880 binding.workspace_id == session.workspace_id
14881 && binding.generation == session.session.generation
14882 })
14883 .map_or(true, |binding| binding.environment_profile.is_some()),
14884 NodeRequest::RenameSessionRecord { record_id, .. }
14885 | NodeRequest::SetSessionTask { record_id, .. }
14886 | NodeRequest::ResumeSessionRecord { record_id, .. }
14887 | NodeRequest::ForgetSessionRecord { record_id } => shared
14888 .record(record_id)
14889 .map_or(true, |record| record.environment_profile.is_some()),
14890 NodeRequest::ArmHarnessMcpReservation { .. }
14891 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14892 | NodeRequest::ActivateHarnessMcpReservation { .. }
14893 | NodeRequest::AbortHarnessMcpReservation { .. }
14894 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14895 | NodeRequest::RejectHarnessMcpCall { .. }
14896 | NodeRequest::Snapshot
14897 | NodeRequest::Resync { .. }
14898 | NodeRequest::BeginDeliveryStage { .. }
14899 | NodeRequest::PutDeliveryBlobChunk { .. }
14900 | NodeRequest::CommitDeliveryStage { .. }
14901 | NodeRequest::AbortDeliveryStage { .. }
14902 | NodeRequest::BrowseHostDirectories { .. }
14903 | NodeRequest::InspectWorkspace { .. }
14904 | NodeRequest::ReadWorkspaceFile { .. }
14905 | NodeRequest::WriteWorkspaceFile { .. }
14906 | NodeRequest::CreateWorkspaceFile { .. }
14907 | NodeRequest::CreateWorkspaceDirectory { .. }
14908 | NodeRequest::ReadGitHistory { .. }
14909 | NodeRequest::ReadGitDiff { .. }
14910 | NodeRequest::AcquireController { .. }
14911 | NodeRequest::ReleaseController
14912 | NodeRequest::Spawn { .. }
14913 | NodeRequest::SpawnWithOptions { .. }
14914 | NodeRequest::SpawnSpec { .. }
14915 | NodeRequest::RegisterWorkspace { .. }
14916 | NodeRequest::CreateStandaloneWorkspace { .. }
14917 | NodeRequest::UnregisterWorkspace { .. }
14918 | NodeRequest::CreateWorktree { .. }
14919 | NodeRequest::RemoveWorktree { .. }
14920 | NodeRequest::SpawnManagedWorktree { .. }
14921 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14922 | NodeRequest::CleanupManagedWorktree { .. }
14923 | NodeRequest::IndexProviderSession { .. }
14924 | NodeRequest::IndexNativeSession { .. }
14925 | NodeRequest::CatalogNativeSessions { .. }
14926 | NodeRequest::PageNativeSessions { .. }
14927 | NodeRequest::PreviewNativeSession { .. }
14928 | NodeRequest::PreviewSessionRecord { .. }
14929 | NodeRequest::ForgetContextPack { .. }
14930 | NodeRequest::ResolveDurableContextPack { .. }
14931 | NodeRequest::ReadContextPack { .. }
14932 | NodeRequest::Shutdown => false,
14933 }
14934}
14935
14936fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14937 if let Some(spec) = match request {
14938 NodeRequest::SpawnSpec { spec }
14939 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14940 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14941 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14942 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14943 _ => None,
14944 } {
14945 return shared
14946 .resolve_spawn_spec(spec)
14947 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14948 }
14949 match request {
14950 NodeRequest::Resume { session, .. }
14951 | NodeRequest::Prompt { session, .. }
14952 | NodeRequest::Paste { session, .. }
14953 | NodeRequest::Input { session, .. }
14954 | NodeRequest::TerminalBytes { session, .. }
14955 | NodeRequest::TerminalControl { session, .. }
14956 | NodeRequest::Resize { session, .. }
14957 | NodeRequest::Interrupt { session }
14958 | NodeRequest::Stop { session, .. }
14959 | NodeRequest::Remove { session }
14960 | NodeRequest::ResolveInteraction { session, .. }
14961 | NodeRequest::SetSessionMode { session, .. }
14962 | NodeRequest::SetSessionConfigOption { session, .. }
14963 | NodeRequest::SetSessionModel { session, .. }
14964 | NodeRequest::DiscoverHistory { session, .. }
14965 | NodeRequest::LoadHistory { session, .. }
14966 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14967 | NodeRequest::ExportContextPack { session } => shared
14968 .session_bindings
14969 .lock()
14970 .unwrap_or_else(|poisoned| poisoned.into_inner())
14971 .get(&session.session.instance_id)
14972 .filter(|binding| {
14973 binding.workspace_id == session.workspace_id
14974 && binding.generation == session.session.generation
14975 })
14976 .map_or(true, |binding| binding.bundle.is_some()),
14977 NodeRequest::RenameSessionRecord { record_id, .. }
14978 | NodeRequest::SetSessionTask { record_id, .. }
14979 | NodeRequest::ResumeSessionRecord { record_id, .. }
14980 | NodeRequest::ForgetSessionRecord { record_id } => shared
14981 .record(record_id)
14982 .map_or(true, |record| record.bundle.is_some()),
14983 NodeRequest::ArmHarnessMcpReservation { .. }
14984 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14985 | NodeRequest::ActivateHarnessMcpReservation { .. }
14986 | NodeRequest::AbortHarnessMcpReservation { .. }
14987 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14988 | NodeRequest::RejectHarnessMcpCall { .. }
14989 | NodeRequest::Snapshot
14990 | NodeRequest::Resync { .. }
14991 | NodeRequest::BeginDeliveryStage { .. }
14992 | NodeRequest::PutDeliveryBlobChunk { .. }
14993 | NodeRequest::CommitDeliveryStage { .. }
14994 | NodeRequest::AbortDeliveryStage { .. }
14995 | NodeRequest::BrowseHostDirectories { .. }
14996 | NodeRequest::InspectWorkspace { .. }
14997 | NodeRequest::ReadWorkspaceFile { .. }
14998 | NodeRequest::WriteWorkspaceFile { .. }
14999 | NodeRequest::CreateWorkspaceFile { .. }
15000 | NodeRequest::CreateWorkspaceDirectory { .. }
15001 | NodeRequest::ReadGitHistory { .. }
15002 | NodeRequest::ReadGitDiff { .. }
15003 | NodeRequest::AcquireController { .. }
15004 | NodeRequest::ReleaseController
15005 | NodeRequest::Spawn { .. }
15006 | NodeRequest::SpawnWithOptions { .. }
15007 | NodeRequest::SpawnSpec { .. }
15008 | NodeRequest::RegisterWorkspace { .. }
15009 | NodeRequest::CreateStandaloneWorkspace { .. }
15010 | NodeRequest::UnregisterWorkspace { .. }
15011 | NodeRequest::CreateWorktree { .. }
15012 | NodeRequest::RemoveWorktree { .. }
15013 | NodeRequest::SpawnManagedWorktree { .. }
15014 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15015 | NodeRequest::CleanupManagedWorktree { .. }
15016 | NodeRequest::IndexProviderSession { .. }
15017 | NodeRequest::IndexNativeSession { .. }
15018 | NodeRequest::CatalogNativeSessions { .. }
15019 | NodeRequest::PageNativeSessions { .. }
15020 | NodeRequest::PreviewNativeSession { .. }
15021 | NodeRequest::PreviewSessionRecord { .. }
15022 | NodeRequest::ForgetContextPack { .. }
15023 | NodeRequest::ResolveDurableContextPack { .. }
15024 | NodeRequest::ReadContextPack { .. }
15025 | NodeRequest::Shutdown => false,
15026 }
15027}
15028
15029fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15030 if request.requires_history_context_pack_capability() {
15031 return true;
15032 }
15033 if let Some(spec) = match request {
15034 NodeRequest::SpawnSpec { spec }
15035 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15036 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15037 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15038 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15039 _ => None,
15040 } {
15041 return shared
15042 .resolve_spawn_spec(spec)
15043 .map_or(true, |resolved| resolved.context_id.is_some());
15044 }
15045 match request {
15046 NodeRequest::Resume { session, .. }
15047 | NodeRequest::Prompt { session, .. }
15048 | NodeRequest::Paste { session, .. }
15049 | NodeRequest::Input { session, .. }
15050 | NodeRequest::TerminalBytes { session, .. }
15051 | NodeRequest::TerminalControl { session, .. }
15052 | NodeRequest::Resize { session, .. }
15053 | NodeRequest::Interrupt { session }
15054 | NodeRequest::Stop { session, .. }
15055 | NodeRequest::Remove { session }
15056 | NodeRequest::ResolveInteraction { session, .. }
15057 | NodeRequest::SetSessionMode { session, .. }
15058 | NodeRequest::SetSessionConfigOption { session, .. }
15059 | NodeRequest::SetSessionModel { session, .. } => shared
15060 .session_bindings
15061 .lock()
15062 .unwrap_or_else(|poisoned| poisoned.into_inner())
15063 .get(&session.session.instance_id)
15064 .filter(|binding| {
15065 binding.workspace_id == session.workspace_id
15066 && binding.generation == session.session.generation
15067 })
15068 .map_or(true, |binding| binding.context.is_some()),
15069 NodeRequest::RenameSessionRecord { record_id, .. }
15070 | NodeRequest::SetSessionTask { record_id, .. }
15071 | NodeRequest::ResumeSessionRecord { record_id, .. }
15072 | NodeRequest::ForgetSessionRecord { record_id } => shared
15073 .record(record_id)
15074 .map_or(true, |record| record.context.is_some()),
15075 NodeRequest::ArmHarnessMcpReservation { .. }
15076 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15077 | NodeRequest::ActivateHarnessMcpReservation { .. }
15078 | NodeRequest::AbortHarnessMcpReservation { .. }
15079 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15080 | NodeRequest::RejectHarnessMcpCall { .. }
15081 | NodeRequest::Snapshot
15082 | NodeRequest::Resync { .. }
15083 | NodeRequest::BeginDeliveryStage { .. }
15084 | NodeRequest::PutDeliveryBlobChunk { .. }
15085 | NodeRequest::CommitDeliveryStage { .. }
15086 | NodeRequest::AbortDeliveryStage { .. }
15087 | NodeRequest::BrowseHostDirectories { .. }
15088 | NodeRequest::InspectWorkspace { .. }
15089 | NodeRequest::ReadWorkspaceFile { .. }
15090 | NodeRequest::WriteWorkspaceFile { .. }
15091 | NodeRequest::CreateWorkspaceFile { .. }
15092 | NodeRequest::CreateWorkspaceDirectory { .. }
15093 | NodeRequest::ReadGitHistory { .. }
15094 | NodeRequest::ReadGitDiff { .. }
15095 | NodeRequest::AcquireController { .. }
15096 | NodeRequest::ReleaseController
15097 | NodeRequest::Spawn { .. }
15098 | NodeRequest::SpawnWithOptions { .. }
15099 | NodeRequest::SpawnSpec { .. }
15100 | NodeRequest::RegisterWorkspace { .. }
15101 | NodeRequest::CreateStandaloneWorkspace { .. }
15102 | NodeRequest::UnregisterWorkspace { .. }
15103 | NodeRequest::CreateWorktree { .. }
15104 | NodeRequest::RemoveWorktree { .. }
15105 | NodeRequest::SpawnManagedWorktree { .. }
15106 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15107 | NodeRequest::CleanupManagedWorktree { .. }
15108 | NodeRequest::IndexProviderSession { .. }
15109 | NodeRequest::IndexNativeSession { .. }
15110 | NodeRequest::CatalogNativeSessions { .. }
15111 | NodeRequest::PageNativeSessions { .. }
15112 | NodeRequest::PreviewNativeSession { .. }
15113 | NodeRequest::PreviewSessionRecord { .. }
15114 | NodeRequest::DiscoverHistory { .. }
15115 | NodeRequest::LoadHistory { .. }
15116 | NodeRequest::ExportContextPackForSessionRecord { .. }
15117 | NodeRequest::ExportContextPack { .. }
15118 | NodeRequest::ForgetContextPack { .. }
15119 | NodeRequest::ResolveDurableContextPack { .. }
15120 | NodeRequest::ReadContextPack { .. }
15121 | NodeRequest::Shutdown => false,
15122 }
15123}
15124
15125fn request_requires_open_provider_ids_with(
15126 request: &NodeRequest,
15127 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15128 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15129) -> bool {
15130 match request {
15131 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15132 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15133 NodeRequest::CatalogNativeSessions { route, .. }
15134 | NodeRequest::PageNativeSessions { route, .. } => {
15135 !provider_id_is_legacy(&route.provider)
15136 }
15137 NodeRequest::PreviewNativeSession { selection, .. }
15138 | NodeRequest::IndexNativeSession { selection, .. } => {
15139 !provider_id_is_legacy(&selection.route.provider)
15140 }
15141 NodeRequest::SpawnSpec { .. }
15142 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15143 NodeRequest::SpawnManagedWorktree { .. }
15144 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15145 NodeRequest::Resume { session, .. }
15146 | NodeRequest::Prompt { session, .. }
15147 | NodeRequest::Paste { session, .. }
15148 | NodeRequest::Input { session, .. }
15149 | NodeRequest::TerminalBytes { session, .. }
15150 | NodeRequest::TerminalControl { session, .. }
15151 | NodeRequest::Resize { session, .. }
15152 | NodeRequest::Interrupt { session }
15153 | NodeRequest::Stop { session, .. }
15154 | NodeRequest::Remove { session }
15155 | NodeRequest::ResolveInteraction { session, .. }
15156 | NodeRequest::SetSessionMode { session, .. }
15157 | NodeRequest::SetSessionConfigOption { session, .. }
15158 | NodeRequest::SetSessionModel { session, .. }
15159 | NodeRequest::DiscoverHistory { session, .. }
15160 | NodeRequest::LoadHistory { session, .. }
15161 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15162 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15163 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15164 NodeRequest::RenameSessionRecord { record_id, .. }
15165 | NodeRequest::SetSessionTask { record_id, .. }
15166 | NodeRequest::ResumeSessionRecord { record_id, .. }
15167 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15168 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15169 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15170 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15171 NodeRequest::ArmHarnessMcpReservation { .. }
15172 | NodeRequest::ActivateHarnessMcpReservation { .. }
15173 | NodeRequest::AbortHarnessMcpReservation { .. }
15174 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15175 | NodeRequest::RejectHarnessMcpCall { .. }
15176 | NodeRequest::Snapshot
15177 | NodeRequest::Resync { .. }
15178 | NodeRequest::BeginDeliveryStage { .. }
15179 | NodeRequest::PutDeliveryBlobChunk { .. }
15180 | NodeRequest::CommitDeliveryStage { .. }
15181 | NodeRequest::AbortDeliveryStage { .. }
15182 | NodeRequest::BrowseHostDirectories { .. }
15183 | NodeRequest::InspectWorkspace { .. }
15184 | NodeRequest::ReadWorkspaceFile { .. }
15185 | NodeRequest::WriteWorkspaceFile { .. }
15186 | NodeRequest::CreateWorkspaceFile { .. }
15187 | NodeRequest::CreateWorkspaceDirectory { .. }
15188 | NodeRequest::ReadGitHistory { .. }
15189 | NodeRequest::ReadGitDiff { .. }
15190 | NodeRequest::AcquireController { .. }
15191 | NodeRequest::ReleaseController
15192 | NodeRequest::RegisterWorkspace { .. }
15193 | NodeRequest::CreateStandaloneWorkspace { .. }
15194 | NodeRequest::UnregisterWorkspace { .. }
15195 | NodeRequest::CreateWorktree { .. }
15196 | NodeRequest::RemoveWorktree { .. }
15197 | NodeRequest::CleanupManagedWorktree { .. }
15198 | NodeRequest::Shutdown => false,
15199 NodeRequest::ForgetContextPack { .. } => true,
15200 NodeRequest::ResolveDurableContextPack { .. }
15206 | NodeRequest::ReadContextPack { .. } => true,
15207 }
15208}
15209
15210fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15211 snapshot.enabled_providers.retain(provider_id_is_legacy);
15212 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15213 snapshot
15214 .provider_runtime_statuses
15215 .as_slice()
15216 .iter()
15217 .filter(|status| provider_id_is_legacy(status.provider()))
15218 .cloned(),
15219 )
15220 .expect("a filtered provider runtime projection remains valid");
15221 snapshot
15222 .session_records
15223 .retain(|record| provider_id_is_legacy(&record.provider));
15224 for workspace in &mut snapshot.workspaces {
15225 workspace
15226 .sessions
15227 .retain(|session| provider_id_is_legacy(&session.agent_id));
15228 }
15229}
15230
15231fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15232 for record in &mut snapshot.session_records {
15233 record.environment_profile = None;
15234 }
15235 if let Some(profiles) = snapshot
15236 .launch_inventory
15237 .as_mut()
15238 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15239 {
15240 for profile in profiles {
15241 profile.environment_profile = None;
15242 }
15243 }
15244}
15245
15246fn project_event_without_child_environment_profile(
15247 mut envelope: NodeEventEnvelope,
15248) -> NodeEventEnvelope {
15249 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15250 record.environment_profile = None;
15251 }
15252 envelope
15253}
15254
15255fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15256 let contains_environment_profile = match reply.result.as_ref() {
15257 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15258 receipt.environment_profile.is_some()
15259 }
15260 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15261 receipt.spawn.environment_profile.is_some()
15262 }
15263 _ => false,
15264 };
15265 if contains_environment_profile {
15266 reply.result = Err(failure(
15267 NodeFailureCode::UnsupportedCapability,
15268 "child environment profiles were not negotiated",
15269 ));
15270 return;
15271 }
15272 let Ok(response) = reply.result.as_mut() else {
15273 return;
15274 };
15275 match response {
15276 NodeResponse::Snapshot { snapshot, .. } => {
15277 clear_snapshot_child_environment_profiles(snapshot);
15278 }
15279 NodeResponse::Resync { snapshot, events, .. } => {
15280 clear_snapshot_child_environment_profiles(snapshot);
15281 for event in events {
15282 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15283 record.environment_profile = None;
15284 }
15285 }
15286 }
15287 NodeResponse::SessionRecordUpdated { record }
15288 | NodeResponse::ProviderSessionIndexed { record }
15289 | NodeResponse::NativeSessionIndexed { record, .. }
15290 | NodeResponse::SessionRecordResumed { record, .. } => {
15291 record.environment_profile = None;
15292 }
15293 NodeResponse::Armed { .. }
15294 | NodeResponse::Spawned { .. }
15295 | NodeResponse::Activated { .. }
15296 | NodeResponse::Aborted { .. }
15297 | NodeResponse::ReplyChunkAccepted { .. }
15298 | NodeResponse::CallRejected { .. }
15299 | NodeResponse::DeliveryStageBegun { .. }
15300 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15301 | NodeResponse::DeliveryCommitted { .. }
15302 | NodeResponse::DeliveryStageAborted { .. }
15303 | NodeResponse::WorkspaceInspected { .. }
15304 | NodeResponse::HostDirectoriesBrowsed { .. }
15305 | NodeResponse::WorkspaceFileRead { .. }
15306 | NodeResponse::WorkspaceFileWritten { .. }
15307 | NodeResponse::WorkspaceFileCreated { .. }
15308 | NodeResponse::WorkspaceDirectoryCreated { .. }
15309 | NodeResponse::GitHistoryRead { .. }
15310 | NodeResponse::GitDiffRead { .. }
15311 | NodeResponse::Controller { .. }
15312 | NodeResponse::SpawnAccepted { .. }
15313 | NodeResponse::SpawnSpecAccepted { .. }
15314 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15315 | NodeResponse::ManagedWorktreeCleanup { .. }
15316 | NodeResponse::NativeSessionsCataloged { .. }
15317 | NodeResponse::NativeSessionsPaged { .. }
15318 | NodeResponse::NativeSessionPreviewed { .. }
15319 | NodeResponse::SessionRecordPreviewed { .. }
15320 | NodeResponse::HistoryDiscovered { .. }
15321 | NodeResponse::HistoryLoaded { .. }
15322 | NodeResponse::ContextPackForSessionRecordExported { .. }
15323 | NodeResponse::ContextPackExported { .. }
15324 | NodeResponse::ContextPackForgotten { .. }
15325 | NodeResponse::DurableContextPackResolved { .. }
15326 | NodeResponse::ContextPackBytesRead { .. }
15327 | NodeResponse::SessionRecordForgotten { .. }
15328 | NodeResponse::WorkspaceRegistered { .. }
15329 | NodeResponse::StandaloneWorkspaceCreated { .. }
15330 | NodeResponse::WorkspaceUnregistered { .. }
15331 | NodeResponse::WorktreeCreated { .. }
15332 | NodeResponse::WorktreeRemoved { .. }
15333 | NodeResponse::Accepted
15334 | NodeResponse::ShuttingDown => {}
15335 }
15336}
15337
15338fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15339 for record in &mut snapshot.session_records {
15340 record.bundle = None;
15341 }
15342}
15343
15344fn project_event_without_session_bundle(
15345 mut envelope: NodeEventEnvelope,
15346) -> NodeEventEnvelope {
15347 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15348 record.bundle = None;
15349 }
15350 envelope
15351}
15352
15353fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15354 let contains_bundle = match reply.result.as_ref() {
15355 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15356 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15357 receipt.spawn.bundle.is_some()
15358 }
15359 _ => false,
15360 };
15361 if contains_bundle {
15362 reply.result = Err(failure(
15363 NodeFailureCode::UnsupportedCapability,
15364 "session bundles were not negotiated",
15365 ));
15366 return;
15367 }
15368 let Ok(response) = reply.result.as_mut() else {
15369 return;
15370 };
15371 match response {
15372 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15373 NodeResponse::Resync { snapshot, events, .. } => {
15374 clear_snapshot_session_bundles(snapshot);
15375 for event in events {
15376 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15377 record.bundle = None;
15378 }
15379 }
15380 }
15381 NodeResponse::SessionRecordUpdated { record }
15382 | NodeResponse::ProviderSessionIndexed { record }
15383 | NodeResponse::NativeSessionIndexed { record, .. }
15384 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15385 NodeResponse::Armed { .. }
15386 | NodeResponse::Spawned { .. }
15387 | NodeResponse::Activated { .. }
15388 | NodeResponse::Aborted { .. }
15389 | NodeResponse::ReplyChunkAccepted { .. }
15390 | NodeResponse::CallRejected { .. }
15391 | NodeResponse::DeliveryStageBegun { .. }
15392 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15393 | NodeResponse::DeliveryCommitted { .. }
15394 | NodeResponse::DeliveryStageAborted { .. }
15395 | NodeResponse::WorkspaceInspected { .. }
15396 | NodeResponse::HostDirectoriesBrowsed { .. }
15397 | NodeResponse::WorkspaceFileRead { .. }
15398 | NodeResponse::WorkspaceFileWritten { .. }
15399 | NodeResponse::WorkspaceFileCreated { .. }
15400 | NodeResponse::WorkspaceDirectoryCreated { .. }
15401 | NodeResponse::GitHistoryRead { .. }
15402 | NodeResponse::GitDiffRead { .. }
15403 | NodeResponse::Controller { .. }
15404 | NodeResponse::SpawnAccepted { .. }
15405 | NodeResponse::SpawnSpecAccepted { .. }
15406 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15407 | NodeResponse::ManagedWorktreeCleanup { .. }
15408 | NodeResponse::NativeSessionsCataloged { .. }
15409 | NodeResponse::NativeSessionsPaged { .. }
15410 | NodeResponse::NativeSessionPreviewed { .. }
15411 | NodeResponse::SessionRecordPreviewed { .. }
15412 | NodeResponse::HistoryDiscovered { .. }
15413 | NodeResponse::HistoryLoaded { .. }
15414 | NodeResponse::ContextPackForSessionRecordExported { .. }
15415 | NodeResponse::ContextPackExported { .. }
15416 | NodeResponse::ContextPackForgotten { .. }
15417 | NodeResponse::DurableContextPackResolved { .. }
15418 | NodeResponse::ContextPackBytesRead { .. }
15419 | NodeResponse::SessionRecordForgotten { .. }
15420 | NodeResponse::WorkspaceRegistered { .. }
15421 | NodeResponse::StandaloneWorkspaceCreated { .. }
15422 | NodeResponse::WorkspaceUnregistered { .. }
15423 | NodeResponse::WorktreeCreated { .. }
15424 | NodeResponse::WorktreeRemoved { .. }
15425 | NodeResponse::Accepted
15426 | NodeResponse::ShuttingDown => {}
15427 }
15428}
15429
15430fn project_snapshot_history_for_wire(
15431 snapshot: &mut NodeSnapshot,
15432 include_history_metadata: bool,
15433) {
15434 for workspace in &mut snapshot.workspaces {
15435 for session in &mut workspace.sessions {
15436 if include_history_metadata {
15437 session.history.loaded = None;
15438 } else {
15439 session.history = Default::default();
15440 }
15441 }
15442 }
15443}
15444
15445fn project_event_history_for_wire(
15446 mut envelope: NodeEventEnvelope,
15447 include_history_metadata: bool,
15448) -> NodeEventEnvelope {
15449 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15450 for session in &mut workspace.sessions {
15451 if include_history_metadata {
15452 session.history.loaded = None;
15453 } else {
15454 session.history = Default::default();
15455 }
15456 }
15457 }
15458 envelope
15459}
15460
15461fn project_response_history_for_wire(
15462 reply: &mut ResponseEnvelope,
15463 include_history_metadata: bool,
15464) {
15465 let Ok(response) = reply.result.as_mut() else {
15466 return;
15467 };
15468 match response {
15469 NodeResponse::Snapshot { snapshot, .. } => {
15470 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15471 }
15472 NodeResponse::Resync {
15473 snapshot, events, ..
15474 } => {
15475 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15476 for event in events {
15477 *event = project_event_history_for_wire(
15478 event.clone(),
15479 include_history_metadata,
15480 );
15481 }
15482 }
15483 NodeResponse::WorkspaceRegistered { workspace }
15484 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15485 | NodeResponse::WorktreeCreated { workspace, .. } => {
15486 for session in &mut workspace.sessions {
15487 if include_history_metadata {
15488 session.history.loaded = None;
15489 } else {
15490 session.history = Default::default();
15491 }
15492 }
15493 }
15494 _ => {}
15495 }
15496}
15497
15498fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15499 for record in &mut snapshot.session_records {
15500 record.context_id = None;
15501 record.context = None;
15502 record.exported_context = None;
15503 }
15504}
15505
15506fn project_event_without_context_pack(
15507 mut envelope: NodeEventEnvelope,
15508) -> NodeEventEnvelope {
15509 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15510 record.context_id = None;
15511 record.context = None;
15512 record.exported_context = None;
15513 }
15514 envelope
15515}
15516
15517fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15518 let contains_context = match reply.result.as_ref() {
15519 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15520 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15521 receipt.spawn.context.is_some()
15522 }
15523 Ok(NodeResponse::HistoryDiscovered { .. })
15524 | Ok(NodeResponse::HistoryLoaded { .. })
15525 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15526 | Ok(NodeResponse::ContextPackExported { .. })
15527 | Ok(NodeResponse::ContextPackForgotten { .. })
15528 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15529 _ => false,
15530 };
15531 if contains_context {
15532 reply.result = Err(failure(
15533 NodeFailureCode::UnsupportedCapability,
15534 "history context packs were not negotiated",
15535 ));
15536 return;
15537 }
15538 let Ok(response) = reply.result.as_mut() else {
15539 return;
15540 };
15541 match response {
15542 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15543 NodeResponse::Resync {
15544 snapshot, events, ..
15545 } => {
15546 clear_snapshot_context_packs(snapshot);
15547 for event in events {
15548 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15549 record.context_id = None;
15550 record.context = None;
15551 record.exported_context = None;
15552 }
15553 }
15554 }
15555 NodeResponse::SessionRecordUpdated { record }
15556 | NodeResponse::ProviderSessionIndexed { record }
15557 | NodeResponse::NativeSessionIndexed { record, .. }
15558 | NodeResponse::SessionRecordResumed { record, .. } => {
15559 record.context_id = None;
15560 record.context = None;
15561 record.exported_context = None;
15562 }
15563 _ => {}
15564 }
15565}
15566
15567fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15568 let contains_open_record = match reply.result.as_ref() {
15569 Ok(NodeResponse::SessionRecordUpdated { record })
15570 | Ok(NodeResponse::ProviderSessionIndexed { record })
15571 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15572 !provider_id_is_legacy(&record.provider)
15573 }
15574 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15575 !provider_id_is_legacy(&selection.route.provider)
15576 || !provider_id_is_legacy(&record.provider)
15577 }
15578 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15579 !provider_id_is_legacy(&receipt.provider)
15580 }
15581 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15582 !provider_id_is_legacy(&receipt.spawn.provider)
15583 }
15584 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15585 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15586 !provider_id_is_legacy(&route.provider)
15587 }
15588 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15589 !provider_id_is_legacy(&selection.route.provider)
15590 }
15591 _ => false,
15592 };
15593 if contains_open_record {
15594 reply.result = Err(failure(
15595 NodeFailureCode::UnsupportedCapability,
15596 "open provider IDs were not negotiated",
15597 ));
15598 return;
15599 }
15600 let Ok(response) = reply.result.as_mut() else {
15601 return;
15602 };
15603 match response {
15604 NodeResponse::Snapshot { snapshot, .. } => {
15605 project_snapshot_legacy_provider_ids(snapshot);
15606 }
15607 NodeResponse::Resync { snapshot, events, .. } => {
15608 project_snapshot_legacy_provider_ids(snapshot);
15609 *events = std::mem::take(events)
15610 .into_iter()
15611 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15612 .collect();
15613 }
15614 NodeResponse::WorkspaceRegistered { workspace }
15615 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15616 | NodeResponse::WorktreeCreated { workspace, .. } => {
15617 workspace
15618 .sessions
15619 .retain(|session| provider_id_is_legacy(&session.agent_id));
15620 }
15621 NodeResponse::Armed { .. }
15622 | NodeResponse::Spawned { .. }
15623 | NodeResponse::Activated { .. }
15624 | NodeResponse::Aborted { .. }
15625 | NodeResponse::ReplyChunkAccepted { .. }
15626 | NodeResponse::CallRejected { .. }
15627 | NodeResponse::DeliveryStageBegun { .. }
15628 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15629 | NodeResponse::DeliveryCommitted { .. }
15630 | NodeResponse::DeliveryStageAborted { .. }
15631 | NodeResponse::WorkspaceInspected { .. }
15632 | NodeResponse::HostDirectoriesBrowsed { .. }
15633 | NodeResponse::WorkspaceFileRead { .. }
15634 | NodeResponse::WorkspaceFileWritten { .. }
15635 | NodeResponse::WorkspaceFileCreated { .. }
15636 | NodeResponse::WorkspaceDirectoryCreated { .. }
15637 | NodeResponse::GitHistoryRead { .. }
15638 | NodeResponse::GitDiffRead { .. }
15639 | NodeResponse::Controller { .. }
15640 | NodeResponse::SpawnAccepted { .. }
15641 | NodeResponse::SpawnSpecAccepted { .. }
15642 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15643 | NodeResponse::ManagedWorktreeCleanup { .. }
15644 | NodeResponse::NativeSessionsCataloged { .. }
15645 | NodeResponse::NativeSessionsPaged { .. }
15646 | NodeResponse::NativeSessionPreviewed { .. }
15647 | NodeResponse::SessionRecordPreviewed { .. }
15648 | NodeResponse::HistoryDiscovered { .. }
15649 | NodeResponse::HistoryLoaded { .. }
15650 | NodeResponse::ContextPackForSessionRecordExported { .. }
15651 | NodeResponse::ContextPackExported { .. }
15652 | NodeResponse::ContextPackForgotten { .. }
15653 | NodeResponse::DurableContextPackResolved { .. }
15654 | NodeResponse::ContextPackBytesRead { .. }
15655 | NodeResponse::SessionRecordUpdated { .. }
15656 | NodeResponse::ProviderSessionIndexed { .. }
15657 | NodeResponse::NativeSessionIndexed { .. }
15658 | NodeResponse::SessionRecordResumed { .. }
15659 | NodeResponse::SessionRecordForgotten { .. }
15660 | NodeResponse::WorkspaceUnregistered { .. }
15661 | NodeResponse::WorktreeRemoved { .. }
15662 | NodeResponse::Accepted
15663 | NodeResponse::ShuttingDown => {}
15664 }
15665}
15666
15667fn project_event_legacy_provider_ids(
15668 shared: &NodeShared,
15669 mut envelope: NodeEventEnvelope,
15670) -> Option<NodeEventEnvelope> {
15671 let include = match &mut envelope.event {
15672 NodeEvent::HarnessMcpReadCall { .. } => true,
15673 NodeEvent::Control { address, .. }
15674 | NodeEvent::AgentStream { address, .. }
15675 | NodeEvent::TerminalFrame { address, .. } => shared
15676 .validate_address(address)
15677 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15678 NodeEvent::WorkspaceAdded { workspace } => {
15679 workspace
15680 .sessions
15681 .retain(|session| provider_id_is_legacy(&session.agent_id));
15682 true
15683 }
15684 NodeEvent::SessionRecordUpserted { record } => {
15685 provider_id_is_legacy(&record.provider)
15686 }
15687 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15688 NodeEvent::SessionRecordRemoved { .. } => shared
15689 .history
15690 .lock()
15691 .unwrap_or_else(|poisoned| poisoned.into_inner())
15692 .removed_record_providers
15693 .get(&envelope.sequence)
15694 .is_some_and(provider_id_is_legacy),
15695 NodeEvent::ControllerChanged { .. }
15696 | NodeEvent::ManagedWorktreeUpserted { .. }
15697 | NodeEvent::ManagedWorktreeRemoved { .. }
15698 | NodeEvent::WorkspaceRemoved { .. }
15699 | NodeEvent::ResyncRequired { .. } => true,
15700 };
15701 include.then_some(envelope)
15702}
15703
15704fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15705 let Ok(response) = reply.result.as_mut() else {
15706 return;
15707 };
15708 match response {
15709 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15710 snapshot.provider_runtime_statuses.clear();
15711 }
15712 NodeResponse::Armed { .. }
15713 | NodeResponse::Spawned { .. }
15714 | NodeResponse::Activated { .. }
15715 | NodeResponse::Aborted { .. }
15716 | NodeResponse::ReplyChunkAccepted { .. }
15717 | NodeResponse::CallRejected { .. }
15718 | NodeResponse::DeliveryStageBegun { .. }
15719 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15720 | NodeResponse::DeliveryCommitted { .. }
15721 | NodeResponse::DeliveryStageAborted { .. }
15722 | NodeResponse::WorkspaceInspected { .. }
15723 | NodeResponse::HostDirectoriesBrowsed { .. }
15724 | NodeResponse::WorkspaceFileRead { .. }
15725 | NodeResponse::WorkspaceFileWritten { .. }
15726 | NodeResponse::WorkspaceFileCreated { .. }
15727 | NodeResponse::WorkspaceDirectoryCreated { .. }
15728 | NodeResponse::GitHistoryRead { .. }
15729 | NodeResponse::GitDiffRead { .. }
15730 | NodeResponse::Controller { .. }
15731 | NodeResponse::SpawnAccepted { .. }
15732 | NodeResponse::SpawnSpecAccepted { .. }
15733 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15734 | NodeResponse::ManagedWorktreeCleanup { .. }
15735 | NodeResponse::NativeSessionsCataloged { .. }
15736 | NodeResponse::NativeSessionsPaged { .. }
15737 | NodeResponse::NativeSessionPreviewed { .. }
15738 | NodeResponse::SessionRecordPreviewed { .. }
15739 | NodeResponse::HistoryDiscovered { .. }
15740 | NodeResponse::HistoryLoaded { .. }
15741 | NodeResponse::ContextPackForSessionRecordExported { .. }
15742 | NodeResponse::ContextPackExported { .. }
15743 | NodeResponse::ContextPackForgotten { .. }
15744 | NodeResponse::DurableContextPackResolved { .. }
15745 | NodeResponse::ContextPackBytesRead { .. }
15746 | NodeResponse::SessionRecordUpdated { .. }
15747 | NodeResponse::ProviderSessionIndexed { .. }
15748 | NodeResponse::NativeSessionIndexed { .. }
15749 | NodeResponse::SessionRecordResumed { .. }
15750 | NodeResponse::SessionRecordForgotten { .. }
15751 | NodeResponse::WorkspaceRegistered { .. }
15752 | NodeResponse::StandaloneWorkspaceCreated { .. }
15753 | NodeResponse::WorkspaceUnregistered { .. }
15754 | NodeResponse::WorktreeCreated { .. }
15755 | NodeResponse::WorktreeRemoved { .. }
15756 | NodeResponse::Accepted
15757 | NodeResponse::ShuttingDown => {}
15758 }
15759}
15760
15761struct ControllerReleaseGuard {
15762 shared: Arc<NodeShared>,
15763 connection_id: u64,
15764}
15765
15766impl Drop for ControllerReleaseGuard {
15767 fn drop(&mut self) {
15768 self.shared.release_controller(self.connection_id);
15769 }
15770}
15771
15772struct AbortTaskOnDrop(AbortHandle);
15773
15774impl Drop for AbortTaskOnDrop {
15775 fn drop(&mut self) {
15776 self.0.abort();
15777 }
15778}
15779
15780fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15785 std::env::var(name)
15786 .ok()
15787 .and_then(|value| value.parse::<u64>().ok())
15788 .filter(|value| *value > 0)
15789 .unwrap_or(default)
15790 .min(max)
15791}
15792
15793fn workspace_inspection_time_budget_ms() -> u64 {
15794 env_config_bounded(
15795 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15796 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15797 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15798 )
15799}
15800
15801fn workspace_inspection_entry_cap() -> usize {
15802 env_config_bounded(
15803 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15804 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15805 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15806 ) as usize
15807}
15808
15809struct WorkspaceWalkBudget {
15815 deadline: Instant,
15816 entry_cap: usize,
15817 entries_visited: u64,
15818 time_budget_exceeded: bool,
15819 entry_cap_exceeded: bool,
15820}
15821
15822impl WorkspaceWalkBudget {
15823 fn new(deadline: Instant, entry_cap: usize) -> Self {
15824 Self {
15825 deadline,
15826 entry_cap,
15827 entries_visited: 0,
15828 time_budget_exceeded: false,
15829 entry_cap_exceeded: false,
15830 }
15831 }
15832
15833 fn is_exhausted(&self) -> bool {
15834 self.time_budget_exceeded || self.entry_cap_exceeded
15835 }
15836
15837 fn record_visit(&mut self) -> bool {
15840 self.entries_visited += 1;
15841 if self.entries_visited as usize >= self.entry_cap {
15842 self.entry_cap_exceeded = true;
15843 }
15844 if Instant::now() >= self.deadline {
15845 self.time_budget_exceeded = true;
15846 }
15847 self.is_exhausted()
15848 }
15849}
15850
15851fn collect_workspace_entries(
15852 root: &Path,
15853 deadline: Instant,
15854 entry_cap: usize,
15855) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15856 let mut entries = Vec::new();
15857 let mut truncated = false;
15858 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15859 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15860 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15864 (entries, truncated, budget)
15865}
15866
15867fn workspace_inspection_truncation(
15871 walk_budget: &WorkspaceWalkBudget,
15872 git_time_budget_exceeded: bool,
15873 elapsed: Duration,
15874) -> Option<WorkspaceInspectionTruncationV1> {
15875 if !walk_budget.time_budget_exceeded
15876 && !walk_budget.entry_cap_exceeded
15877 && !git_time_budget_exceeded
15878 {
15879 return None;
15880 }
15881 Some(WorkspaceInspectionTruncationV1 {
15882 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15883 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15884 git_time_budget_exceeded,
15885 entries_visited: walk_budget.entries_visited,
15886 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15887 })
15888}
15889
15890fn walk_workspace_directory(
15891 directory: &Path,
15892 relative_directory: Option<&RepositoryPath>,
15893 depth: usize,
15894 entries: &mut Vec<WorkspaceEntry>,
15895 truncated: &mut bool,
15896 budget: &mut WorkspaceWalkBudget,
15897) {
15898 if budget.is_exhausted() {
15899 *truncated = true;
15900 return;
15901 }
15902 let read_dir = match std::fs::read_dir(directory) {
15903 Ok(read_dir) => read_dir,
15904 Err(_) => {
15905 *truncated = true;
15906 return;
15907 }
15908 };
15909 let mut children = Vec::new();
15910 for child in read_dir {
15911 match child {
15912 Ok(child) => children.push(child),
15913 Err(_) => *truncated = true,
15914 }
15915 }
15916 children.sort_by_key(|child| child.file_name());
15917 for child in children {
15918 if budget.record_visit() {
15919 *truncated = true;
15920 return;
15921 }
15922 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15923 *truncated = true;
15924 return;
15925 }
15926 let file_type = match child.file_type() {
15927 Ok(file_type) => file_type,
15928 Err(_) => {
15929 *truncated = true;
15930 continue;
15931 }
15932 };
15933 if file_type.is_symlink() {
15934 continue;
15935 }
15936 let name = match child.file_name().into_string() {
15937 Ok(name) => name,
15938 Err(_) => {
15939 *truncated = true;
15940 continue;
15941 }
15942 };
15943 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15944 continue;
15945 }
15946 let relative_path = match relative_directory {
15947 None => windows_repository_path(name),
15948 Some(parent) => parent
15949 .as_utf8()
15950 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15951 };
15952 let Some(relative_path) = relative_path else {
15953 *truncated = true;
15954 continue;
15955 };
15956 let kind = if file_type.is_dir() {
15957 WorkspaceEntryKind::Directory
15958 } else if file_type.is_file() {
15959 WorkspaceEntryKind::File
15960 } else {
15961 continue;
15962 };
15963 entries.push(WorkspaceEntry {
15964 relative_path: relative_path.clone(),
15965 kind,
15966 });
15967 if file_type.is_dir() {
15968 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15969 walk_workspace_directory(
15970 &child.path(),
15971 Some(&relative_path),
15972 depth + 1,
15973 entries,
15974 truncated,
15975 budget,
15976 );
15977 } else {
15978 *truncated = true;
15979 }
15980 }
15981 }
15982}
15983
15984fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15985 if value.contains('\\') {
15986 return None;
15987 }
15988 RepositoryPath::utf8(value).ok()
15989}
15990
15991fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15992 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15993}
15994
15995fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16004 if name.eq_ignore_ascii_case(".git")
16005 || name.eq_ignore_ascii_case("target")
16006 || name.eq_ignore_ascii_case("node_modules")
16007 {
16008 return true;
16009 }
16010 path.join("CACHEDIR.TAG").is_file()
16011}
16012
16013fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16014 matches!(
16015 status,
16016 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16017 )
16018}
16019
16020#[derive(Clone, Debug, Eq, PartialEq)]
16021enum ContextPackRepositoryHead {
16022 NotRepository,
16023 Unborn,
16024 Commit(String),
16025}
16026
16027#[derive(Clone, Debug, Eq, PartialEq)]
16028struct ContextPackRepositoryObservation {
16029 head: ContextPackRepositoryHead,
16030 git: GitSnapshot,
16031 files: Vec<ContextPackRepositoryFileSource>,
16032}
16033
16034fn stable_context_pack_repository(
16035 first: ContextPackRepositoryObservation,
16036 second: ContextPackRepositoryObservation,
16037) -> Result<ContextPackRepository, NodeFailure> {
16038 if first != second {
16039 return Err(failure(
16040 NodeFailureCode::ContextPackMaterializationFailed,
16041 "context repository changed during bounded capture",
16042 ));
16043 }
16044 Ok(ContextPackRepository::from_sources(
16045 &second.git,
16046 second.files,
16047 ))
16048}
16049
16050fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16057 if Instant::now() < deadline {
16058 return false;
16059 }
16060 append_git_diagnostic(
16061 snapshot,
16062 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16063 );
16064 true
16065}
16066
16067async fn run_git_bounded_with_deadline(
16076 root: &str,
16077 arguments: &[&str],
16078 output_limit: usize,
16079 deadline: Instant,
16080) -> io::Result<GitCommandOutput> {
16081 let remaining = deadline.checked_duration_since(Instant::now());
16082 let timeout_ms = remaining
16083 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16084 .unwrap_or(0)
16085 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16086 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16087}
16088
16089async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16096 let mut snapshot = GitSnapshot {
16097 is_repository: false,
16098 branch: None,
16099 status: Vec::new(),
16100 recent_commits: Vec::new(),
16101 worktrees: Vec::new(),
16102 managed_worktree: None,
16103 truncated: false,
16104 diagnostic: None,
16105 };
16106 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16107 return (snapshot, true);
16108 }
16109 let repository = match run_git_bounded_with_deadline(
16110 root,
16111 &["rev-parse", "--is-inside-work-tree"],
16112 4 * 1_024,
16113 deadline,
16114 )
16115 .await
16116 {
16117 Ok(output) => output,
16118 Err(error) => {
16119 append_git_diagnostic(
16120 &mut snapshot,
16121 &format!("git inspection unavailable: {error}"),
16122 );
16123 return (snapshot, false);
16124 }
16125 };
16126 snapshot.truncated |= repository.truncated;
16127 if repository.timed_out {
16128 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16129 return (snapshot, false);
16130 }
16131 if !repository.success
16132 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16133 {
16134 let stderr = String::from_utf8_lossy(&repository.stderr);
16135 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16136 append_git_diagnostic(&mut snapshot, stderr.trim());
16137 }
16138 return (snapshot, false);
16139 }
16140 snapshot.is_repository = true;
16141
16142 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16143 return (snapshot, true);
16144 }
16145 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16146 Ok(output) => {
16147 snapshot.truncated |= output.truncated;
16148 if output.timed_out {
16149 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16150 } else if output.success {
16151 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16152 if !branch.is_empty() {
16153 snapshot.branch = Some(truncate_git_field(
16154 &branch,
16155 MAX_REPOSITORY_PATH_BYTES,
16156 &mut snapshot.truncated,
16157 ));
16158 }
16159 }
16160 }
16161 Err(error) => append_git_diagnostic(
16162 &mut snapshot,
16163 &format!("git branch query failed: {error}"),
16164 ),
16165 }
16166 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16167 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16168 snapshot.truncated |= output.truncated;
16169 if output.success && !output.timed_out {
16170 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16171 if !head.is_empty() {
16172 snapshot.branch = Some(format!("detached:{head}"));
16173 }
16174 }
16175 }
16176 }
16177
16178 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16179 return (snapshot, true);
16180 }
16181 match run_git_bounded_with_deadline(
16182 root,
16183 &[
16184 "status",
16185 "--porcelain=v1",
16186 "-z",
16187 "--renames",
16188 "--untracked-files=normal",
16189 "--",
16190 ".",
16191 ],
16192 GIT_OUTPUT_MAX_BYTES,
16193 deadline,
16194 )
16195 .await
16196 {
16197 Ok(output) => {
16198 snapshot.truncated |= output.truncated;
16199 if output.timed_out {
16200 append_git_diagnostic(&mut snapshot, "git status timed out");
16201 } else if output.success {
16202 parse_git_status(&output.stdout, &mut snapshot);
16203 } else {
16204 append_git_diagnostic(
16205 &mut snapshot,
16206 String::from_utf8_lossy(&output.stderr).trim(),
16207 );
16208 }
16209 }
16210 Err(error) => append_git_diagnostic(
16211 &mut snapshot,
16212 &format!("git status failed: {error}"),
16213 ),
16214 }
16215
16216 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16217 return (snapshot, true);
16218 }
16219 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16220 match run_git_bounded_with_deadline(
16221 root,
16222 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16223 16 * 1_024,
16224 deadline,
16225 )
16226 .await
16227 {
16228 Ok(output) => {
16229 snapshot.truncated |= output.truncated;
16230 if output.timed_out {
16231 append_git_diagnostic(&mut snapshot, "git log timed out");
16232 } else if output.success {
16233 parse_git_commits(&output.stdout, &mut snapshot);
16234 } else {
16235 let stderr = String::from_utf8_lossy(&output.stderr);
16236 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16237 append_git_diagnostic(&mut snapshot, stderr.trim());
16238 }
16239 }
16240 }
16241 Err(error) => append_git_diagnostic(
16242 &mut snapshot,
16243 &format!("git log failed: {error}"),
16244 ),
16245 }
16246
16247 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16248 return (snapshot, true);
16249 }
16250 match list_git_worktrees_with_deadline(root, deadline).await {
16251 Ok(worktrees) => {
16252 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16253 }
16254 Err(error) => append_git_diagnostic(
16255 &mut snapshot,
16256 &format!("git worktree list failed: {}", error.message),
16257 ),
16258 }
16259 (snapshot, false)
16260}
16261
16262fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16263 let mut remaining = output;
16264 while !remaining.is_empty() {
16265 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16266 snapshot.truncated = true;
16267 break;
16268 }
16269 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16270 snapshot.truncated = true;
16271 break;
16272 };
16273 let record = &remaining[..record_end];
16274 remaining = &remaining[record_end + 1..];
16275 let has_previous_path = record
16276 .get(..2)
16277 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16278 let previous_path_bytes = if has_previous_path {
16279 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16280 snapshot.truncated = true;
16281 break;
16282 };
16283 let previous = &remaining[..previous_end];
16284 remaining = &remaining[previous_end + 1..];
16285 Some(previous)
16286 } else {
16287 None
16288 };
16289 if record.len() < 4
16290 || record[2] != b' '
16291 || !is_git_status_code(record[0])
16292 || !is_git_status_code(record[1])
16293 {
16294 snapshot.truncated = true;
16295 continue;
16296 }
16297 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16298 snapshot.truncated = true;
16299 continue;
16300 };
16301 let previous_path = match previous_path_bytes {
16302 Some(previous) => {
16303 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16304 snapshot.truncated = true;
16305 continue;
16306 };
16307 Some(previous)
16308 }
16309 None => None,
16310 };
16311 snapshot.status.push(GitStatusEntry {
16312 index_status: (record[0] as char).to_string(),
16313 worktree_status: (record[1] as char).to_string(),
16314 path,
16315 previous_path,
16316 });
16317 }
16318}
16319
16320fn is_git_status_code(byte: u8) -> bool {
16321 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16322}
16323
16324fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16325 for line in String::from_utf8_lossy(output).lines() {
16326 let Some((id, summary)) = line.split_once('\u{1f}') else {
16327 continue;
16328 };
16329 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16330 snapshot.truncated = true;
16331 break;
16332 }
16333 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16334 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16335 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16336 }
16337}
16338
16339fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16340 if value.len() <= max_bytes {
16341 return value.to_owned();
16342 }
16343 *truncated = true;
16344 let mut boundary = max_bytes;
16345 while boundary > 0 && !value.is_char_boundary(boundary) {
16346 boundary -= 1;
16347 }
16348 value[..boundary].to_owned()
16349}
16350
16351fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16352 if message.is_empty() {
16353 return;
16354 }
16355 let normalized = message
16356 .chars()
16357 .map(|character| if character.is_control() { ' ' } else { character })
16358 .collect::<String>();
16359 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16360 if !diagnostic.is_empty() {
16361 diagnostic.push_str("; ");
16362 }
16363 diagnostic.push_str(normalized.trim());
16364 snapshot.diagnostic = Some(truncate_git_field(
16365 &diagnostic,
16366 GIT_DIAGNOSTIC_MAX_BYTES,
16367 &mut snapshot.truncated,
16368 ));
16369}
16370
16371async fn run_git_bounded(
16372 root: &str,
16373 arguments: &[&str],
16374 output_limit: usize,
16375) -> io::Result<GitCommandOutput> {
16376 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16377}
16378
16379async fn read_git_history_bounded(
16380 root: &str,
16381 path: Option<&RepositoryPath>,
16382 before: Option<&GitObjectId>,
16383 limit: u16,
16384) -> Result<GitHistoryPage, NodeFailure> {
16385 let path = path
16386 .map(|path| {
16387 path.as_utf8().ok_or_else(|| {
16388 failure(
16389 NodeFailureCode::InvalidRepositoryPath,
16390 "git history path must use UTF-8",
16391 )
16392 })
16393 })
16394 .transpose()?
16395 .unwrap_or(".");
16396 let count = (usize::from(limit) + 1).to_string();
16397 let start = before
16398 .map(|revision| format!("{}^", revision.as_str()))
16399 .unwrap_or_else(|| "HEAD".to_owned());
16400 let arguments = [
16401 "--no-pager",
16402 "log",
16403 "--no-decorate",
16404 "--date=iso-strict",
16405 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16406 "-n",
16407 count.as_str(),
16408 start.as_str(),
16409 "--",
16410 path,
16411 ];
16412 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16413 .await
16414 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16415 if output.timed_out {
16416 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16417 }
16418 if !output.success {
16419 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16420 }
16421 let mut commits = parse_git_history(&output.stdout);
16422 let has_more = commits.len() > usize::from(limit) || output.truncated;
16423 commits.truncate(usize::from(limit));
16424 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16425 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16426}
16427
16428fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16429 String::from_utf8_lossy(output)
16430 .split('\u{1e}')
16431 .filter_map(|record| {
16432 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16433 if fields.len() != 9 {
16434 return None;
16435 }
16436 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16437 let parents = if fields[1].is_empty() {
16438 Vec::new()
16439 } else {
16440 fields[1]
16441 .split(' ')
16442 .map(|value| GitObjectId::new(value.to_owned()))
16443 .collect::<Result<Vec<_>, _>>()
16444 .ok()?
16445 };
16446 Some(GitCommitDetails {
16447 id,
16448 parents,
16449 subject: bounded_git_text(fields[2], 512),
16450 author_name: bounded_git_text(fields[3], 256),
16451 author_email: bounded_git_text(fields[4], 320),
16452 authored_at: bounded_git_text(fields[5], 64),
16453 committer_name: bounded_git_text(fields[6], 256),
16454 committer_email: bounded_git_text(fields[7], 320),
16455 committed_at: bounded_git_text(fields[8], 64),
16456 signature_status: GitSignatureStatus::NoSignature,
16457 signer: None,
16458 })
16459 })
16460 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16461 .collect()
16462}
16463
16464fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16465 let normalized = value
16466 .chars()
16467 .map(|character| if character.is_control() { ' ' } else { character })
16468 .collect::<String>();
16469 if normalized.len() <= max_bytes {
16470 return normalized;
16471 }
16472 let mut boundary = max_bytes;
16473 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16474 boundary -= 1;
16475 }
16476 normalized[..boundary].to_owned()
16477}
16478
16479async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16480 let path = request.path.as_ref().map(|path| {
16481 path.as_utf8()
16482 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16483 }).transpose()?;
16484 let mut arguments = match &request.mode {
16485 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16486 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16487 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16488 };
16489 if let GitDiffMode::Commit { revision } = &request.mode {
16490 arguments.push(revision.as_str());
16491 }
16492 arguments.push("--");
16493 if let Some(path) = path {
16494 arguments.push(path);
16495 }
16496 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16497 .await
16498 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16499 if output.timed_out {
16500 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16501 }
16502 if !output.success {
16503 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16504 }
16505 Ok(GitDiff {
16506 mode: request.mode,
16507 path: request.path,
16508 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16509 truncated: output.truncated,
16510 })
16511}
16512
16513async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16514 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16515 ResponseEnvelope { request_id: envelope.request_id, result }
16516}
16517
16518async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16519 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16520 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16521 }
16522 let read_only = matches!(
16523 &request,
16524 NodeRequest::Snapshot
16525 | NodeRequest::Resync { .. }
16526 | NodeRequest::BrowseHostDirectories { .. }
16527 | NodeRequest::InspectWorkspace { .. }
16528 | NodeRequest::ReadWorkspaceFile { .. }
16529 | NodeRequest::ReadGitHistory { .. }
16530 | NodeRequest::ReadGitDiff { .. }
16531 | NodeRequest::CatalogNativeSessions { .. }
16532 | NodeRequest::PageNativeSessions { .. }
16533 | NodeRequest::PreviewNativeSession { .. }
16534 | NodeRequest::PreviewSessionRecord { .. }
16535 );
16536 let _mutation_guard = if read_only {
16537 None
16538 } else {
16539 let guard = shared.mutation_gate.lock().await;
16540 if shared.shutdown.load(Ordering::Acquire) {
16541 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16542 }
16543 Some(guard)
16544 };
16545 match request {
16546 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16547 event_sequence: shared.current_sequence(),
16548 controller: shared.controller_state(),
16549 snapshot: shared.snapshot(),
16550 }),
16551 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16552 NodeRequest::ArmHarnessMcpReservation {
16553 reservation_id,
16554 activation_digest,
16555 spawn_spec,
16556 launch,
16557 expires_at_unix_ms,
16558 } => {
16559 shared.require_controller(connection_id, role)?;
16560 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16561 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16562 })?;
16563 let expires_at_unix_ms = registry
16564 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16565 .await
16566 .map_err(harness_mcp_failure)?;
16567 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16568 }
16569 NodeRequest::SpawnSpecWithHarnessMcp {
16570 reservation_id,
16571 activation_digest,
16572 spec,
16573 deadline_unix_ms,
16574 } => {
16575 shared.require_controller(connection_id, role)?;
16576 let receipt = shared
16577 .spawn_from_spec_with_harness_mcp(
16578 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16579 )
16580 .await?;
16581 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16582 }
16583 NodeRequest::ActivateHarnessMcpReservation {
16584 reservation_id,
16585 activation_digest,
16586 record_id,
16587 session,
16588 } => {
16589 shared.require_controller(connection_id, role)?;
16590 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16591 current.instance_id == session.session.instance_id
16592 && current.generation == session.session.generation
16593 }).and_then(|current| current.process_id).ok_or_else(|| {
16594 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16595 })?;
16596 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16597 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16598 })?.activate(
16599 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16600 ).map_err(harness_mcp_failure)?;
16601 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16602 }
16603 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16604 shared.require_controller(connection_id, role)?;
16605 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16606 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16607 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16608 if let Some(instance_id) = instance_id {
16609 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16610 }
16611 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16612 }
16613 NodeRequest::PutHarnessMcpReplyChunk {
16614 reservation_id, activation_digest, record_id, session, call_id,
16615 offset, final_chunk, chunk_hex,
16616 } => {
16617 shared.require_controller(connection_id, role)?;
16618 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16619 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16620 })?.put_reply_chunk(
16621 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16622 offset, final_chunk, &chunk_hex,
16623 ).map_err(harness_mcp_failure)?;
16624 Ok(NodeResponse::ReplyChunkAccepted {
16625 reservation_id, activation_digest, record_id, session, call_id,
16626 next_offset, completed,
16627 })
16628 }
16629 NodeRequest::RejectHarnessMcpCall {
16630 reservation_id, activation_digest, record_id, session, call_id, reason,
16631 } => {
16632 shared.require_controller(connection_id, role)?;
16633 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16634 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16635 })?.reject_call(
16636 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16637 ).map_err(harness_mcp_failure)?;
16638 Ok(NodeResponse::CallRejected {
16639 reservation_id, activation_digest, record_id, session, call_id,
16640 })
16641 }
16642 NodeRequest::BeginDeliveryStage { manifest } => {
16643 shared.require_controller(connection_id, role)?;
16644 shared.begin_delivery_stage(manifest)
16645 }
16646 NodeRequest::PutDeliveryBlobChunk {
16647 stage_id,
16648 blob_digest,
16649 offset,
16650 chunk_hex,
16651 } => {
16652 shared.require_controller(connection_id, role)?;
16653 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16654 }
16655 NodeRequest::CommitDeliveryStage { stage_id } => {
16656 shared.require_controller(connection_id, role)?;
16657 let receipt = shared.commit_delivery_stage(stage_id)?;
16658 Ok(NodeResponse::DeliveryCommitted { receipt })
16659 }
16660 NodeRequest::AbortDeliveryStage { stage_id } => {
16661 shared.require_controller(connection_id, role)?;
16662 shared.abort_delivery_stage(&stage_id)?;
16663 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16664 }
16665 NodeRequest::BrowseHostDirectories { directory, after } => {
16666 let listing = shared.browse_host_directories(directory, after).await?;
16667 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16668 }
16669 NodeRequest::InspectWorkspace { workspace_id } => {
16670 let inspection = shared.inspect_workspace(workspace_id).await?;
16671 Ok(NodeResponse::WorkspaceInspected { inspection })
16672 }
16673 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16674 let file = shared.read_workspace_file(workspace_id, path).await?;
16675 Ok(NodeResponse::WorkspaceFileRead { file })
16676 }
16677 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16678 shared.require_controller(connection_id, role)?;
16679 let file = shared
16680 .write_workspace_file(workspace_id, path, expected_revision, text)
16681 .await?;
16682 Ok(NodeResponse::WorkspaceFileWritten { file })
16683 }
16684 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16685 shared.require_controller(connection_id, role)?;
16686 let file = shared.create_workspace_file(workspace_id, path).await?;
16687 Ok(NodeResponse::WorkspaceFileCreated { file })
16688 }
16689 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16690 shared.require_controller(connection_id, role)?;
16691 let entry = shared
16692 .create_workspace_directory(workspace_id.clone(), path)
16693 .await?;
16694 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16695 }
16696 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16697 if role != ClientRole::Operator {
16698 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16699 }
16700 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16701 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16702 }
16703 let page = shared
16704 .read_git_history(workspace_id.clone(), path, before, limit)
16705 .await?;
16706 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16707 }
16708 NodeRequest::ReadGitDiff { workspace_id, request } => {
16709 if role != ClientRole::Operator {
16710 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16711 }
16712 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16713 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16714 }
16715 NodeRequest::CatalogNativeSessions { route, limit } => {
16716 if role != ClientRole::Operator {
16717 return Err(failure(
16718 NodeFailureCode::ObserverReadOnly,
16719 "native session catalog requires an operator connection",
16720 ));
16721 }
16722 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16723 return Err(failure(
16724 NodeFailureCode::InvalidRequest,
16725 "native session catalog limit is invalid",
16726 ));
16727 }
16728 let (entries, summary) = shared
16729 .catalog_native_sessions(route.clone(), limit)
16730 .await?;
16731 Ok(NodeResponse::NativeSessionsCataloged {
16732 route,
16733 entries,
16734 summary: Some(summary),
16735 })
16736 }
16737 NodeRequest::PageNativeSessions {
16738 route,
16739 window,
16740 catalog_revision,
16741 recent_cutoff_unix_ms,
16742 after_selection_id,
16743 limit,
16744 } => {
16745 if role != ClientRole::Operator {
16746 return Err(failure(
16747 NodeFailureCode::ObserverReadOnly,
16748 "native session catalog paging requires an operator connection",
16749 ));
16750 }
16751 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16752 || after_selection_id
16753 .as_deref()
16754 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16755 {
16756 return Err(failure(
16757 NodeFailureCode::InvalidRequest,
16758 "native session catalog page request is invalid",
16759 ));
16760 }
16761 let page = shared
16762 .page_native_sessions(
16763 route.clone(),
16764 window,
16765 catalog_revision,
16766 recent_cutoff_unix_ms,
16767 after_selection_id,
16768 limit,
16769 )
16770 .await?;
16771 Ok(NodeResponse::NativeSessionsPaged {
16772 route,
16773 page,
16774 })
16775 }
16776 NodeRequest::PreviewNativeSession {
16777 selection,
16778 message_limit,
16779 } => {
16780 if role != ClientRole::Operator {
16781 return Err(failure(
16782 NodeFailureCode::ObserverReadOnly,
16783 "native session preview requires an operator connection",
16784 ));
16785 }
16786 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16787 .contains(&message_limit)
16788 || selection.validate().is_err()
16789 {
16790 return Err(failure(
16791 NodeFailureCode::InvalidRequest,
16792 "native session preview request is invalid",
16793 ));
16794 }
16795 let preview = shared
16796 .preview_native_session(selection.clone(), message_limit)
16797 .await?;
16798 Ok(NodeResponse::NativeSessionPreviewed {
16799 selection,
16800 preview,
16801 })
16802 }
16803 NodeRequest::IndexNativeSession {
16804 selection,
16805 display_name,
16806 } => {
16807 shared.require_controller(connection_id, role)?;
16808 let record = shared
16809 .index_native_session(selection.clone(), display_name)
16810 .await?;
16811 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16812 }
16813 NodeRequest::PreviewSessionRecord {
16814 record_id,
16815 message_limit,
16816 } => {
16817 if role != ClientRole::Operator {
16818 return Err(failure(
16819 NodeFailureCode::ObserverReadOnly,
16820 "native session preview requires an operator connection",
16821 ));
16822 }
16823 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16824 .contains(&message_limit)
16825 {
16826 return Err(failure(
16827 NodeFailureCode::InvalidRequest,
16828 "native session preview request is invalid",
16829 ));
16830 }
16831 let record = shared.record(&record_id)?;
16832 let working_directory = shared.workspace_root(&record.workspace_id)?;
16833 if !platform::roots_equal(
16834 &working_directory,
16835 windows_path_text(&record.canonical_root),
16836 ) {
16837 return Err(failure(
16838 NodeFailureCode::SessionRecordConflict,
16839 "managed session workspace root changed; refusing to preview another directory",
16840 ));
16841 }
16842 let identity = record.provider_session.clone().ok_or_else(|| {
16843 failure(
16844 NodeFailureCode::BackendOperationFailed,
16845 "native session preview is unavailable for this session record",
16846 )
16847 })?;
16848 if identity.key != ProviderSessionKey::SessionId {
16849 return Err(failure(
16850 NodeFailureCode::BackendOperationFailed,
16851 "native session preview is unavailable for this session identity",
16852 ));
16853 }
16854 let preview = shared
16855 .preview_native_session_by_session_id(
16856 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16857 )
16858 .await?;
16859 if preview.session_id != identity.id {
16860 return Err(failure(
16861 NodeFailureCode::SessionRecordConflict,
16862 "native session preview identity changed while loading",
16863 ));
16864 }
16865 shared.revalidate_session_record_preview(&record, &identity)?;
16866 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16867 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16868 record_id: record_id.clone(),
16869 summary: SessionHistorySummaryV1::from(&preview),
16870 });
16871 Ok(NodeResponse::SessionRecordPreviewed {
16872 record_id,
16873 preview,
16874 })
16875 }
16876 NodeRequest::AcquireController { lease_ms } => {
16877 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16878 Ok(NodeResponse::Controller { controller: Some(controller) })
16879 }
16880 NodeRequest::ReleaseController => {
16881 shared.release_controller(connection_id);
16882 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16883 }
16884 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16885 shared.require_controller(connection_id, role)?;
16886 let root = require_windows_path(root)?;
16887 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16888 let workspace = shared.register_workspace(workspace_id, root).await?;
16889 Ok(NodeResponse::WorkspaceRegistered { workspace })
16890 }
16891 NodeRequest::CreateStandaloneWorkspace {
16892 workspace_id,
16893 root,
16894 initial_branch,
16895 } => {
16896 shared.require_controller(connection_id, role)?;
16897 let root = require_windows_path(root)?;
16898 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16899 let workspace = shared
16900 .create_standalone_workspace(workspace_id, root, initial_branch)
16901 .await?;
16902 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16903 }
16904 NodeRequest::UnregisterWorkspace { workspace_id } => {
16905 shared.require_controller(connection_id, role)?;
16906 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16907 shared.unregister_workspace(&workspace_id)?;
16908 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16909 }
16910 NodeRequest::CreateWorktree {
16911 source_workspace_id,
16912 workspace_id,
16913 target_root,
16914 branch,
16915 base,
16916 } => {
16917 shared.require_controller(connection_id, role)?;
16918 let target_root = require_windows_path(target_root)?;
16919 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16920 let (worktree, workspace) = shared
16921 .create_worktree(
16922 source_workspace_id,
16923 workspace_id,
16924 target_root,
16925 branch,
16926 base,
16927 )
16928 .await?;
16929 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16930 }
16931 NodeRequest::RemoveWorktree {
16932 source_workspace_id,
16933 target_root,
16934 } => {
16935 shared.require_controller(connection_id, role)?;
16936 let native_target_root = require_windows_path(target_root.clone())?;
16937 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16938 let workspace_id = shared
16939 .remove_worktree(source_workspace_id, native_target_root)
16940 .await?;
16941 Ok(NodeResponse::WorktreeRemoved {
16942 target_root,
16943 workspace_id,
16944 })
16945 }
16946 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16947 shared.require_controller(connection_id, role)?;
16948 if !terminal_size.is_valid() {
16949 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16950 }
16951 if let Some(prompt) = initial_prompt.as_deref() {
16952 validate_node_text("spawn initial prompt", prompt)?;
16953 }
16954 match shared
16955 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16956 .await
16957 {
16958 Ok(session) => {
16959 shared.mark_spawn_provisional(session.clone());
16960 Ok(NodeResponse::SpawnAccepted { session })
16961 }
16962 Err(err) => {
16963 tracing::warn!(
16964 %provider,
16965 %workspace_id,
16966 ?mode,
16967 code = ?err.code,
16968 message = %err.message,
16969 "session spawn refused"
16970 );
16971 Err(err)
16972 }
16973 }
16974 }
16975 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16976 shared.require_controller(connection_id, role)?;
16977 if !terminal_size.is_valid() {
16978 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16979 }
16980 if let Some(prompt) = initial_prompt.as_deref() {
16981 validate_node_text("spawn initial prompt", prompt)?;
16982 }
16983 let plan = backend_paths::plan_launch_options(
16984 provider.as_str(),
16985 mode,
16986 options.model.is_some(),
16987 options.effort.is_some(),
16988 )
16989 .map_err(|r| {
16990 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16991 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16992 })?;
16993 let selection = spawn_selection(&options)?;
16994 let nick_model = selection.as_ref().map(|s| s.model.clone());
16995 let pty_selection = match plan {
16996 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16997 _ => None,
16998 };
16999 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17000 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17001 SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17002 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17003 .await
17004 }).await
17005 }).await;
17006 match spawned {
17007 Ok(session) => {
17008 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17009 shared
17010 .pending_acp_options
17011 .lock()
17012 .unwrap_or_else(|p| p.into_inner())
17013 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17014 }
17015 shared.mark_spawn_provisional(session.clone());
17016 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17017 Ok(NodeResponse::SpawnAccepted { session })
17018 }
17019 Err(err) => {
17020 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17021 Err(err)
17022 }
17023 }
17024 }
17025 NodeRequest::SpawnSpec { spec } => {
17026 shared.require_controller(connection_id, role)?;
17027 let receipt = shared.spawn_from_spec(spec).await?;
17028 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17029 }
17030 NodeRequest::SpawnManagedWorktree { request } => {
17031 shared.require_controller(connection_id, role)?;
17032 let receipt = shared.spawn_managed_worktree(request).await?;
17033 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17034 }
17035 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17036 shared.require_controller(connection_id, role)?;
17037 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17038 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17039 }
17040 NodeRequest::CleanupManagedWorktree { lease_id } => {
17041 shared.require_controller(connection_id, role)?;
17042 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17043 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17044 }
17045 NodeRequest::DiscoverHistory { session, limit } => {
17046 controlled_session(shared, connection_id, role, &session)?;
17047 let candidates = shared.discover_history(&session, limit).await?;
17048 Ok(NodeResponse::HistoryDiscovered {
17049 session,
17050 candidates,
17051 })
17052 }
17053 NodeRequest::LoadHistory {
17054 session,
17055 candidate_id,
17056 } => {
17057 controlled_session(shared, connection_id, role, &session)?;
17058 let loaded = shared
17059 .load_history(&session, candidate_id)
17060 .await?;
17061 Ok(NodeResponse::HistoryLoaded {
17062 session,
17063 session_id: loaded.session_id,
17064 message_count: loaded.message_count,
17065 completed_turn_count: loaded.completed_turn_count,
17066 })
17067 }
17068 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17069 shared.require_controller(connection_id, role)?;
17070 let context = shared
17071 .export_context_pack_for_session_record(&record_id, &session, false)
17072 .await?;
17073 Ok(NodeResponse::ContextPackForSessionRecordExported {
17074 record_id,
17075 session,
17076 context,
17077 })
17078 }
17079 NodeRequest::ExportContextPack { session } => {
17080 controlled_session(shared, connection_id, role, &session)?;
17081 let context = shared.export_context_pack(&session).await?;
17082 Ok(NodeResponse::ContextPackExported { context })
17083 }
17084 NodeRequest::ForgetContextPack { context_id } => {
17085 shared.require_controller(connection_id, role)?;
17086 shared.forget_context_pack(&context_id)?;
17087 Ok(NodeResponse::ContextPackForgotten { context_id })
17088 }
17089 NodeRequest::ResolveDurableContextPack { context_id } => {
17090 let context = shared
17094 .context_catalog
17095 .read()
17096 .unwrap_or_else(|poisoned| poisoned.into_inner())
17097 .get(&context_id)
17098 .map(|pack| pack.receipt().clone())
17099 .ok_or_else(|| {
17100 failure(
17101 NodeFailureCode::UnknownContextPack,
17102 "context pack is not present",
17103 )
17104 })?;
17105 Ok(NodeResponse::DurableContextPackResolved { context })
17106 }
17107 NodeRequest::ReadContextPack { digest } => {
17108 let (id, byte_len, bytes) = shared
17113 .context_catalog
17114 .read()
17115 .unwrap_or_else(|poisoned| poisoned.into_inner())
17116 .get_by_digest(&digest)
17117 .map(|pack| {
17118 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17119 })
17120 .ok_or_else(|| {
17121 failure(
17122 NodeFailureCode::UnknownContextPack,
17123 &format!("context pack {digest} not on this node"),
17124 )
17125 })?;
17126 tracing::debug!(
17127 digest = digest.as_str(),
17128 byte_len,
17129 "read context pack bytes",
17130 );
17131 Ok(NodeResponse::ContextPackBytesRead {
17132 pack: ContextPackBytesRead {
17133 digest,
17134 id,
17135 byte_len,
17136 bytes,
17137 },
17138 })
17139 }
17140 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17141 let provider = controlled_session(shared, connection_id, role, &session)?;
17142 if let Some(prompt) = initial_prompt.as_deref() {
17143 validate_node_text("resume initial prompt", prompt)?;
17144 }
17145 let runtime_requirement = if initial_prompt.is_some() {
17146 ProviderRuntimeRequirement::ResumeWithPrompt
17147 } else {
17148 ProviderRuntimeRequirement::Resume
17149 };
17150 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17151 let runtime_policy = shared
17152 .admit_provider_runtime(&provider, runtime_requirement)
17153 .await?;
17154 let working_directory = shared.bound_workspace_root(&session)?;
17155 let request = ResumeLaunchRequest {
17156 working_directory,
17157 terminal_size,
17158 initial_prompt,
17159 };
17160 request
17161 .validate()
17162 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17163 let command = shared.prepare_command(ControlCommand::Resume {
17164 instance_id: session.session.instance_id,
17165 target: ResumeTarget::CurrentProvider,
17166 runtime_policy,
17167 request,
17168 });
17169 shared.arm_resume(&session, command.id, runtime_policy)?;
17170 let dispatch = shared.dispatch_envelope(command);
17171 if let Err(error) = dispatch {
17172 shared.clear_armed_resume(&session);
17173 return Err(error);
17174 }
17175 Ok(NodeResponse::Accepted)
17176 }
17177 NodeRequest::RenameSessionRecord {
17178 record_id,
17179 display_name,
17180 } => {
17181 shared.require_controller(connection_id, role)?;
17182 let record = shared.rename_session_record(&record_id, display_name)?;
17183 Ok(NodeResponse::SessionRecordUpdated { record })
17184 }
17185 NodeRequest::SetSessionTask {
17186 record_id,
17187 expected_revision,
17188 target,
17189 } => {
17190 shared.require_controller(connection_id, role)?;
17191 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17192 Ok(NodeResponse::SessionRecordUpdated { record })
17193 }
17194 NodeRequest::IndexProviderSession {
17195 workspace_id,
17196 provider,
17197 identity,
17198 display_name,
17199 } => {
17200 shared.require_controller(connection_id, role)?;
17201 let record = shared.index_provider_session(
17202 workspace_id,
17203 provider,
17204 identity,
17205 display_name,
17206 )?;
17207 Ok(NodeResponse::ProviderSessionIndexed { record })
17208 }
17209 NodeRequest::ResumeSessionRecord {
17210 record_id,
17211 terminal_size,
17212 initial_prompt,
17213 } => {
17214 shared.require_controller(connection_id, role)?;
17215 if let Some(prompt) = initial_prompt.as_deref() {
17216 validate_node_text("managed resume initial prompt", prompt)?;
17217 }
17218 let (record, session) = shared
17219 .resume_session_record(
17220 &record_id,
17221 terminal_size,
17222 initial_prompt,
17223 )
17224 .await?;
17225 Ok(NodeResponse::SessionRecordResumed { record, session })
17226 }
17227 NodeRequest::ForgetSessionRecord { record_id } => {
17228 shared.require_controller(connection_id, role)?;
17229 shared.forget_session_record(&record_id).await?;
17230 Ok(NodeResponse::SessionRecordForgotten { record_id })
17231 }
17232 NodeRequest::Prompt { session, text } => {
17233 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17234 validate_node_text("prompt", &text)?;
17235 shared.require_session_runtime_policy(
17236 &session,
17237 ProviderRuntimeRequirement::SemanticPrompt,
17238 )?;
17239 let framing = prompt_framing(&agent_id);
17240 shared
17241 .dispatch_input_bounded(
17242 &session,
17243 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17244 )
17245 .await?;
17246 Ok(NodeResponse::Accepted)
17247 }
17248 NodeRequest::Paste { session, text } => {
17249 controlled_session(shared, connection_id, role, &session)?;
17250 validate_node_text("paste", &text)?;
17251 shared.require_session_runtime_policy(
17252 &session,
17253 ProviderRuntimeRequirement::SemanticPrompt,
17254 )?;
17255 shared
17256 .dispatch_input_bounded(
17257 &session,
17258 InputAction::InsertDraft(PromptPayload {
17259 text,
17260 framing: PromptFraming::BracketedPaste,
17261 }),
17262 )
17263 .await?;
17264 Ok(NodeResponse::Accepted)
17265 }
17266 NodeRequest::Input { session, text } => {
17267 controlled_session(shared, connection_id, role, &session)?;
17268 validate_node_text("terminal input", &text)?;
17269 shared
17270 .dispatch_input_bounded(
17271 &session,
17272 InputAction::TerminalText(TerminalText { text }),
17273 )
17274 .await?;
17275 Ok(NodeResponse::Accepted)
17276 }
17277 NodeRequest::TerminalBytes { session, bytes } => {
17278 controlled_session(shared, connection_id, role, &session)?;
17279 shared.sessionize_spawn(session.session.instance_id);
17280 let action = terminal_bytes_action(bytes)?;
17281 shared
17282 .dispatch_input_bounded(&session, action)
17283 .await?;
17284 Ok(NodeResponse::Accepted)
17285 }
17286 NodeRequest::TerminalControl { session, control } => {
17287 controlled_session(shared, connection_id, role, &session)?;
17288 shared
17289 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17290 .await?;
17291 Ok(NodeResponse::Accepted)
17292 }
17293 NodeRequest::Resize { session, size } => {
17294 controlled_session(shared, connection_id, role, &session)?;
17295 shared.sessionize_spawn(session.session.instance_id);
17296 if !size.is_valid() {
17297 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17298 }
17299 shared.dispatch_resize_bounded(&session, size).await?;
17300 Ok(NodeResponse::Accepted)
17301 }
17302 NodeRequest::Interrupt { session } => {
17303 controlled_session(shared, connection_id, role, &session)?;
17304 shared
17305 .dispatch_input_bounded(
17306 &session,
17307 InputAction::TerminalControl(TerminalControl::Interrupt),
17308 )
17309 .await?;
17310 Ok(NodeResponse::Accepted)
17311 }
17312 NodeRequest::Stop { session, force } => {
17313 controlled_session(shared, connection_id, role, &session)?;
17314 shared.sessionize_spawn(session.session.instance_id);
17315 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17316 shared.retire_session_grok_leader(session.session.instance_id);
17317 Ok(NodeResponse::Accepted)
17318 }
17319 NodeRequest::Remove { session } => {
17320 controlled_session(shared, connection_id, role, &session)?;
17321 shared.remove_session(&session).await?;
17322 Ok(NodeResponse::Accepted)
17323 }
17324 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17325 shared.require_controller(connection_id, role)?;
17326 let snapshot = shared.require_acp_session(&session)?;
17327 let interaction = snapshot
17328 .provider
17329 .interactions
17330 .iter()
17331 .find(|interaction| {
17332 interaction_correlation(
17333 session.session.instance_id,
17334 session.session.generation,
17335 interaction.id.0,
17336 ) == correlation_id
17337 })
17338 .ok_or_else(|| failure(
17339 NodeFailureCode::InvalidRequest,
17340 "unknown interaction correlation",
17341 ))?;
17342 if interaction.status != ProviderInteractionStatus::Pending {
17343 return Err(failure(
17344 NodeFailureCode::BackendOperationFailed,
17345 "interaction has already been resolved",
17346 ));
17347 }
17348 response
17349 .validate_for(interaction.interaction_kind)
17350 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17351 let interaction_id = interaction.id;
17352 let response_kind = response.kind();
17353 let response_outcome = response.outcome();
17354 shared.dispatch(ControlCommand::ResolveInteraction {
17355 instance_id: session.session.instance_id,
17356 generation: session.session.generation,
17357 interaction_id,
17358 response,
17359 })?;
17360 tracing::info!(
17366 node_id = %shared.node_id,
17367 session = ?session.session,
17368 correlation_id = %correlation_id,
17369 interaction_id = interaction_id.0,
17370 response_kind = ?response_kind,
17371 outcome = ?response_outcome,
17372 "interaction resolved",
17373 );
17374 Ok(NodeResponse::Accepted)
17375 }
17376 NodeRequest::SetSessionMode { session, mode_id } => {
17377 shared.require_controller(connection_id, role)?;
17378 shared.require_acp_session(&session)?;
17379 shared.dispatch(ControlCommand::SetSessionMode {
17380 instance_id: session.session.instance_id,
17381 mode_id,
17382 })?;
17383 Ok(NodeResponse::Accepted)
17384 }
17385 NodeRequest::SetSessionConfigOption {
17386 session,
17387 option_id,
17388 value_json,
17389 } => {
17390 shared.require_controller(connection_id, role)?;
17391 shared.require_acp_session(&session)?;
17392 shared.dispatch(ControlCommand::SetSessionConfigOption {
17393 instance_id: session.session.instance_id,
17394 option_id,
17395 value_json,
17396 })?;
17397 Ok(NodeResponse::Accepted)
17398 }
17399 NodeRequest::SetSessionModel { session, model_id } => {
17400 shared.require_controller(connection_id, role)?;
17401 shared.require_acp_session(&session)?;
17402 shared.dispatch(ControlCommand::SetSessionModel {
17403 instance_id: session.session.instance_id,
17404 model_id,
17405 })?;
17406 Ok(NodeResponse::Accepted)
17407 }
17408 NodeRequest::Shutdown => {
17409 shared.require_controller(connection_id, role)?;
17410 shared.begin_shutdown_locked().await?;
17411 Ok(NodeResponse::ShuttingDown)
17412 }
17413 }
17414}
17415
17416fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17417 shared.require_controller(connection_id, role)?;
17418 shared.validate_address(session)
17419}
17420
17421fn select_history_candidate(
17427 candidates: &[HistoryCandidateSummary],
17428 provider_session_id: Option<&str>,
17429) -> Result<String, NodeFailure> {
17430 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17431 Some(id) => candidate.session_id_hint == id,
17432 None => true,
17433 });
17434 let candidate = matches.next().ok_or_else(|| {
17435 failure(
17436 NodeFailureCode::SessionRecordConflict,
17437 if provider_session_id.is_some() {
17438 "no history candidate matches the managed provider session"
17439 } else {
17440 "no history candidate exists for the managed session"
17441 },
17442 )
17443 })?;
17444 if matches.next().is_some() {
17445 return Err(failure(
17446 NodeFailureCode::SessionRecordConflict,
17447 if provider_session_id.is_some() {
17448 "history identity is ambiguous for the managed provider session"
17449 } else {
17450 "history is ambiguous without a verified provider session identity"
17451 },
17452 ));
17453 }
17454 Ok(candidate.id.clone())
17455}
17456
17457fn resolved_spawn_profile_summary(
17458 profile: &SpawnProfileDefaults,
17459 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17460) -> Option<SpawnProfileSummary> {
17461 let environment_profile = match profile.environment_profile_id.as_ref() {
17462 None => None,
17463 Some(profile_id) => {
17464 let binding = environment_profiles.get(profile_id)?;
17465 if binding.provider != profile.provider
17466 || binding.native_profile_id(profile.mode).is_none()
17467 {
17468 return None;
17469 }
17470 Some(ResolvedEnvironmentProfileReceipt {
17471 profile_id: binding.id.clone(),
17472 profile_revision: binding.revision.clone(),
17473 network_allowlist: None,
17474 browser_profile_id: None,
17475 })
17476 }
17477 };
17478 Some(SpawnProfileSummary {
17479 id: profile.profile_id.clone(),
17480 revision: profile.revision.clone(),
17481 environment_profile,
17482 })
17483}
17484
17485fn session_record_export_target_matches(
17504 expected: &ManagedSessionRecord,
17505 current: &ManagedSessionRecord,
17506 session: &SessionAddress,
17507 allow_clean_detachment: bool,
17508) -> bool {
17509 if current == expected && current.active_session.as_ref() == Some(session) {
17510 return current.state == ManagedSessionState::Live;
17511 }
17512 allow_clean_detachment
17513 && current.state == ManagedSessionState::Dormant
17514 && current.active_session.is_none()
17515 && current.record_id == expected.record_id
17516 && current.display_name == expected.display_name
17517 && current.provider == expected.provider
17518 && current.mode == expected.mode
17519 && current.workspace_id == expected.workspace_id
17520 && current.canonical_root == expected.canonical_root
17521 && current.provider_session == expected.provider_session
17522 && current.environment_profile == expected.environment_profile
17523 && current.bundle == expected.bundle
17524 && current.context_id == expected.context_id
17525 && current.context == expected.context
17526 && current.exported_context == expected.exported_context
17527 && current.task_binding == expected.task_binding
17528 && current.created_at_unix_ms == expected.created_at_unix_ms
17529 && current.last_error == expected.last_error
17530}
17531
17532fn session_record_context_export_binding_is_exact(
17533 expected: &ManagedSessionRecord,
17534 current: &ManagedSessionRecord,
17535 identity: Option<&ProviderSessionIdentity>,
17536 session: &SessionAddress,
17537 binding: &SessionBinding,
17538 provider: &AgentId,
17539 current_root: &str,
17540 allow_clean_detachment: bool,
17541) -> bool {
17542 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17543 && current.provider_session.as_ref() == identity
17544 && current.workspace_id == session.workspace_id
17545 && provider == ¤t.provider
17546 && binding.workspace_id == session.workspace_id
17547 && binding.generation == session.session.generation
17548 && binding.record_id.as_ref() == Some(¤t.record_id)
17549 && platform::roots_equal(
17550 current_root,
17551 windows_path_text(¤t.canonical_root),
17552 )
17553}
17554
17555fn session_record_context_export_source_is_usable(
17556 record: &ManagedSessionRecord,
17557 status: &SessionStatus,
17558 allow_clean_detachment: bool,
17559) -> bool {
17560 (record.state == ManagedSessionState::Live
17561 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17562 && context_pack_source_status_is_usable(status)
17563}
17564
17565fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17566 PromptFraming::BracketedPaste
17567}
17568
17569fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17570 if text.len() > MAX_NODE_TEXT_BYTES {
17571 return Err(failure(
17572 NodeFailureCode::InvalidRequest,
17573 &format!(
17574 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17575 text.len(),
17576 ),
17577 ));
17578 }
17579 Ok(())
17580}
17581
17582fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17583 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17584 return Err(failure(
17585 NodeFailureCode::InvalidRequest,
17586 &format!(
17587 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17588 bytes.len(),
17589 ),
17590 ));
17591 }
17592 Ok(())
17593}
17594
17595fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17596 validate_terminal_bytes(&bytes)?;
17597 Ok(InputAction::TerminalBytes(bytes))
17598}
17599
17600fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17601 if root.is_empty()
17602 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17603 || root.chars().any(char::is_control)
17604 {
17605 return Err(failure(
17606 NodeFailureCode::InvalidWorkspaceRoot,
17607 &format!(
17608 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17609 ),
17610 ));
17611 }
17612 Ok(())
17613}
17614
17615fn canonical_standalone_workspace_candidate(
17616 workspace_id: &WorkspaceId,
17617 root: &str,
17618) -> Result<String, NodeServerError> {
17619 let path = Path::new(root);
17620 match std::fs::symlink_metadata(path) {
17621 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17622 .map(|workspace| workspace.canonical_root),
17623 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17624 if !path.is_absolute() {
17625 return Err(NodeServerError::InvalidWorkspaceRoot {
17626 workspace_id: workspace_id.clone(),
17627 path: root.to_owned(),
17628 message: "path is not absolute".to_owned(),
17629 });
17630 }
17631 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17632 workspace_id: workspace_id.clone(),
17633 path: root.to_owned(),
17634 message: "path has no standalone directory name".to_owned(),
17635 })?;
17636 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17637 workspace_id: workspace_id.clone(),
17638 path: root.to_owned(),
17639 message: "path has no parent directory".to_owned(),
17640 })?;
17641 let canonical = std::fs::canonicalize(parent)
17642 .map(|parent| parent.join(name))
17643 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17644 workspace_id: workspace_id.clone(),
17645 path: root.to_owned(),
17646 message: error.to_string(),
17647 })?;
17648 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17649 NodeServerError::InvalidWorkspaceRoot {
17650 workspace_id: workspace_id.clone(),
17651 path: path.to_string_lossy().into_owned(),
17652 message: "canonical path is not valid Unicode".to_owned(),
17653 }
17654 })?;
17655 let canonical_root = platform::normalize_canonical_root(canonical_root);
17656 validate_workspace_root(workspace_id, &canonical_root)?;
17657 Ok(canonical_root)
17658 }
17659 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17660 workspace_id: workspace_id.clone(),
17661 path: root.to_owned(),
17662 message: error.to_string(),
17663 }),
17664 }
17665}
17666
17667fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17668 let Some(revision) = revision else {
17669 return Ok(());
17670 };
17671 if revision.is_empty()
17672 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17673 || revision.chars().any(char::is_control)
17674 {
17675 return Err(failure(
17676 NodeFailureCode::InvalidRequest,
17677 &format!(
17678 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17679 ),
17680 ));
17681 }
17682 Ok(())
17683}
17684
17685fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17686 let code = match error.kind {
17687 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17688 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17689 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17690 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17691 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17692 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17693 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17694 };
17695 failure(code, &error.message)
17696}
17697
17698fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17699 let code = match error.kind {
17700 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17701 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17702 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17703 NodeFailureCode::BackendOperationFailed
17704 }
17705 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17706 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17707 }
17708 };
17709 failure(code, error.message)
17710}
17711
17712fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17713 let (code, message) = match error.kind {
17714 GitWorktreeErrorKind::Invalid => (
17715 NodeFailureCode::InvalidRequest,
17716 "managed Git input is invalid",
17717 ),
17718 GitWorktreeErrorKind::NotRepository => (
17719 NodeFailureCode::NotGitRepository,
17720 "managed source is not a Git repository",
17721 ),
17722 GitWorktreeErrorKind::Conflict => (
17723 NodeFailureCode::WorktreeConflict,
17724 "managed Git identity conflicts with existing repository state",
17725 ),
17726 GitWorktreeErrorKind::Protected => (
17727 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17728 "managed worktree ownership validation failed",
17729 ),
17730 GitWorktreeErrorKind::Dirty => (
17731 NodeFailureCode::WorktreeDirty,
17732 "managed worktree contains uncommitted changes",
17733 ),
17734 GitWorktreeErrorKind::Locked => (
17735 NodeFailureCode::WorktreeLocked,
17736 "managed worktree is locked",
17737 ),
17738 GitWorktreeErrorKind::Failed => (
17739 NodeFailureCode::BackendOperationFailed,
17740 "managed Git operation failed",
17741 ),
17742 };
17743 NodeFailure { code, message: message.to_owned() }
17744}
17745
17746fn managed_spawn_receipt(
17747 resolved: &ResolvedSpawnSpec,
17748 incarnation_id: NodeIncarnationId,
17749 session: SessionAddress,
17750 lease: ManagedWorktreeLeaseSnapshot,
17751 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17752 bundle: Option<ResolvedBundleReceipt>,
17753 context: Option<ResolvedContextPackReceipt>,
17754) -> ManagedWorktreeSpawnReceipt {
17755 let mut managed = resolved.clone();
17756 managed.target.worktree_id = Some(lease.workspace_id.clone());
17757 ManagedWorktreeSpawnReceipt {
17758 spawn: managed.receipt_with_materialization(
17759 incarnation_id,
17760 session,
17761 environment_profile,
17762 bundle,
17763 context,
17764 ),
17765 lease,
17766 }
17767}
17768
17769fn validate_selected_worktree(
17770 source_root: &str,
17771 selected_root: &str,
17772 worktrees: &[NativeGitWorktreeSnapshot],
17773) -> Result<(), NodeFailure> {
17774 if !worktrees
17775 .iter()
17776 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17777 {
17778 return Err(failure(
17779 NodeFailureCode::WorktreeProtected,
17780 "source workspace is not an authoritative non-bare Git worktree root",
17781 ));
17782 }
17783 if !worktrees.iter().any(|worktree| {
17784 worktree_paths_equal(&worktree.path, selected_root)
17785 && !worktree.is_main
17786 && !worktree.is_bare
17787 }) {
17788 return Err(failure(
17789 NodeFailureCode::WorktreeProtected,
17790 "selected workspace is not an eligible Git worktree in the source repository",
17791 ));
17792 }
17793 Ok(())
17794}
17795
17796fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17797 let code = match error.kind() {
17798 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17799 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17800 NodeFailureCode::RepositoryPathUnsafe
17801 }
17802 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17803 WorkspaceFileReadErrorKind::NotRegularFile => {
17804 NodeFailureCode::RepositoryFileNotRegular
17805 }
17806 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17807 NodeFailureCode::RepositoryFileReadFailed
17808 }
17809 WorkspaceFileReadErrorKind::RevisionConflict => {
17810 NodeFailureCode::RepositoryFileRevisionConflict
17811 }
17812 WorkspaceFileReadErrorKind::AlreadyExists
17813 | WorkspaceFileReadErrorKind::ParentNotFound
17814 | WorkspaceFileReadErrorKind::ParentNotDirectory
17815 | WorkspaceFileReadErrorKind::Canceled => {
17816 NodeFailureCode::RepositoryFileReadFailed
17817 }
17818 };
17819 failure(code, "repository file read failed")
17820}
17821
17822fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17823 let code = match error.kind() {
17824 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17825 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17826 NodeFailureCode::RepositoryPathUnsafe
17827 }
17828 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17829 WorkspaceFileReadErrorKind::NotRegularFile => {
17830 NodeFailureCode::RepositoryFileNotRegular
17831 }
17832 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17833 NodeFailureCode::RepositoryFileWriteFailed
17834 }
17835 WorkspaceFileReadErrorKind::RevisionConflict => {
17836 NodeFailureCode::RepositoryFileRevisionConflict
17837 }
17838 WorkspaceFileReadErrorKind::AlreadyExists
17839 | WorkspaceFileReadErrorKind::ParentNotFound
17840 | WorkspaceFileReadErrorKind::ParentNotDirectory
17841 | WorkspaceFileReadErrorKind::Canceled => {
17842 NodeFailureCode::RepositoryFileWriteFailed
17843 }
17844 };
17845 failure(code, "repository file write failed")
17846}
17847
17848async fn settle_workspace_entry_create<T>(
17849 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17850 commit_state: Arc<AtomicU8>,
17851 deadline: Duration,
17852 timeout_message: &'static str,
17853 task_failure_message: &'static str,
17854) -> Result<T, NodeFailure> {
17855 let joined = match timeout(deadline, &mut task).await {
17856 Ok(joined) => joined,
17857 Err(_) => {
17858 if commit_state
17859 .compare_exchange(
17860 WORKSPACE_ENTRY_CREATE_PENDING,
17861 WORKSPACE_ENTRY_CREATE_CANCELED,
17862 Ordering::AcqRel,
17863 Ordering::Acquire,
17864 )
17865 .is_ok()
17866 {
17867 return Err(failure(
17868 NodeFailureCode::RepositoryEntryCreateTimedOut,
17869 timeout_message,
17870 ));
17871 }
17872 debug_assert_eq!(
17873 commit_state.load(Ordering::Acquire),
17874 WORKSPACE_ENTRY_CREATE_COMMITTING,
17875 );
17876 task.await
17877 }
17878 };
17879 joined
17880 .map_err(|_| {
17881 failure(
17882 NodeFailureCode::RepositoryEntryCreateFailed,
17883 task_failure_message,
17884 )
17885 })?
17886 .map_err(workspace_entry_create_failure)
17887}
17888
17889fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17890 let code = match error.kind() {
17891 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17892 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17893 NodeFailureCode::RepositoryPathUnsafe
17894 }
17895 WorkspaceFileReadErrorKind::AlreadyExists => {
17896 NodeFailureCode::RepositoryEntryAlreadyExists
17897 }
17898 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17899 NodeFailureCode::RepositoryParentNotFound
17900 }
17901 WorkspaceFileReadErrorKind::ParentNotDirectory
17902 | WorkspaceFileReadErrorKind::NotRegularFile => {
17903 NodeFailureCode::RepositoryParentNotDirectory
17904 }
17905 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17906 NodeFailureCode::RepositoryEntryCreateFailed
17907 }
17908 WorkspaceFileReadErrorKind::RevisionConflict => {
17909 NodeFailureCode::RepositoryEntryCreateFailed
17910 }
17911 WorkspaceFileReadErrorKind::Canceled => {
17912 NodeFailureCode::RepositoryEntryCreateTimedOut
17913 }
17914 };
17915 failure(code, "repository entry create failed")
17916}
17917
17918fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17919 let value = digest(&SHA256, bytes)
17920 .as_ref()
17921 .iter()
17922 .map(|byte| format!("{byte:02x}"))
17923 .collect();
17924 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17925}
17926
17927fn managed_spawn_request_digest_v2(
17928 request: &ManagedWorktreeSpawnRequestV2,
17929) -> Result<String, NodeFailure> {
17930 let encoded = serde_json::to_vec(request).map_err(|_| {
17931 failure(
17932 NodeFailureCode::InvalidRequest,
17933 "managed worktree V2 request could not be canonicalized",
17934 )
17935 })?;
17936 let mut value = String::with_capacity(71);
17937 value.push_str("sha256:");
17938 for byte in digest(&SHA256, &encoded).as_ref() {
17939 use std::fmt::Write as _;
17940 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17941 }
17942 Ok(value)
17943}
17944
17945fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17946 let code = match error {
17947 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17948 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17949 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17950 NativeSessionPreviewError::UnsupportedProvider
17951 | NativeSessionPreviewError::PreviewUnavailable
17952 | NativeSessionPreviewError::SessionNotFound
17953 | NativeSessionPreviewError::AmbiguousSession => {
17954 NodeFailureCode::BackendOperationFailed
17955 }
17956 };
17957 failure(code, "native session preview failed")
17958}
17959
17960fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17961 let code = match error.kind() {
17962 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17963 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17964 };
17965 failure(code, "host directory browse failed")
17966}
17967
17968fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17969 let code = match error {
17970 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17971 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17972 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17973 NodeFailureCode::DeliveryStageConflict
17974 }
17975 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17976 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17977 NodeFailureCode::DeliveryChunkOutOfOrder
17978 }
17979 DeliveryStoreError::BlobDigestMismatch => {
17980 NodeFailureCode::DeliveryBlobDigestMismatch
17981 }
17982 DeliveryStoreError::BundleDigestMismatch => {
17983 NodeFailureCode::DeliveryBundleDigestMismatch
17984 }
17985 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17986 DeliveryStoreError::Unavailable
17987 | DeliveryStoreError::Corrupt
17988 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17989 };
17990 failure(code, "delivery operation failed")
17991}
17992
17993fn validate_workspace_root(
17994 workspace_id: &WorkspaceId,
17995 canonical_root: &str,
17996) -> Result<(), NodeServerError> {
17997 #[cfg(windows)]
17998 if canonical_root.starts_with(r"\\") {
17999 return Err(NodeServerError::InvalidWorkspaceRoot {
18000 workspace_id: workspace_id.clone(),
18001 path: canonical_root.to_owned(),
18002 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18003 });
18004 }
18005 if !platform::workspace_root_supported(canonical_root)
18006 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18007 {
18008 return Err(NodeServerError::InvalidWorkspaceRoot {
18009 workspace_id: workspace_id.clone(),
18010 path: canonical_root.to_owned(),
18011 message: "canonical path must be absolute, bounded, and free of control characters"
18012 .to_owned(),
18013 });
18014 }
18015 Ok(())
18016}
18017
18018fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18019 NodeFailure {
18020 code,
18021 message: node_failure_category(code).to_owned(),
18022 }
18023}
18024
18025fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18026 let code = match error {
18027 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18028 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18029 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18030 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18031 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18032 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18033 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18034 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18035 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18036 };
18037 failure(code, "harness MCP proxy operation failed")
18038}
18039
18040static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18044
18045fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18052 let trace = launch.trace.as_ref()?;
18053 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18054 if trace_dir.is_empty() {
18055 return None;
18056 }
18057 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18058 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18059 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18060}
18061
18062fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18063 let remaining = deadline.saturating_duration_since(Instant::now());
18064 if remaining.is_zero() {
18065 Err(failure(
18066 NodeFailureCode::SpawnDeadlineExceeded,
18067 "spawn deadline elapsed",
18068 ))
18069 } else {
18070 Ok(remaining)
18071 }
18072}
18073
18074fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18075 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18076 match deadline {
18077 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18078 None => Ok(bounded),
18079 }
18080}
18081
18082fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18083 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18084 failure(
18085 NodeFailureCode::SpawnDeadlineExceeded,
18086 "spawn dispatch exceeded the spawn deadline",
18087 )
18088 } else {
18089 error
18090 }
18091}
18092
18093fn spawn_runtime_capabilities(
18094 capabilities: &SpawnRequiredCapabilities,
18095) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18096 capabilities
18097 .iter()
18098 .map(|capability| match capability.as_str() {
18099 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18100 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18101 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18102 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18103 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18104 }
18105 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18106 _ => Err(failure(
18107 NodeFailureCode::UnsupportedSpawnCapability,
18108 "spawn required capability is unknown",
18109 )),
18110 })
18111 .collect()
18112}
18113
18114fn persistence_failure(_error: io::Error) -> NodeFailure {
18115 NodeFailure {
18116 code: NodeFailureCode::BackendOperationFailed,
18117 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18118 }
18119}
18120
18121fn node_failure_category(code: NodeFailureCode) -> &'static str {
18122 match code {
18123 NodeFailureCode::InvalidRequest => "invalid-request",
18124 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18125 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18126 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18127 NodeFailureCode::ReservationConflict => "reservation-conflict",
18128 NodeFailureCode::ReservationExpired => "reservation-expired",
18129 NodeFailureCode::BindingMismatch => "binding-mismatch",
18130 NodeFailureCode::NotActivated => "not-activated",
18131 NodeFailureCode::CallNotFound => "call-not-found",
18132 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18133 NodeFailureCode::ResponseTooLarge => "response-too-large",
18134 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18135 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18136 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18137 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18138 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18139 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18140 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18141 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18142 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18143 NodeFailureCode::Unauthorized => "unauthorized",
18144 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18145 NodeFailureCode::ControllerBusy => "controller-busy",
18146 NodeFailureCode::ControllerRequired => "controller-required",
18147 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18148 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18149 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18150 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18151 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18152 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18153 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18154 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18155 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18156 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18157 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18158 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18159 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18160 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18161 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18162 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18163 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18164 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18165 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18166 NodeFailureCode::GitReadFailed => "git-read-failed",
18167 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18168 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18169 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18170 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18171 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18172 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18173 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18174 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18175 NodeFailureCode::TurnInFlight => "turn-in-flight",
18176 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18177 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18178 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18179 "unsupported-network-allowlist-mapping"
18180 }
18181 NodeFailureCode::BrowserStationProbeUnavailable => {
18182 "browser-station-probe-unavailable"
18183 }
18184 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18185 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18186 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18187 "environment-profile-binding-mismatch"
18188 }
18189 NodeFailureCode::UnknownBundle => "unknown-bundle",
18190 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18191 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18192 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18193 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18194 NodeFailureCode::ContextPackMaterializationFailed => {
18195 "context-pack-materialization-failed"
18196 }
18197 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18198 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18199 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18200 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18201 NodeFailureCode::LastWorkspace => "last-workspace",
18202 NodeFailureCode::NotGitRepository => "not-git-repository",
18203 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18204 NodeFailureCode::WorktreeProtected => "worktree-protected",
18205 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18206 NodeFailureCode::WorktreeLocked => "worktree-locked",
18207 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18208 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18209 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18210 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18211 "managed-worktree-profile-revision-mismatch"
18212 }
18213 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18214 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18215 "standalone-workspace-recovery-required"
18216 }
18217 NodeFailureCode::UnknownSession => "unknown-session",
18218 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18219 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18220 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18221 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18222 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18223 NodeFailureCode::WorkspaceRegistrationRequired => {
18224 "workspace-registration-required"
18225 }
18226 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18227 NodeFailureCode::StaleGeneration => "stale-generation",
18228 NodeFailureCode::BackendBusy => "backend-busy",
18229 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18230 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18231 NodeFailureCode::ShuttingDown => "shutting-down",
18232 }
18233}
18234
18235fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18236 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18237}
18238
18239fn durable_state_load_error(error: io::Error) -> NodeServerError {
18240 let category = match session_registry::state_load_refusal(&error) {
18241 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18242 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18243 }
18244 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18245 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18246 }
18247 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18248 DURABLE_STATE_CONFLICT_ERROR
18249 }
18250 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18251 };
18252 durable_state_server_error(error, category)
18253}
18254
18255fn unix_time_ms() -> u64 {
18256 std::time::SystemTime::now()
18257 .duration_since(std::time::UNIX_EPOCH)
18258 .unwrap_or_default()
18259 .as_millis()
18260 .min(u64::MAX as u128) as u64
18261}
18262
18263fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18264 if record.provider_session.is_some() {
18265 ManagedSessionState::Dormant
18266 } else {
18267 ManagedSessionState::Unavailable
18268 }
18269}
18270
18271fn active_record_state(
18272 identity_admitted: bool,
18273 provider_session_is_known: bool,
18274) -> ManagedSessionState {
18275 if identity_admitted && !provider_session_is_known {
18276 ManagedSessionState::IdentityPending
18277 } else {
18278 ManagedSessionState::Live
18279 }
18280}
18281
18282fn managed_worktree_record_holders(
18283 records: &[ManagedSessionRecord],
18284) -> Vec<(SessionRecordId, WorkspaceId)> {
18285 records
18286 .iter()
18287 .filter(|record| record.provider_session.is_some())
18288 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18289 .collect()
18290}
18291
18292fn managed_spawn_record_ownership(
18293 binding: &SessionBinding,
18294 effective_runtime_policy: ProviderRuntimePolicy,
18295) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18296 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18297 if effective_runtime_policy.provider_session_identity {
18298 (binding.record_id.clone(), None)
18299 } else {
18300 (None, binding.record_id.clone())
18301 }
18302}
18303
18304#[derive(Debug, Error)]
18305pub enum NodeServerError {
18306 #[error("node endpoint must be a bounded absolute local transport path")]
18307 InvalidEndpoint,
18308 #[error("node access token must contain 1..=4096 bytes")]
18309 InvalidAccessToken,
18310 #[error("node HTTP observer must listen on a loopback address: {0}")]
18311 InvalidApiListen(std::net::SocketAddr),
18312 #[error("node browser bridge must listen on a loopback address: {0}")]
18313 InvalidBridgeListen(std::net::SocketAddr),
18314 #[error("node browser bridge token must contain 1..=4096 bytes")]
18315 InvalidBridgeToken,
18316 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18317 BridgeUnderlayRequiresBridgeListen,
18318 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18319 BridgeUnderlayRequiresBridgeToken,
18320 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18321 InvalidBridgeUnderlayToken,
18322 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18323 InvalidBridgeUnderlayKey,
18324 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18325 InvalidCallHome(std::net::SocketAddr),
18326 #[error("node could not reach its call-home relay: {0}")]
18327 CallHomeTransport(#[source] std::io::Error),
18328 #[error("node durable state path must be an absolute file path: {0}")]
18329 InvalidStatePath(String),
18330 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18331 InvalidHarnessMcpHelper,
18332 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18333 InvalidNetworkAllowlistCatalogPath(String),
18334 #[error("network allowlist catalog could not be read: {0}")]
18335 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18336 #[error("network allowlist catalog line {line}: {message}")]
18337 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18338 #[error("network allowlist catalog: {0}")]
18339 InvalidNetworkAllowlistCatalog(String),
18340 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18341 NetworkAllowlistCatalogCapacity { max: usize },
18342 #[error("the local state directory is unavailable; supply an explicit state path")]
18343 LocalStateDirectoryUnavailable,
18344 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18345 LocalRuntimeDirectoryUnavailable,
18346 #[error("node durable state failed: {0}")]
18347 DurableState(io::Error),
18348 #[error("node requires at least one configured workspace")]
18349 NoWorkspaces,
18350 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18351 InvalidWorkspaceRoot {
18352 workspace_id: WorkspaceId,
18353 path: String,
18354 message: String,
18355 },
18356 #[error("duplicate workspace ID: {0}")]
18357 DuplicateWorkspaceId(WorkspaceId),
18358 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18359 DuplicateWorkspaceRoot {
18360 first: WorkspaceId,
18361 second: WorkspaceId,
18362 root: String,
18363 },
18364 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18365 ManagedWorktreeProfileRequired(WorkspaceId),
18366 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18367 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18368 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18369 DuplicateManagedWorktreeProfile {
18370 workspace_id: WorkspaceId,
18371 profile_id: WorktreeProfileId,
18372 },
18373 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18374 ManagedWorktreeProfileCapacity {
18375 workspace_id: WorkspaceId,
18376 max: usize,
18377 },
18378 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18379 InvalidManagedWorktreeProfile {
18380 workspace_id: WorkspaceId,
18381 profile_id: WorktreeProfileId,
18382 message: String,
18383 },
18384 #[error("duplicate node environment profile: {0}")]
18385 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18386 #[error("node environment profile provider is unavailable: {0}")]
18387 EnvironmentProfileProviderUnavailable(AgentId),
18388 #[error("node environment profile capacity is {max}")]
18389 EnvironmentProfileCapacity { max: usize },
18390 #[error("node environment profiles reuse native profile ID '{0}'")]
18391 DuplicateNativeEnvironmentProfile(String),
18392 #[error("node native environment profile is invalid: {0}")]
18393 NativeEnvironmentProfile(String),
18394 #[error("node session-environment materialization root must be absolute")]
18395 InvalidSessionEnvironmentRoot,
18396 #[error("node session-environment materializer is unavailable")]
18397 SessionEnvironmentMaterializerRequired,
18398 #[error("node session-environment materializer failed to initialize")]
18399 SessionEnvironmentMaterializer,
18400 #[error("node bundle catalog is invalid: {0}")]
18401 BundleCatalog(String),
18402 #[error("node delivery store failed to initialize: {0}")]
18403 DeliveryStore(String),
18404 #[error("node context pack store failed to initialize")]
18405 ContextPackStore,
18406 #[error("active agent registry failed: {0}")]
18407 Registry(String),
18408 #[error("node provider contract manifest is invalid: {0}")]
18409 ProviderContractManifest(String),
18410 #[error("named pipe I/O failed: {0}")]
18411 Io(#[from] io::Error),
18412 #[error("node HTTP observer failed: {0}")]
18413 HttpApi(io::Error),
18414 #[error("node browser bridge failed: {0}")]
18415 BridgeApi(io::Error),
18416 #[error(transparent)]
18417 Frame(#[from] FrameError),
18418 #[error("node handshake failed: {0}")]
18419 Handshake(String),
18420 #[error("node authentication frame was not received before the bounded deadline")]
18421 AuthenticationTimedOut,
18422 #[error("node authentication primitive failed: {0}")]
18423 Authentication(String),
18424 #[error("node incarnation identity generation failed: {0}")]
18425 IncarnationIdentity(String),
18426 #[error("node pre-authentication limiter is closed")]
18427 PreauthClosed,
18428 #[error("node authenticated connection limit was reached")]
18429 AuthenticatedConnectionLimit,
18430 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18431 SignalStreamClosed,
18432 #[error("node stop-all dispatch failed: {0:?}")]
18433 ShutdownDispatch(NodeFailure),
18434 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18435 ShutdownTimedOut { active_native_sessions: usize },
18436}
18437
18438impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18439 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18440 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18441 match error {
18442 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18443 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18444 E::InvalidEntry { line, message } => {
18445 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18446 }
18447 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18448 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18449 }
18450 }
18451}
18452
18453#[cfg(test)]
18454mod agent_stream_blocked_tests {
18455 use super::*;
18456 use gate4agent_types::{
18457 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18458 ProviderSource,
18459 };
18460
18461 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18462 ProviderSource {
18463 family,
18464 binding: AdapterBinding::new(
18465 AdapterId::new(adapter).unwrap(),
18466 "fixture/v1",
18467 AdapterVerification::SyntheticFixture,
18468 )
18469 .unwrap(),
18470 }
18471 }
18472
18473 fn provider_control_event_at(
18474 family: AdapterFamily,
18475 adapter: &str,
18476 source_sequence: u64,
18477 provider_event: ProviderEvent,
18478 ) -> ControlEvent {
18479 ControlEvent {
18480 sequence: 41,
18481 command_id: None,
18482 instance_id: AgentInstanceId(7),
18483 generation: SessionGeneration(3),
18484 event: ControlEventKind::ProviderEvent {
18485 sequence: 8,
18486 source: provider_source(family, adapter),
18487 source_sequence,
18488 event: provider_event,
18489 },
18490 }
18491 }
18492
18493 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18494 provider_control_event_at(family, "fixture", 9, provider_event)
18495 }
18496
18497 fn denied_host_request(
18498 by: ProviderHostDecisionAuthority,
18499 reason: Option<String>,
18500 ) -> ControlEvent {
18501 provider_control_event_at(
18502 AdapterFamily::Acp,
18503 "claude-code",
18504 2,
18505 ProviderEvent::HostRequestObserved {
18506 method: "terminal/create".to_owned(),
18507 params_json: String::new(),
18508 decision: ProviderHostRequestDecision::Denied { by },
18509 outcome: ProviderHostRequestOutcome::Executed,
18510 reason,
18511 },
18512 )
18513 }
18514
18515 fn blocked(
18516 event: &ControlEvent,
18517 ) -> (
18518 Option<String>,
18519 String,
18520 BlockAuthorityV1,
18521 Option<String>,
18522 String,
18523 Option<String>,
18524 ) {
18525 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18526 chunk.validate().expect("minted chunk must validate");
18527 let AgentStreamChunkKindV1::Blocked {
18528 correlation_id,
18529 tool_class,
18530 authority,
18531 reason_kind,
18532 reason,
18533 help,
18534 } = chunk.kind
18535 else {
18536 panic!("expected AgentStreamChunkKindV1::Blocked");
18537 };
18538 (correlation_id, tool_class, authority, reason_kind, reason, help)
18539 }
18540
18541 #[test]
18545 fn the_enabled_provider_matrix_is_exact() {
18546 let registry = active_registry().unwrap();
18547 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18548 assert_eq!(
18549 providers
18550 .iter()
18551 .map(|contract| contract.provider.as_str())
18552 .collect::<Vec<_>>(),
18553 vec!["shell", "claude", "codex", "grok", "kimi"],
18555 );
18556 assert!(adapters.iter().all(|contract| {
18557 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18558 }));
18559 }
18560
18561 #[test]
18564 fn gate_denied_host_request_mints_a_blocked_chunk() {
18565 let gate_text =
18566 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18567 let event = denied_host_request(
18568 ProviderHostDecisionAuthority::Gate,
18569 Some(gate_text.to_owned()),
18570 );
18571 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18572 assert_eq!(chunk.source_sequence, 2);
18573 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18574 assert_eq!(authority, BlockAuthorityV1::HostGate);
18575 assert_eq!(reason, gate_text);
18576 assert_eq!(correlation_id, None);
18577 assert_eq!(reason_kind, None);
18578 assert_eq!(help, None);
18579 assert_eq!(tool_class, "Write");
18580 }
18581
18582 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.";
18583
18584 #[test]
18590 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18591 let event = provider_control_event(
18592 AdapterFamily::Acp,
18593 ProviderEvent::ToolCompleted {
18594 id: "toolu_9".to_owned(),
18595 output: CLASSIFIER_SAMPLE.to_owned(),
18596 is_error: true,
18597 duration_ms: None,
18598 agent_id: None,
18599 non_execution_kind: Some("permission-rule".to_owned()),
18600 },
18601 );
18602 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18603 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18604 assert_eq!(reason, "Blocked by classifier");
18605 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18606 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18607 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18608 }
18609
18610 #[test]
18615 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18616 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.";
18617 let event = provider_control_event(
18618 AdapterFamily::Acp,
18619 ProviderEvent::SessionEnded {
18620 result: "JSON-RPC error -32603: Internal error".to_owned(),
18621 cost_usd: None,
18622 is_error: true,
18623 stop_reason: Some(ProviderStopReason::ProviderError {
18624 code: -32603,
18625 message: message.to_owned(),
18626 vendor_code: Some("usageLimitExceeded".to_owned()),
18627 }),
18628 },
18629 );
18630 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18631 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18632 assert_eq!(reason, message);
18633 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18634 assert_eq!(tool_class, "Session");
18635 }
18636
18637 #[test]
18640 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18641 let event = provider_control_event(
18642 AdapterFamily::Acp,
18643 ProviderEvent::SessionEnded {
18644 result: "refusal".to_owned(),
18645 cost_usd: None,
18646 is_error: true,
18647 stop_reason: Some(ProviderStopReason::Refusal),
18648 },
18649 );
18650 let (_, _, authority, _, _, _) = blocked(&event);
18651 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18652 }
18653
18654 #[test]
18658 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18659 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18660 let event = provider_control_event(
18661 AdapterFamily::Acp,
18662 ProviderEvent::ToolCompleted {
18663 id: "toolu_1".to_owned(),
18664 output: "command exited with status 1".to_owned(),
18665 is_error: true,
18666 duration_ms: Some(12),
18667 agent_id: None,
18668 non_execution_kind,
18669 },
18670 );
18671 assert!(agent_stream_chunk(&event).is_none());
18672 }
18673 let narrative = provider_control_event(
18674 AdapterFamily::Acp,
18675 ProviderEvent::ToolCompleted {
18676 id: "toolu_2".to_owned(),
18677 output: "the harness blocked the write, approval needed".to_owned(),
18678 is_error: false,
18679 duration_ms: Some(3),
18680 agent_id: None,
18681 non_execution_kind: None,
18682 },
18683 );
18684 assert!(agent_stream_chunk(&narrative).is_none());
18685 }
18686
18687 #[test]
18691 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18692 for (decision, outcome) in [
18693 (
18694 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18695 ProviderHostRequestOutcome::Executed,
18696 ),
18697 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18698 (
18699 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18700 ProviderHostRequestOutcome::Failed {
18701 error: "terminal/create spawn failed: os error 3".to_owned(),
18702 },
18703 ),
18704 ] {
18705 let event = provider_control_event_at(
18706 AdapterFamily::Acp,
18707 "claude-code",
18708 3,
18709 ProviderEvent::HostRequestObserved {
18710 method: "fs/read_text_file".to_owned(),
18711 params_json: String::new(),
18712 decision: decision.clone(),
18713 outcome,
18714 reason: None,
18715 },
18716 );
18717 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18718 }
18719 }
18720
18721 #[test]
18725 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18726 for (by, expected_authority, expected_reason) in [
18727 (
18728 ProviderHostDecisionAuthority::Operator,
18729 BlockAuthorityV1::Operator,
18730 "declined by the operator",
18731 ),
18732 (
18733 ProviderHostDecisionAuthority::DeadlinePolicy,
18734 BlockAuthorityV1::HostDeadline,
18735 "denied by host policy after the operator deadline expired",
18736 ),
18737 ] {
18738 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18739 assert_eq!(authority, expected_authority);
18740 assert_eq!(reason, expected_reason);
18741 }
18742 }
18743
18744 #[test]
18748 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18749 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18750 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18751 ProviderHostDecisionAuthority::Policy,
18752 Some(oversized),
18753 ));
18754 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18755 }
18756}
18757
18758#[cfg(test)]
18759mod standalone_capability_platform_tests {
18760 use super::*;
18761
18762 #[test]
18763 fn standalone_workspace_capability_requires_windows_identity_guards() {
18764 let capabilities = baseline_capabilities().unwrap();
18765 let advertised = capabilities.iter().any(|capability| {
18766 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18767 });
18768 assert_eq!(advertised, cfg!(windows));
18769
18770 let request = NodeRequest::CreateStandaloneWorkspace {
18771 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18772 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18773 initial_branch: None,
18774 };
18775 assert_eq!(
18776 request_uses_unnegotiated_capability(&request, &capabilities),
18777 !cfg!(windows),
18778 );
18779 }
18780}
18781
18782
18783#[cfg(test)]
18784mod station_knobs_resolve_tests {
18785 use super::*;
18786 use gate4agent_catalog::EnvMutation;
18787 use gate4agent_runtime_native::{
18788 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18789 NativeLaunchProfileId,
18790 };
18791 use std::ffi::OsString;
18792 use std::sync::Arc;
18793
18794 struct EmptyEnvironmentResolver;
18795
18796 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18797 fn resolve_child_environment(
18798 &self,
18799 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18800 Ok(vec![EnvMutation {
18801 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18802 value: None,
18803 }])
18804 }
18805 }
18806
18807 fn agent(value: &str) -> AgentId {
18808 AgentId::new(value).unwrap()
18809 }
18810
18811 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18812 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18813 let profile_revision =
18814 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18815 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18816 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18817 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18818 provider: agent("claude"),
18819 mode: SessionMode::Pty,
18820 terminal_size: gate4agent_types::TerminalSize {
18821 rows: 24,
18822 columns: 80,
18823 },
18824 prompt: None,
18825 bundle_id: None,
18826 context_id: None,
18827 environment_profile_id: Some(profile_id.clone()),
18828 };
18829 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18830 let catalog = active_registry().unwrap();
18831 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18832 let control = runtime.native_launch_profile_control();
18833 let node_profile = NodeEnvironmentProfile::new(
18834 profile_id.clone(),
18835 profile_revision,
18836 agent("claude"),
18837 [NativeLaunchProfile::new(
18838 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18839 agent("claude"),
18840 TransportKind::Pty,
18841 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18842 Arc::new(EmptyEnvironmentResolver),
18843 )
18844 .unwrap()],
18845 )
18846 .unwrap();
18847 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18848 for native_profile in native_profiles {
18849 runtime.upsert_native_launch_profile(native_profile).unwrap();
18850 }
18851 let workspace = WorkspaceConfig::new(
18852 WorkspaceId::new("primary").unwrap(),
18853 std::env::current_dir().unwrap(),
18854 )
18855 .unwrap();
18856 let shared = NodeShared::new_with_incarnation(
18857 handle,
18858 "fixture-token".to_owned(),
18859 NodeId::new("node-station-knobs-test").unwrap(),
18860 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18861 vec![workspace],
18862 vec![agent("claude")],
18863 ProviderRuntimeStatuses::default(),
18864 None,
18865 Vec::new(),
18866 Vec::new(),
18867 spawn_profiles,
18868 Some(control),
18869 None,
18870 None,
18871 None,
18872 Vec::new(),
18873 Vec::new(),
18874 Vec::new(),
18875 Vec::new(),
18876 None,
18877 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18878 );
18879 shared
18880 .environment_profiles
18881 .write()
18882 .unwrap_or_else(|poisoned| poisoned.into_inner())
18883 .insert(binding.id.clone(), binding);
18884 (shared, spawn_defaults)
18885 }
18886
18887 #[test]
18890 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18891 let (shared, defaults) = station_knobs_fixture();
18892 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18893 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18894 let spec = crate::protocol::SpawnSpec {
18895 target: crate::protocol::SpawnTarget {
18896 node_id: shared.node_id.clone(),
18897 workspace_id: WorkspaceId::new("primary").unwrap(),
18898 worktree_id: None,
18899 },
18900 profile_id: defaults.profile_id.clone(),
18901 expected_profile_revision: defaults.revision.clone(),
18902 overrides: crate::protocol::SpawnOverrides {
18903 network_allowlist: Some(unknown),
18904 browser_profile_id: Some(browser),
18905 ..crate::protocol::SpawnOverrides::default()
18906 },
18907 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18908 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18909 "station-knobs-unknown-allowlist",
18910 )
18911 .unwrap(),
18912 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18913 };
18914 let resolved = spec.resolve(&defaults).unwrap();
18915 let failure = shared
18916 .resolve_environment_profile(&resolved)
18917 .expect_err("unknown allowlist must refuse");
18918 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18919 }
18920
18921 #[test]
18927 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18928 let (shared, defaults) = station_knobs_fixture();
18929 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18930 #[cfg(not(feature = "dig2-station-probe"))]
18931 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18932 shared
18933 .network_allowlist_catalog
18934 .write()
18935 .unwrap_or_else(|poisoned| poisoned.into_inner())
18936 .insert_id(allowlist.clone())
18937 .unwrap();
18938 let spec = crate::protocol::SpawnSpec {
18939 target: crate::protocol::SpawnTarget {
18940 node_id: shared.node_id.clone(),
18941 workspace_id: WorkspaceId::new("primary").unwrap(),
18942 worktree_id: None,
18943 },
18944 profile_id: defaults.profile_id.clone(),
18945 expected_profile_revision: defaults.revision.clone(),
18946 overrides: crate::protocol::SpawnOverrides {
18947 network_allowlist: Some(allowlist.clone()),
18948 #[cfg(not(feature = "dig2-station-probe"))]
18949 browser_profile_id: Some(browser.clone()),
18950 ..crate::protocol::SpawnOverrides::default()
18951 },
18952 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18953 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18954 .unwrap(),
18955 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18956 };
18957 let resolved = spec.resolve(&defaults).unwrap();
18958 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18959 #[cfg(not(feature = "dig2-station-probe"))]
18960 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18961 let receipt = shared
18962 .resolve_environment_profile(&resolved)
18963 .expect("resolve ok")
18964 .expect("env profile present");
18965 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18966 #[cfg(not(feature = "dig2-station-probe"))]
18967 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18968 #[cfg(feature = "dig2-station-probe")]
18969 assert_eq!(receipt.browser_profile_id, None);
18970 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18971
18972 let omitted = crate::protocol::SpawnSpec {
18973 overrides: crate::protocol::SpawnOverrides::default(),
18974 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18975 .unwrap(),
18976 ..spec
18977 };
18978 let resolved_omit = omitted.resolve(&defaults).unwrap();
18979 let bare = shared
18980 .resolve_environment_profile(&resolved_omit)
18981 .expect("resolve ok")
18982 .expect("env profile present");
18983 assert_eq!(bare.network_allowlist, None);
18984 assert_eq!(bare.browser_profile_id, None);
18985 }
18986
18987 #[cfg(feature = "dig2-station-probe")]
18992 #[test]
18993 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18994 let (shared, defaults) = station_knobs_fixture();
18995 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18996 let spec = crate::protocol::SpawnSpec {
18997 target: crate::protocol::SpawnTarget {
18998 node_id: shared.node_id.clone(),
18999 workspace_id: WorkspaceId::new("primary").unwrap(),
19000 worktree_id: None,
19001 },
19002 profile_id: defaults.profile_id.clone(),
19003 expected_profile_revision: defaults.revision.clone(),
19004 overrides: crate::protocol::SpawnOverrides {
19005 browser_profile_id: Some(browser),
19006 ..crate::protocol::SpawnOverrides::default()
19007 },
19008 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19009 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19010 "station-knobs-probe-refuse",
19011 )
19012 .unwrap(),
19013 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19014 };
19015 let resolved = spec.resolve(&defaults).unwrap();
19016 let failure = shared
19017 .resolve_environment_profile(&resolved)
19018 .expect_err("browser_profile with failing probe must refuse");
19019 #[cfg(windows)]
19020 {
19021 assert!(
19022 matches!(
19023 failure.code,
19024 NodeFailureCode::BrowserStationUnreachable
19025 | NodeFailureCode::BrowserStationProbeUnavailable
19026 ),
19027 "unexpected code {:?}",
19028 failure.code
19029 );
19030 }
19031 #[cfg(not(windows))]
19032 {
19033 assert_eq!(
19034 failure.code,
19035 NodeFailureCode::BrowserStationProbeUnavailable
19036 );
19037 }
19038 }
19039
19040 #[cfg(feature = "dig2-station-probe")]
19042 #[test]
19043 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19044 let (shared, defaults) = station_knobs_fixture();
19045 let spec = crate::protocol::SpawnSpec {
19046 target: crate::protocol::SpawnTarget {
19047 node_id: shared.node_id.clone(),
19048 workspace_id: WorkspaceId::new("primary").unwrap(),
19049 worktree_id: None,
19050 },
19051 profile_id: defaults.profile_id.clone(),
19052 expected_profile_revision: defaults.revision.clone(),
19053 overrides: crate::protocol::SpawnOverrides::default(),
19054 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19055 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19056 "station-knobs-probe-omit",
19057 )
19058 .unwrap(),
19059 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19060 };
19061 let resolved = spec.resolve(&defaults).unwrap();
19062 let receipt = shared
19063 .resolve_environment_profile(&resolved)
19064 .expect("omit browser_profile must not probe")
19065 .expect("env profile present");
19066 assert_eq!(receipt.browser_profile_id, None);
19067 }
19068
19069 #[cfg(feature = "dig2-station-probe")]
19073 #[test]
19074 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19075 let (shared, _defaults) = station_knobs_fixture();
19076 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19077 let workspace_id = WorkspaceId::new("primary").unwrap();
19078 let address_a = SessionAddress {
19079 workspace_id: workspace_id.clone(),
19080 session: SessionKey {
19081 instance_id: AgentInstanceId(101),
19082 generation: SessionGeneration(1),
19083 },
19084 };
19085 let address_b = SessionAddress {
19086 workspace_id: workspace_id.clone(),
19087 session: SessionKey {
19088 instance_id: AgentInstanceId(102),
19089 generation: SessionGeneration(1),
19090 },
19091 };
19092 let guard_a = crate::dig2_station_lease::acquire_lease(
19093 &shared.browser_station_leases,
19094 profile.clone(),
19095 address_a.session.instance_id,
19096 1,
19097 )
19098 .expect("first lease");
19099 let busy = crate::dig2_station_lease::acquire_lease(
19100 &shared.browser_station_leases,
19101 profile.clone(),
19102 address_b.session.instance_id,
19103 2,
19104 )
19105 .expect_err("second holder must refuse");
19106 assert!(matches!(
19107 busy,
19108 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19109 ));
19110 shared.bind_session_with_policy(
19112 &address_a,
19113 ProviderRuntimePolicy::raw_pty(),
19114 None,
19115 );
19116 guard_a.retain();
19117 assert!(shared.remove_binding(&address_a).is_some());
19118 crate::dig2_station_lease::acquire_lease(
19119 &shared.browser_station_leases,
19120 profile,
19121 address_b.session.instance_id,
19122 3,
19123 )
19124 .expect("reacquire after clear")
19125 .retain();
19126 assert!(
19127 crate::dig2_station_lease::release_lease_for_holder(
19128 &shared.browser_station_leases,
19129 address_b.session.instance_id,
19130 )
19131 );
19132 }
19133
19134 #[cfg(not(feature = "dig2-station-probe"))]
19137 #[test]
19138 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19139 let (shared, defaults) = station_knobs_fixture();
19140 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19141 let spec = crate::protocol::SpawnSpec {
19142 target: crate::protocol::SpawnTarget {
19143 node_id: shared.node_id.clone(),
19144 workspace_id: WorkspaceId::new("primary").unwrap(),
19145 worktree_id: None,
19146 },
19147 profile_id: defaults.profile_id.clone(),
19148 expected_profile_revision: defaults.revision.clone(),
19149 overrides: crate::protocol::SpawnOverrides {
19150 browser_profile_id: Some(browser.clone()),
19151 ..crate::protocol::SpawnOverrides::default()
19152 },
19153 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19154 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19155 .unwrap(),
19156 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19157 };
19158 let resolved = spec.resolve(&defaults).unwrap();
19159 let receipt = shared
19160 .resolve_environment_profile(&resolved)
19161 .expect("stub resolve")
19162 .expect("env profile");
19163 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19164 }
19165
19166
19167 #[test]
19170 fn network_allowlist_catalog_loads_from_temp_file() {
19171 let unique = std::time::SystemTime::now()
19172 .duration_since(std::time::UNIX_EPOCH)
19173 .unwrap()
19174 .as_nanos();
19175 let path = std::env::temp_dir().join(format!(
19176 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19177 std::process::id(),
19178 ));
19179 std::fs::write(
19180 &path,
19181 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19182 )
19183 .unwrap();
19184 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19185 .expect("temp catalog file must load");
19186 let _ = std::fs::remove_file(&path);
19187 assert_eq!(loaded.len(), 2);
19188 let (shared, defaults) = station_knobs_fixture();
19189 {
19190 let mut catalog = shared
19191 .network_allowlist_catalog
19192 .write()
19193 .unwrap_or_else(|poisoned| poisoned.into_inner());
19194 *catalog = loaded;
19195 }
19196 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19197 let spec = crate::protocol::SpawnSpec {
19198 target: crate::protocol::SpawnTarget {
19199 node_id: shared.node_id.clone(),
19200 workspace_id: WorkspaceId::new("primary").unwrap(),
19201 worktree_id: None,
19202 },
19203 profile_id: defaults.profile_id.clone(),
19204 expected_profile_revision: defaults.revision.clone(),
19205 overrides: crate::protocol::SpawnOverrides {
19206 network_allowlist: Some(allowlist.clone()),
19207 ..crate::protocol::SpawnOverrides::default()
19208 },
19209 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19210 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19211 "station-knobs-catalog-file",
19212 )
19213 .unwrap(),
19214 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19215 };
19216 let resolved = spec.resolve(&defaults).unwrap();
19217 let receipt = shared
19218 .resolve_environment_profile(&resolved)
19219 .expect("registered via file load")
19220 .expect("env profile present");
19221 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19222 }
19223
19224 #[test]
19227 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19228 let (shared, _defaults) = station_knobs_fixture();
19229 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19230 shared
19231 .network_allowlist_catalog
19232 .write()
19233 .unwrap_or_else(|poisoned| poisoned.into_inner())
19234 .insert_id(allowlist.clone())
19235 .unwrap();
19236 let snapshot = shared.snapshot();
19237 let inventory = snapshot
19238 .launch_inventory
19239 .expect("launch inventory present");
19240 let ids = inventory
19241 .network_allowlists
19242 .expect("non-empty catalog surfaces ids");
19243 assert_eq!(ids, vec![allowlist]);
19244 }
19245
19246 #[test]
19250 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19251 let (shared, defaults) = station_knobs_fixture();
19252 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19253 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19254 id: allowlist.clone(),
19255 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19256 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19257 peer: "127.0.0.1:443".to_owned(),
19258 }],
19259 provider_native: Some(
19260 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19261 codex_network_access: Some(true),
19262 },
19263 ),
19264 };
19265 shared
19266 .network_allowlist_catalog
19267 .write()
19268 .unwrap_or_else(|poisoned| poisoned.into_inner())
19269 .insert(entry)
19270 .unwrap();
19271 let spec = crate::protocol::SpawnSpec {
19272 target: crate::protocol::SpawnTarget {
19273 node_id: shared.node_id.clone(),
19274 workspace_id: WorkspaceId::new("primary").unwrap(),
19275 worktree_id: None,
19276 },
19277 profile_id: defaults.profile_id.clone(),
19278 expected_profile_revision: defaults.revision.clone(),
19279 overrides: crate::protocol::SpawnOverrides {
19280 network_allowlist: Some(allowlist),
19281 ..crate::protocol::SpawnOverrides::default()
19282 },
19283 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19284 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19285 "station-knobs-unsupported-mapping",
19286 )
19287 .unwrap(),
19288 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19289 };
19290 let resolved = spec.resolve(&defaults).unwrap();
19291 assert_eq!(resolved.provider.as_str(), "claude");
19292 let failure = shared
19293 .resolve_environment_profile(&resolved)
19294 .expect_err("claude + codex_network_access must refuse");
19295 assert_eq!(
19296 failure.code,
19297 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19298 );
19299 }
19300
19301 #[test]
19305 fn resolve_codex_network_access_overlay_moderate_only() {
19306 let (shared, defaults) = station_knobs_fixture();
19307 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19308 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19309 id: allowlist.clone(),
19310 permits: Vec::new(),
19311 provider_native: Some(
19312 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19313 codex_network_access: Some(true),
19314 },
19315 ),
19316 };
19317 shared
19318 .network_allowlist_catalog
19319 .write()
19320 .unwrap_or_else(|poisoned| poisoned.into_inner())
19321 .insert(entry)
19322 .unwrap();
19323
19324 let overlay = shared
19325 .resolve_codex_network_access_overlay_args(
19326 &agent("codex"),
19327 SessionMode::Pty,
19328 ApprovalLevel::Moderate,
19329 Some(&allowlist),
19330 )
19331 .expect("Moderate Codex maps network_access");
19332 assert_eq!(
19333 overlay,
19334 vec![
19335 std::ffi::OsString::from("-c"),
19336 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19337 ]
19338 );
19339
19340 let acp = shared
19341 .resolve_codex_network_access_overlay_args(
19342 &agent("codex"),
19343 SessionMode::Acp,
19344 ApprovalLevel::Moderate,
19345 Some(&allowlist),
19346 )
19347 .unwrap();
19348 assert_eq!(acp, overlay);
19349
19350 let inline = shared
19351 .resolve_codex_network_access_overlay_args(
19352 &agent("codex"),
19353 SessionMode::Inline,
19354 ApprovalLevel::Moderate,
19355 Some(&allowlist),
19356 )
19357 .unwrap_err();
19358 assert_eq!(
19359 inline.code,
19360 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19361 );
19362
19363 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19364 let spec = crate::protocol::SpawnSpec {
19365 target: crate::protocol::SpawnTarget {
19366 node_id: shared.node_id.clone(),
19367 workspace_id: WorkspaceId::new("primary").unwrap(),
19368 worktree_id: None,
19369 },
19370 profile_id: defaults.profile_id.clone(),
19371 expected_profile_revision: defaults.revision.clone(),
19372 overrides: crate::protocol::SpawnOverrides {
19373 provider: crate::protocol::SpawnOverride::Set {
19374 value: agent("codex"),
19375 },
19376 approval_level: Some(level),
19377 network_allowlist: Some(allowlist.clone()),
19378 ..crate::protocol::SpawnOverrides::default()
19379 },
19380 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19381 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19382 "codex-net-refuse-{}",
19383 match level {
19384 ApprovalLevel::ReadOnly => "ro",
19385 ApprovalLevel::FullAuto => "fa",
19386 _ => "other",
19387 }
19388 ))
19389 .unwrap(),
19390 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19391 };
19392 let resolved = spec.resolve(&defaults).unwrap();
19393 let failure = shared
19394 .resolve_environment_profile(&resolved)
19395 .expect_err("RO/FullAuto must refuse");
19396 assert_eq!(
19397 failure.code,
19398 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19399 );
19400 }
19401 }
19402
19403 #[test]
19407 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19408 let (shared, _defaults) = station_knobs_fixture();
19409 let allowlist =
19410 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19411 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19412 id: allowlist.clone(),
19413 permits: vec![
19414 crate::network_allowlist_catalog::NetworkPermitSketch {
19415 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19416 peer: "github.com:443".to_owned(),
19417 },
19418 crate::network_allowlist_catalog::NetworkPermitSketch {
19419 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19420 peer: "*.npmjs.org".to_owned(),
19421 },
19422 ],
19423 provider_native: None,
19424 };
19425 shared
19426 .network_allowlist_catalog
19427 .write()
19428 .unwrap_or_else(|poisoned| poisoned.into_inner())
19429 .insert(entry)
19430 .unwrap();
19431
19432 let unique = std::time::SystemTime::now()
19433 .duration_since(std::time::UNIX_EPOCH)
19434 .unwrap()
19435 .as_nanos();
19436 let provider_home = std::env::temp_dir().join(format!(
19437 "gate4agent-claude-provider-home-{}-{unique}",
19438 std::process::id(),
19439 ));
19440 std::fs::create_dir_all(&provider_home).unwrap();
19441
19442 let overlay = shared
19443 .resolve_network_allowlist_overlay_args(
19444 &agent("claude"),
19445 SessionMode::Pty,
19446 ApprovalLevel::Moderate,
19447 Some(&allowlist),
19448 Some(provider_home.as_path()),
19449 )
19450 .expect("Claude PTY settings overlay on unix");
19451 let settings_path = provider_home.join(
19452 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19453 );
19454 assert_eq!(
19455 overlay,
19456 vec![
19457 std::ffi::OsString::from("--settings"),
19458 std::ffi::OsString::from(settings_path.as_os_str()),
19459 ]
19460 );
19461 let body = std::fs::read_to_string(&settings_path).unwrap();
19462 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19463 assert_eq!(value["sandbox"]["enabled"], true);
19464 assert_eq!(
19465 value["sandbox"]["network"]["allowedDomains"],
19466 serde_json::json!(["github.com", "*.npmjs.org"])
19467 );
19468 assert!(settings_path.starts_with(&provider_home));
19469
19470 let acp = shared
19471 .resolve_network_allowlist_overlay_args(
19472 &agent("claude"),
19473 SessionMode::Acp,
19474 ApprovalLevel::Moderate,
19475 Some(&allowlist),
19476 Some(provider_home.as_path()),
19477 )
19478 .expect_err("Claude ACP must refuse settings overlay");
19479 assert_eq!(
19480 acp.code,
19481 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19482 );
19483
19484 let no_home = shared
19485 .resolve_network_allowlist_overlay_args(
19486 &agent("claude"),
19487 SessionMode::Pty,
19488 ApprovalLevel::Moderate,
19489 Some(&allowlist),
19490 None,
19491 )
19492 .expect_err("Claude overlay requires ProviderHome");
19493 assert_eq!(
19494 no_home.code,
19495 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19496 );
19497
19498 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19499 shared
19500 .network_allowlist_catalog
19501 .write()
19502 .unwrap_or_else(|poisoned| poisoned.into_inner())
19503 .insert_id(bare.clone())
19504 .unwrap();
19505 let empty = shared
19506 .resolve_network_allowlist_overlay_args(
19507 &agent("claude"),
19508 SessionMode::Pty,
19509 ApprovalLevel::Moderate,
19510 Some(&bare),
19511 Some(provider_home.as_path()),
19512 )
19513 .unwrap();
19514 assert!(empty.is_empty());
19515
19516 let _ = std::fs::remove_dir_all(&provider_home);
19517 }
19518
19519 #[test]
19521 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19522 let (shared, _defaults) = station_knobs_fixture();
19523 let allowlist =
19524 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19525 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19526 id: allowlist.clone(),
19527 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19528 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19529 peer: "example.com:443".to_owned(),
19530 }],
19531 provider_native: None,
19532 };
19533 shared
19534 .network_allowlist_catalog
19535 .write()
19536 .unwrap_or_else(|poisoned| poisoned.into_inner())
19537 .insert(entry)
19538 .unwrap();
19539 for provider in ["kimi", "grok"] {
19540 let overlay = shared
19541 .resolve_network_allowlist_overlay_args(
19542 &agent(provider),
19543 SessionMode::Pty,
19544 ApprovalLevel::Moderate,
19545 Some(&allowlist),
19546 None,
19547 )
19548 .unwrap();
19549 assert!(
19550 overlay.is_empty(),
19551 "{provider} must not invent network argv from permits"
19552 );
19553 }
19554 }
19555
19556 #[test]
19558 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19559 let unique = std::time::SystemTime::now()
19560 .duration_since(std::time::UNIX_EPOCH)
19561 .unwrap()
19562 .as_nanos();
19563 let path = std::env::temp_dir().join(format!(
19564 "gate4agent-node-allowlist-json-{}-{unique}.json",
19565 std::process::id(),
19566 ));
19567 std::fs::write(
19568 &path,
19569 r#"{
19570 "schema_version": 2,
19571 "entries": [
19572 {
19573 "id": "egress-default",
19574 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19575 },
19576 { "id": "lab-egress" }
19577 ]
19578 }"#,
19579 )
19580 .unwrap();
19581 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19582 .expect("json v2 catalog must load");
19583 let _ = std::fs::remove_file(&path);
19584 assert_eq!(loaded.len(), 2);
19585 let entry = loaded
19586 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19587 .unwrap();
19588 assert_eq!(entry.permits.len(), 1);
19589 let (shared, defaults) = station_knobs_fixture();
19590 {
19591 let mut catalog = shared
19592 .network_allowlist_catalog
19593 .write()
19594 .unwrap_or_else(|poisoned| poisoned.into_inner());
19595 *catalog = loaded;
19596 }
19597 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19598 let spec = crate::protocol::SpawnSpec {
19599 target: crate::protocol::SpawnTarget {
19600 node_id: shared.node_id.clone(),
19601 workspace_id: WorkspaceId::new("primary").unwrap(),
19602 worktree_id: None,
19603 },
19604 profile_id: defaults.profile_id.clone(),
19605 expected_profile_revision: defaults.revision.clone(),
19606 overrides: crate::protocol::SpawnOverrides {
19607 network_allowlist: Some(allowlist.clone()),
19608 ..crate::protocol::SpawnOverrides::default()
19609 },
19610 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19611 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19612 "station-knobs-catalog-json",
19613 )
19614 .unwrap(),
19615 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19616 };
19617 let resolved = spec.resolve(&defaults).unwrap();
19618 let receipt = shared
19619 .resolve_environment_profile(&resolved)
19620 .expect("permits-only entry ok for claude")
19621 .expect("env profile present");
19622 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19623 let inventory = shared
19624 .snapshot()
19625 .launch_inventory
19626 .expect("launch inventory present");
19627 let ids = inventory.network_allowlists.expect("ids surface");
19628 assert_eq!(
19629 ids,
19630 vec![
19631 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19632 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19633 ]
19634 );
19635 }
19636
19637
19638}
19639
19640
19641#[cfg(test)]
19642mod workspace_environment_collision_tests {
19643 use super::*;
19644 use crate::session_environment::{
19645 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19646 };
19647 use gate4agent_catalog::EnvMutation;
19648 use gate4agent_runtime_native::{
19649 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19650 NativeLaunchProfileId,
19651 };
19652 use std::ffi::OsString;
19653 use std::sync::Arc;
19654
19655 struct EmptyEnvironmentResolver;
19656
19657 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19658 fn resolve_child_environment(
19659 &self,
19660 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19661 Ok(vec![EnvMutation {
19662 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19663 value: None,
19664 }])
19665 }
19666 }
19667
19668 fn agent(value: &str) -> AgentId {
19669 AgentId::new(value).unwrap()
19670 }
19671
19672 fn claude_config_dir_collision_fixture() -> (
19674 NodeShared,
19675 ResolvedEnvironmentProfileReceipt,
19676 ResolvedEnvironmentProfileReceipt,
19677 ) {
19678 let catalog = active_registry().unwrap();
19679 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19680 let control = runtime.native_launch_profile_control();
19681 let workspace = WorkspaceConfig::new(
19682 WorkspaceId::new("primary").unwrap(),
19683 std::env::current_dir().unwrap(),
19684 )
19685 .unwrap();
19686 let shared = NodeShared::new_with_incarnation(
19687 handle,
19688 "fixture-token".to_owned(),
19689 NodeId::new("node-env-collision-test").unwrap(),
19690 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19691 vec![workspace],
19692 vec![agent("claude")],
19693 ProviderRuntimeStatuses::default(),
19694 None,
19695 Vec::new(),
19696 Vec::new(),
19697 SpawnProfileRegistry::default(),
19698 Some(control),
19699 None,
19700 None,
19701 None,
19702 Vec::new(),
19703 Vec::new(),
19704 Vec::new(),
19705 Vec::new(),
19706 None,
19707 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19708 );
19709
19710 let mut receipts = Vec::new();
19711 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19712 let profile_id =
19713 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19714 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19715 "claude-config-dir-{suffix}-r1"
19716 ))
19717 .unwrap();
19718 let materialization = NodeSessionMaterializationProfile::new(
19719 Vec::new(),
19720 vec![NodeSessionPathBinding::new(
19721 "CLAUDE_CONFIG_DIR",
19722 NodeSessionPathClass::ProviderHome,
19723 )
19724 .unwrap()],
19725 Vec::new(),
19726 )
19727 .unwrap();
19728 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19729 profile_id.clone(),
19730 profile_revision.clone(),
19731 agent("claude"),
19732 [NativeLaunchProfile::new(
19733 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19734 .unwrap(),
19735 agent("claude"),
19736 TransportKind::Pty,
19737 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19738 Arc::new(EmptyEnvironmentResolver),
19739 )
19740 .unwrap()],
19741 Some(materialization),
19742 )
19743 .unwrap();
19744 let (binding, native_profiles, materialization) = node_profile.into_parts();
19745 for native_profile in native_profiles {
19746 runtime.upsert_native_launch_profile(native_profile).unwrap();
19747 }
19748 shared
19749 .environment_profiles
19750 .write()
19751 .unwrap_or_else(|poisoned| poisoned.into_inner())
19752 .insert(binding.id.clone(), binding);
19753 shared
19754 .environment_materialization_profiles
19755 .write()
19756 .unwrap_or_else(|poisoned| poisoned.into_inner())
19757 .insert(profile_id.clone(), materialization.unwrap());
19758 receipts.push(ResolvedEnvironmentProfileReceipt {
19759 profile_id,
19760 profile_revision,
19761 network_allowlist: None,
19762 browser_profile_id: None,
19763 });
19764 }
19765 let profile_b = receipts.pop().unwrap();
19766 let profile_a = receipts.pop().unwrap();
19767 (shared, profile_a, profile_b)
19768 }
19769
19770 #[test]
19771 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19772 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19773 let primary = WorkspaceId::new("primary").unwrap();
19774 let worktree = WorkspaceId::new("worktree-b").unwrap();
19775 let address = SessionAddress {
19776 workspace_id: primary.clone(),
19777 session: SessionKey {
19778 instance_id: AgentInstanceId(41),
19779 generation: SessionGeneration(1),
19780 },
19781 };
19782 shared.bind_session_with_policy(
19783 &address,
19784 ProviderRuntimePolicy::raw_pty(),
19785 Some(profile_a.clone()),
19786 );
19787
19788 let refused = shared
19789 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19790 .unwrap_err();
19791 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19792
19793 let refused_none = shared
19795 .ensure_workspace_environment_profile_compatible(&primary, None)
19796 .unwrap_err();
19797 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19798
19799 shared
19801 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19802 .unwrap();
19803
19804 shared
19806 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19807 .unwrap();
19808 }
19809
19810 #[tokio::test]
19811 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19812 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19813 let primary = WorkspaceId::new("primary").unwrap();
19814 let address = SessionAddress {
19815 workspace_id: primary.clone(),
19816 session: SessionKey {
19817 instance_id: AgentInstanceId(41),
19818 generation: SessionGeneration(1),
19819 },
19820 };
19821 shared.bind_session_with_policy(
19822 &address,
19823 ProviderRuntimePolicy::raw_pty(),
19824 Some(profile_a),
19825 );
19826
19827 let failure = shared
19828 .spawn_session_with_deadline(
19829 primary,
19830 agent("claude"),
19831 SessionMode::Pty,
19832 gate4agent_types::TerminalSize {
19833 rows: 24,
19834 columns: 80,
19835 },
19836 None,
19837 Some(profile_b),
19838 None,
19839 None,
19840 None,
19841 None,
19842 SpawnRecordPolicy::ProviderIdentityOnly,
19843 Some(Instant::now() + Duration::from_secs(30)),
19844 &[],
19845 None,
19846 ApprovalLevel::default(),
19847 None,
19848 None,
19849 )
19850 .await
19851 .unwrap_err();
19852 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19853 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19855 assert!(shared.handle.snapshot().sessions.is_empty());
19856 }
19857}
19858
19859#[cfg(test)]
19860mod spawn_characterization_tests {
19861 use super::*;
19862 use crate::session_environment::{
19863 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19864 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19865 NodeSessionPathClass,
19866 };
19867 use gate4agent_catalog::EnvMutation;
19868 use gate4agent_runtime_native::{
19869 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19870 NativeLaunchProfile, NativeLaunchProfileId,
19871 };
19872 use std::ffi::OsString;
19873 use std::sync::Arc;
19874
19875 struct EmptyEnvironmentResolver;
19876
19877 struct FixtureSecretResolver {
19878 deny: Arc<AtomicBool>,
19879 }
19880
19881 impl NodeSecretResolver for FixtureSecretResolver {
19882 fn resolve(
19883 &self,
19884 _reference: &NodeSecretReference,
19885 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19886 if self.deny.load(Ordering::Acquire) {
19887 Err(NodeSecretResolveError::Denied)
19888 } else {
19889 NodeSecretValue::text("fixture-secret")
19890 .map_err(|_| NodeSecretResolveError::Unavailable)
19891 }
19892 }
19893 }
19894
19895 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19896 fn resolve_child_environment(
19897 &self,
19898 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19899 Ok(vec![EnvMutation {
19900 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19901 value: None,
19902 }])
19903 }
19904 }
19905
19906 fn agent(value: &str) -> AgentId {
19907 AgentId::new(value).unwrap()
19908 }
19909
19910 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19911 let unique = std::time::SystemTime::now()
19912 .duration_since(std::time::UNIX_EPOCH)
19913 .unwrap()
19914 .as_nanos();
19915 std::env::temp_dir().join(format!(
19916 "gate4agent-node-{label}-{}-{unique}",
19917 std::process::id(),
19918 ))
19919 }
19920
19921 fn materialization_test_shared(
19922 deny_secret: bool,
19923 ) -> (
19924 NodeShared,
19925 NativeRuntime,
19926 ResolvedEnvironmentProfileReceipt,
19927 PathBuf,
19928 Arc<AtomicBool>,
19929 ) {
19930 let workspace = WorkspaceConfig::new(
19931 WorkspaceId::new("primary").unwrap(),
19932 std::env::current_dir().unwrap(),
19933 )
19934 .unwrap();
19935 materialization_test_shared_with_workspace(deny_secret, workspace)
19936 }
19937
19938 fn materialization_test_shared_with_workspace(
19939 deny_secret: bool,
19940 workspace: WorkspaceConfig,
19941 ) -> (
19942 NodeShared,
19943 NativeRuntime,
19944 ResolvedEnvironmentProfileReceipt,
19945 PathBuf,
19946 Arc<AtomicBool>,
19947 ) {
19948 let root = temporary_workspace_root(if deny_secret {
19949 "materialization-denied"
19950 } else {
19951 "materialization-ready"
19952 });
19953 std::fs::create_dir_all(&root).unwrap();
19954 let materialization_root = root.join("session-environments");
19955 let deny = Arc::new(AtomicBool::new(deny_secret));
19956 let resolver = Arc::new(FixtureSecretResolver {
19957 deny: Arc::clone(&deny),
19958 });
19959 let materializer = SessionEnvironmentMaterializer::new(
19960 materialization_root,
19961 resolver,
19962 )
19963 .unwrap();
19964 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19965 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19966 "materialized-claude-r1",
19967 )
19968 .unwrap();
19969 let materialization = NodeSessionMaterializationProfile::new(
19970 vec![NodeSessionEnvironmentMutation::SetSecret {
19971 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19972 reference: NodeSecretReference::new("fixture-token").unwrap(),
19973 }],
19974 vec![NodeSessionPathBinding::new(
19975 "GATE4AGENT_PROVIDER_HOME",
19976 NodeSessionPathClass::ProviderHome,
19977 )
19978 .unwrap()],
19979 vec![NodeSessionFile::secret(
19980 NodeSessionPathClass::Config,
19981 "auth/token",
19982 NodeSecretReference::new("fixture-token").unwrap(),
19983 )
19984 .unwrap()],
19985 )
19986 .unwrap();
19987 let catalog = active_registry().unwrap();
19988 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19989 let control = runtime.native_launch_profile_control();
19990 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19991 profile_id.clone(),
19992 profile_revision.clone(),
19993 agent("claude"),
19994 [NativeLaunchProfile::new(
19995 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19996 agent("claude"),
19997 TransportKind::Pty,
19998 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19999 Arc::new(EmptyEnvironmentResolver),
20000 )
20001 .unwrap()],
20002 Some(materialization),
20003 )
20004 .unwrap();
20005 let (binding, native_profiles, materialization) = node_profile.into_parts();
20006 for native_profile in native_profiles {
20007 runtime.upsert_native_launch_profile(native_profile).unwrap();
20008 }
20009 let shared = NodeShared::new_with_incarnation(
20010 handle,
20011 "fixture-token".to_owned(),
20012 NodeId::new("node-materialization-test").unwrap(),
20013 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20014 vec![workspace],
20015 vec![agent("claude")],
20016 ProviderRuntimeStatuses::default(),
20017 None,
20018 Vec::new(),
20019 Vec::new(),
20020 SpawnProfileRegistry::default(),
20021 Some(control),
20022 None,
20023 Some(materializer),
20024 None,
20025 Vec::new(),
20026 Vec::new(),
20027 Vec::new(),
20028 Vec::new(),
20029 None,
20030 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20031 );
20032 shared
20033 .environment_profiles
20034 .write()
20035 .unwrap_or_else(|poisoned| poisoned.into_inner())
20036 .insert(binding.id.clone(), binding);
20037 shared
20038 .environment_materialization_profiles
20039 .write()
20040 .unwrap_or_else(|poisoned| poisoned.into_inner())
20041 .insert(profile_id.clone(), materialization.unwrap());
20042 (
20043 shared,
20044 runtime,
20045 ResolvedEnvironmentProfileReceipt {
20046 profile_id,
20047 profile_revision,
20048 network_allowlist: None,
20049 browser_profile_id: None,
20050 },
20051 root,
20052 deny,
20053 )
20054 }
20055
20056
20057 async fn characterization_spawn(
20060 shared: &NodeShared,
20061 mode: SessionMode,
20062 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20063 required: &[ProviderRuntimeCapability],
20064 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20065 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20066 shared
20067 .spawn_session_with_deadline(
20068 WorkspaceId::new("primary").unwrap(),
20069 agent("claude"),
20070 mode,
20071 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20072 None,
20073 environment_profile,
20074 None,
20075 None,
20076 None,
20077 Some(ProviderRuntimePolicy::raw_pty()),
20078 SpawnRecordPolicy::ProviderIdentityOnly,
20079 Some(Instant::now() + Duration::from_secs(30)),
20080 required,
20081 harness_mcp,
20082 ApprovalLevel::default(),
20083 None,
20084 None,
20085 )
20086 .await
20087 }
20088
20089 fn assert_no_session_state(shared: &NodeShared) {
20090 assert!(shared.handle.snapshot().sessions.is_empty());
20091 assert!(shared
20092 .session_bindings
20093 .lock()
20094 .unwrap_or_else(|poisoned| poisoned.into_inner())
20095 .is_empty());
20096 assert!(shared
20097 .materializations
20098 .lock()
20099 .unwrap_or_else(|poisoned| poisoned.into_inner())
20100 .is_empty());
20101 }
20102
20103 #[tokio::test]
20104 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20105 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20106 let error = characterization_spawn(
20107 &shared,
20108 SessionMode::Pty,
20109 None,
20110 &[ProviderRuntimeCapability::SemanticReadiness],
20111 None,
20112 )
20113 .await
20114 .unwrap_err();
20115 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20116 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20117 assert_no_session_state(&shared);
20118 drop(shared);
20119 drop(runtime);
20120 std::fs::remove_dir_all(root).unwrap();
20121 }
20122
20123 #[tokio::test]
20124 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20125 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20126 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20127 .await
20128 .unwrap_err();
20129 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20130 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20131 assert_no_session_state(&shared);
20132 drop(shared);
20133 drop(runtime);
20134 std::fs::remove_dir_all(root).unwrap();
20135 }
20136
20137 #[test]
20138 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20139 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20140 let address = SessionAddress {
20141 workspace_id: WorkspaceId::new("primary").unwrap(),
20142 session: SessionKey {
20143 instance_id: AgentInstanceId(77),
20144 generation: SessionGeneration(1),
20145 },
20146 };
20147 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20148 let removed = shared.remove_binding(&address).expect("binding removed");
20149 assert!(removed.environment_profile.is_some());
20150 assert!(shared.remove_binding(&address).is_none());
20151 assert_no_session_state(&shared);
20152 drop(shared);
20153 drop(runtime);
20154 std::fs::remove_dir_all(root).unwrap();
20155 }
20156
20157 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20158 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20159 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20160 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20161 .expect("review current test executable as fixture helper");
20162 let registry = HarnessMcpProxyRegistry::new(helper);
20163 let spec = crate::protocol::SpawnSpec {
20164 target: crate::protocol::SpawnTarget {
20165 node_id: NodeId::new("node-characterization").unwrap(),
20166 workspace_id: WorkspaceId::new("primary").unwrap(),
20167 worktree_id: None,
20168 },
20169 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20170 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20171 overrides: crate::protocol::SpawnOverrides::default(),
20172 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20173 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20174 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20175 };
20176 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20177 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20178 let launch = HarnessMcpLaunchV1 {
20179 server_name: "fixture-harness".to_owned(),
20180 args: vec!["--session-proxy".to_owned()],
20181 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20182 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20183 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20184 trace: None,
20185 scrub_env: Vec::new(),
20186 };
20187 let expires = unix_time_ms().saturating_add(30_000);
20188 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20189 registry
20190 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20191 .unwrap()
20192 }
20193
20194 #[tokio::test]
20195 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20196 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20197 let prepared = characterization_prepared_harness("claude").await;
20198 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20199 .await
20200 .unwrap_err();
20201 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20203 let mismatched = characterization_prepared_harness("codex").await;
20204 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20205 .await
20206 .unwrap_err();
20207 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20208 assert_no_session_state(&shared);
20209 drop(shared);
20210 drop(runtime);
20211 std::fs::remove_dir_all(root).unwrap();
20212 }
20213
20214 #[tokio::test]
20215 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20216 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20217 shared.native_launch_profile_control = None;
20218 let prepared = characterization_prepared_harness("claude").await;
20219 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20220 .await
20221 .unwrap_err();
20222 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20223 assert_no_session_state(&shared);
20224 drop(shared);
20225 drop(runtime);
20226 std::fs::remove_dir_all(root).unwrap();
20227 }
20228}
20229
20230#[cfg(all(test, windows))]
20231mod tests {
20232 use super::*;
20233 use gate4agent_types::ProviderActivity;
20234 use crate::session_environment::{
20235 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20236 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20237 NodeSessionPathClass,
20238 };
20239 use gate4agent_catalog::EnvMutation;
20240 use gate4agent_runtime_native::{
20241 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20242 NativeLaunchProfile, NativeLaunchProfileId,
20243 };
20244 use std::ffi::OsString;
20245 use std::sync::Arc;
20246
20247 struct EmptyEnvironmentResolver;
20248
20249 struct FixtureSecretResolver {
20250 deny: Arc<AtomicBool>,
20251 }
20252
20253 impl NodeSecretResolver for FixtureSecretResolver {
20254 fn resolve(
20255 &self,
20256 _reference: &NodeSecretReference,
20257 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20258 if self.deny.load(Ordering::Acquire) {
20259 Err(NodeSecretResolveError::Denied)
20260 } else {
20261 NodeSecretValue::text("fixture-secret")
20262 .map_err(|_| NodeSecretResolveError::Unavailable)
20263 }
20264 }
20265 }
20266
20267 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20268 fn resolve_child_environment(
20269 &self,
20270 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20271 Ok(vec![EnvMutation {
20272 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20273 value: None,
20274 }])
20275 }
20276 }
20277
20278 fn agent(value: &str) -> AgentId {
20279 AgentId::new(value).unwrap()
20280 }
20281
20282 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20283 let catalog = active_registry().unwrap();
20284 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20285 let workspace = WorkspaceConfig::new(
20286 WorkspaceId::new("primary").unwrap(),
20287 std::env::current_dir().unwrap(),
20288 )
20289 .unwrap()
20290 .with_worktree_service_mode(mode);
20291 NodeShared::new(
20292 handle,
20293 "fixture-token".to_owned(),
20294 NodeId::new("node-terminal-test").unwrap(),
20295 vec![workspace],
20296 vec![agent("claude")],
20297 )
20298 }
20299
20300 fn terminal_test_shared() -> NodeShared {
20301 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20302 }
20303
20304 #[cfg(feature = "fixture")]
20305 #[test]
20306 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20307 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20308 assert_eq!(probe.latest_code(), None);
20309
20310 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20311 assert_eq!(
20312 probe.latest_code(),
20313 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20314 );
20315
20316 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20317 assert_eq!(
20318 probe.latest_code(),
20319 Some(NodeFailureCode::SpawnDeadlineExceeded),
20320 );
20321
20322 probe.record(None);
20323 assert_eq!(probe.latest_code(), None);
20324 }
20325
20326 #[test]
20327 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20328 let source = temporary_workspace_root("launch-inventory-source");
20329 let allocation = temporary_workspace_root("launch-inventory-allocation");
20330 std::fs::create_dir_all(&source).unwrap();
20331 std::fs::create_dir_all(&allocation).unwrap();
20332 let workspace_id = WorkspaceId::new("primary").unwrap();
20333 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20334 .unwrap()
20335 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20336 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20337 workspace = workspace
20338 .with_managed_worktree_profile(
20339 ManagedWorktreeProfile::new(
20340 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20341 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20342 &allocation,
20343 "gate4agent",
20344 "HEAD",
20345 ManagedWorktreeRetention::RemoveWhenReleased,
20346 )
20347 .unwrap(),
20348 )
20349 .unwrap();
20350 }
20351 let overflow = workspace.clone().with_managed_worktree_profile(
20352 ManagedWorktreeProfile::new(
20353 WorktreeProfileId::new("overflow").unwrap(),
20354 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20355 &allocation,
20356 "gate4agent",
20357 "HEAD",
20358 ManagedWorktreeRetention::Retain,
20359 )
20360 .unwrap(),
20361 );
20362 assert!(matches!(
20363 overflow,
20364 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20365 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20366 ..
20367 }),
20368 ));
20369
20370 let catalog = active_registry().unwrap();
20371 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20372 let shared = NodeShared::new(
20373 handle,
20374 "fixture-token".to_owned(),
20375 NodeId::new("node-launch-inventory").unwrap(),
20376 vec![workspace],
20377 vec![agent("claude")],
20378 );
20379 let snapshot = shared.snapshot();
20380 let launch = snapshot.launch_inventory.as_ref().unwrap();
20381 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20382 assert!(launch.bundles.as_ref().unwrap().is_empty());
20383 let profiles = snapshot.workspaces[0]
20384 .managed_worktree_profiles
20385 .as_ref()
20386 .unwrap();
20387 assert_eq!(
20388 snapshot.workspaces[0].worktree_service_mode,
20389 Some(WorktreeServiceMode::Managed),
20390 );
20391 assert_eq!(
20392 profiles.profiles.len(),
20393 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20394 );
20395 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20396 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20397 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20398 }
20399 let legacy = snapshot_for_wire(
20400 &shared,
20401 true,
20402 true,
20403 false,
20404 false,
20405 true,
20406 false,
20407 true,
20408 false,
20409 true,
20410 );
20411 assert!(legacy.launch_inventory.is_none());
20412 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20413
20414 drop(shared);
20415 std::fs::remove_dir_all(source).unwrap();
20416 std::fs::remove_dir_all(allocation).unwrap();
20417 }
20418
20419 #[test]
20420 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20421 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20422 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20423 }));
20424 let source = gate4agent_types::ProviderSource {
20425 family: AdapterFamily::PtySemantic,
20426 binding: AdapterBinding::new(
20427 gate4agent_types::AdapterId::new("claude").unwrap(),
20428 "fixture/v1",
20429 gate4agent_types::AdapterVerification::SyntheticFixture,
20430 )
20431 .unwrap(),
20432 };
20433 let provider = ProviderSnapshot {
20434 sequence: 17,
20435 session: Some(ProviderSessionIdentity {
20436 key: ProviderSessionKey::SessionId,
20437 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20438 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20439 }),
20440 model: Some("PRIVATE_MODEL".to_owned()),
20441 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20442 completed_turns: 3,
20443 usage: gate4agent_types::TokenUsage {
20444 input_tokens: 10,
20445 output_tokens: 20,
20446 cache_read_tokens: 30,
20447 cache_write_tokens: 40,
20448 reasoning_tokens: 50,
20449 context_window: Some(200_000),
20450 },
20451 lead_activity: ProviderActivity::WaitingForInput,
20452 activity: ProviderActivity::WaitingForInput,
20453 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20454 active_tools: (0..9)
20455 .map(|index| gate4agent_types::ActiveProviderTool {
20456 id: format!("private-tool-id-{index}"),
20457 name: if index == 0 {
20458 format!(" Read\u{0000}{}", "x".repeat(80))
20459 } else {
20460 format!("Tool-{index}")
20461 },
20462 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20463 })
20464 .collect(),
20465 interactions: vec![gate4agent_types::ProviderInteraction {
20466 id: gate4agent_types::ProviderInteractionId(17),
20467 source: source.clone(),
20468 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20469 interaction_kind: ProviderInteractionKind::Approval,
20470 tool_name: " Approve\u{0007}Tool ".to_owned(),
20471 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20472 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20473 resume_lead_activity: Some(ProviderActivity::Working),
20474 status: ProviderInteractionStatus::Pending,
20475 }],
20476 subagents: vec![gate4agent_types::ProviderSubagent {
20477 source: source.clone(),
20478 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20479 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20480 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20481 }],
20482 sources: vec![gate4agent_types::ProviderSourceCursor {
20483 source,
20484 sequence: 17,
20485 gap_count: 2,
20486 stale: true,
20487 }],
20488 last_event: Some(ProviderEvent::Error {
20489 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20490 }),
20491 gap_count: 2,
20492 stale: true,
20493 };
20494 let address = terminal_address(3);
20495 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20496 assert_eq!(entry.address, address);
20497 assert_eq!(entry.progress.provider_sequence, 17);
20498 assert_eq!(entry.progress.active_tool_count, 9);
20499 assert_eq!(
20500 entry.progress.active_tool_labels.len(),
20501 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20502 );
20503 assert_eq!(entry.progress.subagent_count, 1);
20504 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20505 assert!(entry.progress.truncated);
20506 let encoded = serde_json::to_string(&entry).unwrap();
20507 for forbidden in [
20508 "PRIVATE_PROVIDER_SESSION",
20509 "PRIVATE_PROVIDER_PATH",
20510 "PRIVATE_MODEL",
20511 "PRIVATE_TOOL_INVENTORY",
20512 "PRIVATE_CURRENT_PROMPT",
20513 "PRIVATE_TOOL_INPUT_JSON",
20514 "PRIVATE_PROVIDER_REQUEST",
20515 "PRIVATE_INTERACTION_PROMPT",
20516 "PRIVATE_INTERACTION_AGENT",
20517 "PRIVATE_SUBAGENT_ID",
20518 "PRIVATE_SUBAGENT_TYPE",
20519 "PRIVATE_SUBAGENT_DESCRIPTION",
20520 "PRIVATE_PROVIDER_ERROR",
20521 "input_json",
20522 "current_prompt",
20523 "session_id",
20524 ] {
20525 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20526 }
20527
20528 let snapshot = NodeSnapshot {
20529 node_id: NodeId::new("node-progress-test").unwrap(),
20530 enabled_providers: vec![agent("claude")],
20531 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20532 workspaces: Vec::new(),
20533 session_records: Vec::new(),
20534 managed_worktrees: Vec::new(),
20535 launch_inventory: None,
20536 agent_progress: vec![entry],
20537 };
20538 let mut legacy_reply = ResponseEnvelope {
20539 request_id: 1,
20540 result: Ok(NodeResponse::Snapshot {
20541 event_sequence: 0,
20542 controller: None,
20543 snapshot: snapshot.clone(),
20544 }),
20545 };
20546 project_response_without_agent_progress(&mut legacy_reply);
20547 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20548 panic!("agent progress projection changed response kind");
20549 };
20550 assert!(legacy.agent_progress.is_empty());
20551 assert_eq!(snapshot.agent_progress.len(), 1);
20552 }
20553
20554 fn terminal_address(generation: u64) -> SessionAddress {
20555 SessionAddress {
20556 workspace_id: WorkspaceId::new("primary").unwrap(),
20557 session: SessionKey {
20558 instance_id: AgentInstanceId(41),
20559 generation: SessionGeneration(generation),
20560 },
20561 }
20562 }
20563
20564 fn spawn_spec_fixture() -> SpawnSpec {
20565 SpawnSpec {
20566 target: crate::protocol::SpawnTarget {
20567 node_id: NodeId::new("node-terminal-test").unwrap(),
20568 workspace_id: WorkspaceId::new("primary").unwrap(),
20569 worktree_id: None,
20570 },
20571 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20572 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20573 .unwrap(),
20574 overrides: crate::protocol::SpawnOverrides::default(),
20575 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20576 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20577 required_capabilities: SpawnRequiredCapabilities::default(),
20578 }
20579 }
20580
20581 fn environment_profile_test_shared(
20582 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20583 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20584 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20585 "local-claude-r1",
20586 )
20587 .unwrap();
20588 let spawn_profiles = SpawnProfileRegistry::new([
20589 crate::protocol::SpawnProfileDefaults {
20590 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20591 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20592 provider: agent("claude"),
20593 mode: SessionMode::Pty,
20594 terminal_size: gate4agent_types::TerminalSize {
20595 rows: 24,
20596 columns: 80,
20597 },
20598 prompt: None,
20599 bundle_id: None,
20600 context_id: None,
20601 environment_profile_id: Some(profile_id.clone()),
20602 },
20603 ])
20604 .unwrap();
20605 let catalog = active_registry().unwrap();
20606 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20607 let control = runtime.native_launch_profile_control();
20608 let node_profile = NodeEnvironmentProfile::new(
20609 profile_id.clone(),
20610 profile_revision.clone(),
20611 agent("claude"),
20612 [NativeLaunchProfile::new(
20613 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20614 agent("claude"),
20615 TransportKind::Pty,
20616 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20617 Arc::new(EmptyEnvironmentResolver),
20618 )
20619 .unwrap()],
20620 )
20621 .unwrap();
20622 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20623 for native_profile in native_profiles {
20624 runtime.upsert_native_launch_profile(native_profile).unwrap();
20625 }
20626 let workspace = WorkspaceConfig::new(
20627 WorkspaceId::new("primary").unwrap(),
20628 std::env::current_dir().unwrap(),
20629 )
20630 .unwrap();
20631 let shared = NodeShared::new_with_incarnation(
20632 handle,
20633 "fixture-token".to_owned(),
20634 NodeId::new("node-terminal-test").unwrap(),
20635 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20636 vec![workspace],
20637 vec![agent("claude")],
20638 ProviderRuntimeStatuses::default(),
20639 None,
20640 Vec::new(),
20641 Vec::new(),
20642 spawn_profiles,
20643 Some(control),
20644 None,
20645 None,
20646 None,
20647 Vec::new(),
20648 Vec::new(),
20649 Vec::new(),
20650 Vec::new(),
20651 None,
20652 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20653 );
20654 shared
20655 .environment_profiles
20656 .write()
20657 .unwrap_or_else(|poisoned| poisoned.into_inner())
20658 .insert(binding.id.clone(), binding);
20659 (
20660 shared,
20661 runtime,
20662 ResolvedEnvironmentProfileReceipt {
20663 profile_id,
20664 profile_revision,
20665 network_allowlist: None,
20666 browser_profile_id: None,
20667 },
20668 )
20669 }
20670
20671 fn materialization_test_shared(
20672 deny_secret: bool,
20673 ) -> (
20674 NodeShared,
20675 NativeRuntime,
20676 ResolvedEnvironmentProfileReceipt,
20677 PathBuf,
20678 Arc<AtomicBool>,
20679 ) {
20680 let workspace = WorkspaceConfig::new(
20681 WorkspaceId::new("primary").unwrap(),
20682 std::env::current_dir().unwrap(),
20683 )
20684 .unwrap();
20685 materialization_test_shared_with_workspace(deny_secret, workspace)
20686 }
20687
20688 fn materialization_test_shared_with_workspace(
20689 deny_secret: bool,
20690 workspace: WorkspaceConfig,
20691 ) -> (
20692 NodeShared,
20693 NativeRuntime,
20694 ResolvedEnvironmentProfileReceipt,
20695 PathBuf,
20696 Arc<AtomicBool>,
20697 ) {
20698 let root = temporary_workspace_root(if deny_secret {
20699 "materialization-denied"
20700 } else {
20701 "materialization-ready"
20702 });
20703 std::fs::create_dir_all(&root).unwrap();
20704 let materialization_root = root.join("session-environments");
20705 let deny = Arc::new(AtomicBool::new(deny_secret));
20706 let resolver = Arc::new(FixtureSecretResolver {
20707 deny: Arc::clone(&deny),
20708 });
20709 let materializer = SessionEnvironmentMaterializer::new(
20710 materialization_root,
20711 resolver,
20712 )
20713 .unwrap();
20714 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20715 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20716 "materialized-claude-r1",
20717 )
20718 .unwrap();
20719 let materialization = NodeSessionMaterializationProfile::new(
20720 vec![NodeSessionEnvironmentMutation::SetSecret {
20721 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20722 reference: NodeSecretReference::new("fixture-token").unwrap(),
20723 }],
20724 vec![NodeSessionPathBinding::new(
20725 "GATE4AGENT_PROVIDER_HOME",
20726 NodeSessionPathClass::ProviderHome,
20727 )
20728 .unwrap()],
20729 vec![NodeSessionFile::secret(
20730 NodeSessionPathClass::Config,
20731 "auth/token",
20732 NodeSecretReference::new("fixture-token").unwrap(),
20733 )
20734 .unwrap()],
20735 )
20736 .unwrap();
20737 let catalog = active_registry().unwrap();
20738 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20739 let control = runtime.native_launch_profile_control();
20740 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20741 profile_id.clone(),
20742 profile_revision.clone(),
20743 agent("claude"),
20744 [NativeLaunchProfile::new(
20745 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20746 agent("claude"),
20747 TransportKind::Pty,
20748 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20749 Arc::new(EmptyEnvironmentResolver),
20750 )
20751 .unwrap()],
20752 Some(materialization),
20753 )
20754 .unwrap();
20755 let (binding, native_profiles, materialization) = node_profile.into_parts();
20756 for native_profile in native_profiles {
20757 runtime.upsert_native_launch_profile(native_profile).unwrap();
20758 }
20759 let shared = NodeShared::new_with_incarnation(
20760 handle,
20761 "fixture-token".to_owned(),
20762 NodeId::new("node-materialization-test").unwrap(),
20763 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20764 vec![workspace],
20765 vec![agent("claude")],
20766 ProviderRuntimeStatuses::default(),
20767 None,
20768 Vec::new(),
20769 Vec::new(),
20770 SpawnProfileRegistry::default(),
20771 Some(control),
20772 None,
20773 Some(materializer),
20774 None,
20775 Vec::new(),
20776 Vec::new(),
20777 Vec::new(),
20778 Vec::new(),
20779 None,
20780 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20781 );
20782 shared
20783 .environment_profiles
20784 .write()
20785 .unwrap_or_else(|poisoned| poisoned.into_inner())
20786 .insert(binding.id.clone(), binding);
20787 shared
20788 .environment_materialization_profiles
20789 .write()
20790 .unwrap_or_else(|poisoned| poisoned.into_inner())
20791 .insert(profile_id.clone(), materialization.unwrap());
20792 (
20793 shared,
20794 runtime,
20795 ResolvedEnvironmentProfileReceipt {
20796 profile_id,
20797 profile_revision,
20798 network_allowlist: None,
20799 browser_profile_id: None,
20800 },
20801 root,
20802 deny,
20803 )
20804 }
20805
20806 #[cfg(feature = "fixture")]
20807 #[derive(Clone, Copy)]
20808 enum CodexHomeProfile {
20809 Missing,
20810 WrongClass,
20811 Exact,
20812 }
20813
20814 #[cfg(feature = "fixture")]
20815 fn codex_bundle_test_shared(
20816 home_profile: CodexHomeProfile,
20817 ) -> (
20818 NodeShared,
20819 NativeRuntime,
20820 ResolvedEnvironmentProfileReceipt,
20821 NodeBundle,
20822 PathBuf,
20823 ) {
20824 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20825 const CLAUDE_MANIFEST: &[u8] =
20826 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20827 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20828 const DIGEST: &str =
20829 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20830
20831 let (shared, mut runtime, _claude_receipt, root, _deny) =
20832 materialization_test_shared(false);
20833 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20834 CodexHomeProfile::Missing => "codex-home-missing",
20835 CodexHomeProfile::WrongClass => "codex-home-wrong",
20836 CodexHomeProfile::Exact => "codex-home-exact",
20837 })
20838 .unwrap();
20839 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20840 "codex-home-r1",
20841 )
20842 .unwrap();
20843 let materialization = match home_profile {
20844 CodexHomeProfile::Missing => None,
20845 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20846 Some(NodeSessionMaterializationProfile::new(
20847 Vec::new(),
20848 vec![NodeSessionPathBinding::new(
20849 "CODEX_HOME",
20850 if matches!(home_profile, CodexHomeProfile::Exact) {
20851 NodeSessionPathClass::ProviderHome
20852 } else {
20853 NodeSessionPathClass::Config
20854 },
20855 )
20856 .unwrap()],
20857 Vec::new(),
20858 )
20859 .unwrap())
20860 }
20861 };
20862 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20863 profile_id.clone(),
20864 profile_revision.clone(),
20865 agent("codex"),
20866 [NativeLaunchProfile::new(
20867 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20868 agent("codex"),
20869 TransportKind::Pty,
20870 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20871 Arc::new(EmptyEnvironmentResolver),
20872 )
20873 .unwrap()],
20874 materialization,
20875 )
20876 .unwrap();
20877 let (binding, native_profiles, materialization) = node_profile.into_parts();
20878 for native_profile in native_profiles {
20879 runtime.upsert_native_launch_profile(native_profile).unwrap();
20880 }
20881 shared
20882 .environment_profiles
20883 .write()
20884 .unwrap_or_else(|poisoned| poisoned.into_inner())
20885 .insert(binding.id.clone(), binding);
20886 if let Some(materialization) = materialization {
20887 shared
20888 .environment_materialization_profiles
20889 .write()
20890 .unwrap_or_else(|poisoned| poisoned.into_inner())
20891 .insert(profile_id.clone(), materialization);
20892 }
20893
20894 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20895 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20896 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20897 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20898 std::fs::write(
20899 bundle_source.join(".claude-plugin/plugin.json"),
20900 CLAUDE_MANIFEST,
20901 )
20902 .unwrap();
20903 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20904 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20905 let bundle = NodeBundle::new(
20906 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20907 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20908 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20909 &bundle_source,
20910 )
20911 .unwrap();
20912 *shared
20913 .bundle_catalog
20914 .write()
20915 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20916 BundleCatalog::new([bundle.clone()]).unwrap();
20917 (
20918 shared,
20919 runtime,
20920 ResolvedEnvironmentProfileReceipt {
20921 profile_id,
20922 profile_revision,
20923 network_allowlist: None,
20924 browser_profile_id: None,
20925 },
20926 bundle,
20927 root,
20928 )
20929 }
20930
20931 #[cfg(feature = "fixture")]
20932 #[test]
20933 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20934 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20935 let (shared, runtime, receipt, bundle, root) =
20936 codex_bundle_test_shared(home_profile);
20937 let error = shared
20938 .bundle_layout(
20939 &agent("codex"),
20940 SessionMode::Pty,
20941 Some(&receipt),
20942 &bundle,
20943 )
20944 .unwrap_err();
20945 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20946 assert!(shared
20947 .materializations
20948 .lock()
20949 .unwrap_or_else(|poisoned| poisoned.into_inner())
20950 .is_empty());
20951 let materialization_root = root.join("session-environments");
20952 assert!(!std::fs::read_dir(&materialization_root)
20953 .unwrap()
20954 .filter_map(Result::ok)
20955 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20956 drop(shared);
20957 drop(runtime);
20958 let _ = std::fs::remove_dir_all(root);
20959 }
20960 }
20961
20962 #[cfg(feature = "fixture")]
20963 #[test]
20964 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20965 let (shared, runtime, receipt, bundle, root) =
20966 codex_bundle_test_shared(CodexHomeProfile::Exact);
20967 let address = SessionAddress {
20968 workspace_id: WorkspaceId::new("primary").unwrap(),
20969 session: SessionKey {
20970 instance_id: AgentInstanceId(310),
20971 generation: SessionGeneration(1),
20972 },
20973 };
20974 let (overlay, guard) = shared
20975 .prepare_session_materialization(
20976 &address,
20977 &agent("codex"),
20978 SessionMode::Pty,
20979 Some(&receipt),
20980 Some(&bundle.receipt()),
20981 None,
20982 None,
20983 )
20984 .unwrap()
20985 .unwrap();
20986 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20987 let ownership = shared
20988 .materializations
20989 .lock()
20990 .unwrap_or_else(|poisoned| poisoned.into_inner())
20991 .get(guard.id().unwrap())
20992 .unwrap()
20993 .clone();
20994 let (profile, _, _) = shared
20995 .materialization_profile(
20996 &agent("codex"),
20997 SessionMode::Pty,
20998 Some(&receipt),
20999 Some(&bundle.receipt()),
21000 None,
21001 )
21002 .unwrap()
21003 .unwrap();
21004 let environment = shared
21005 .session_environment_materializer
21006 .as_ref()
21007 .unwrap()
21008 .resolve_environment(&ownership, &profile)
21009 .unwrap();
21010 assert!(environment.iter().any(|mutation| {
21011 mutation.key == OsString::from("CODEX_HOME")
21012 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21013 }));
21014 assert_eq!(
21015 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21016 .unwrap(),
21017 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21018 );
21019 assert!(std::fs::read_dir(ownership.bundle_root())
21020 .unwrap()
21021 .next()
21022 .is_none());
21023 drop(guard);
21024 drop(shared);
21025 drop(runtime);
21026 let _ = std::fs::remove_dir_all(root);
21027 }
21028
21029 #[cfg(feature = "fixture")]
21030 #[test]
21031 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21032 let (shared, runtime, receipt, bundle, root) =
21033 codex_bundle_test_shared(CodexHomeProfile::Exact);
21034 let bundle_receipt = bundle.receipt();
21035 let address = SessionAddress {
21036 workspace_id: WorkspaceId::new("primary").unwrap(),
21037 session: SessionKey {
21038 instance_id: AgentInstanceId(311),
21039 generation: SessionGeneration(1),
21040 },
21041 };
21042 let (_overlay, guard) = shared
21043 .prepare_session_materialization(
21044 &address,
21045 &agent("codex"),
21046 SessionMode::Pty,
21047 Some(&receipt),
21048 Some(&bundle_receipt),
21049 None,
21050 None,
21051 )
21052 .unwrap()
21053 .unwrap();
21054 let materialization_id = guard.id().unwrap().clone();
21055 let skill_path = shared
21056 .materializations
21057 .lock()
21058 .unwrap_or_else(|poisoned| poisoned.into_inner())
21059 .get(&materialization_id)
21060 .unwrap()
21061 .provider_home()
21062 .join("skills/review-code/SKILL.md");
21063 let record_id = shared
21064 .bind_spawn_session_with_materialization(
21065 &address,
21066 agent("codex"),
21067 SessionMode::Pty,
21068 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21069 SpawnRecordPolicy::ProviderIdentityOnly,
21070 Some(receipt.clone()),
21071 Some(bundle_receipt.clone()),
21072 None,
21073 Some(materialization_id.clone()),
21074 )
21075 .unwrap()
21076 .unwrap();
21077 guard.retain();
21078 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21079
21080 let error = match shared.resolve_record_materialization(
21081 &record_id,
21082 &agent("codex"),
21083 SessionMode::Pty,
21084 Some(&receipt),
21085 Some(&bundle_receipt),
21086 None,
21087 ) {
21088 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21089 Err(error) => error,
21090 };
21091 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21092 assert_eq!(
21093 shared
21094 .materializations
21095 .lock()
21096 .unwrap_or_else(|poisoned| poisoned.into_inner())
21097 .get(&materialization_id)
21098 .unwrap()
21099 .state(),
21100 MaterializationState::RecoveryRequired,
21101 );
21102 drop(shared);
21103 drop(runtime);
21104 let _ = std::fs::remove_dir_all(root);
21105 }
21106
21107 fn bind_record_owned_materialization_fixture(
21108 shared: &NodeShared,
21109 receipt: &ResolvedEnvironmentProfileReceipt,
21110 instance_id: u64,
21111 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21112 let address = SessionAddress {
21113 workspace_id: WorkspaceId::new("primary").unwrap(),
21114 session: SessionKey {
21115 instance_id: AgentInstanceId(instance_id),
21116 generation: SessionGeneration(1),
21117 },
21118 };
21119 let (overlay, materialization_guard) = shared
21120 .prepare_session_materialization(
21121 &address,
21122 &agent("claude"),
21123 SessionMode::Pty,
21124 Some(receipt),
21125 None,
21126 None,
21127 None,
21128 )
21129 .unwrap()
21130 .unwrap();
21131 let materialization_id = materialization_guard.id().unwrap().clone();
21132 let materialization_root = shared
21133 .materializations
21134 .lock()
21135 .unwrap_or_else(|poisoned| poisoned.into_inner())
21136 .get(&materialization_id)
21137 .unwrap()
21138 .root()
21139 .to_path_buf();
21140 let selection = shared
21141 .select_environment_profile(
21142 address.session.instance_id,
21143 &agent("claude"),
21144 SessionMode::Pty,
21145 Some(receipt),
21146 )
21147 .unwrap()
21148 .unwrap();
21149 assert!(shared
21150 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21151 .unwrap()
21152 .is_none());
21153 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21154 let record_id = shared
21155 .bind_spawn_session_with_materialization(
21156 &address,
21157 agent("claude"),
21158 SessionMode::Pty,
21159 policy,
21160 SpawnRecordPolicy::ProviderIdentityOnly,
21161 Some(receipt.clone()),
21162 None,
21163 None,
21164 Some(materialization_id.clone()),
21165 )
21166 .unwrap()
21167 .unwrap();
21168 selection.retain();
21169 materialization_guard.retain();
21170 (address, record_id, materialization_id, materialization_root)
21171 }
21172
21173 #[test]
21174 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21175 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21176 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21177 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21178 }));
21179 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21180 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21181 let mut spec = spawn_spec_fixture();
21182 spec.expected_profile_revision =
21183 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21184 assert!(matches!(
21185 &spec.overrides.environment_profile_id,
21186 crate::protocol::SpawnOverride::Inherit,
21187 ));
21188 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21189 .requires_child_environment_profile_capability());
21190 assert!(request_requires_child_environment_profile(
21191 &shared,
21192 &NodeRequest::SpawnSpec { spec: spec.clone() },
21193 ));
21194
21195 let address = terminal_address(1);
21196 shared.bind_session_with_policy(
21197 &address,
21198 ProviderRuntimePolicy::raw_pty(),
21199 Some(environment_profile.clone()),
21200 );
21201 assert!(request_requires_child_environment_profile(
21202 &shared,
21203 &NodeRequest::Input {
21204 session: address.clone(),
21205 text: "x".to_owned(),
21206 },
21207 ));
21208
21209 let mut record = record("claude", "profile-record");
21210 record.active_session = Some(address.clone());
21211 record.environment_profile = Some(environment_profile.clone());
21212 let record_id = record.record_id.clone();
21213 shared.insert_record(record.clone()).unwrap();
21214 assert!(request_requires_child_environment_profile(
21215 &shared,
21216 &NodeRequest::ResumeSessionRecord {
21217 record_id,
21218 terminal_size: gate4agent_types::TerminalSize {
21219 rows: 24,
21220 columns: 80,
21221 },
21222 initial_prompt: None,
21223 },
21224 ));
21225
21226 let snapshot = snapshot_for_wire(
21227 &shared, false, true, true, true, false, false, false, false, true,
21228 );
21229 assert_eq!(snapshot.session_records.len(), 1);
21230 assert!(snapshot.session_records[0].environment_profile.is_none());
21231 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21232 sequence: 1,
21233 event: NodeEvent::SessionRecordUpserted { record },
21234 });
21235 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21236 panic!("record projection changed the event kind");
21237 };
21238 assert!(record.environment_profile.is_none());
21239
21240 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21241 let mut reply = ResponseEnvelope {
21242 request_id: 1,
21243 result: Ok(NodeResponse::SpawnSpecAccepted {
21244 receipt: resolved.receipt_with_environment(
21245 shared.incarnation_id,
21246 address,
21247 Some(environment_profile),
21248 ),
21249 }),
21250 };
21251 project_response_without_child_environment_profile(&mut reply);
21252 assert_eq!(
21253 reply.result.unwrap_err().code,
21254 NodeFailureCode::UnsupportedCapability,
21255 );
21256 }
21257
21258 #[test]
21259 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21260 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21261 let snapshot = shared.snapshot();
21262 let profiles = snapshot
21263 .launch_inventory
21264 .as_ref()
21265 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21266 .unwrap();
21267 assert_eq!(profiles.len(), 1);
21268 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21269 assert!(snapshot.requires_child_environment_profile_capability());
21270
21271 let mut legacy = snapshot.clone();
21272 clear_snapshot_child_environment_profiles(&mut legacy);
21273 assert!(legacy
21274 .launch_inventory
21275 .as_ref()
21276 .unwrap()
21277 .spawn_profiles
21278 .as_ref()
21279 .unwrap()[0]
21280 .environment_profile
21281 .is_none());
21282 assert!(!legacy.requires_child_environment_profile_capability());
21283
21284 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21285 let bindings = shared
21286 .environment_profiles
21287 .read()
21288 .unwrap_or_else(|poisoned| poisoned.into_inner())
21289 .clone();
21290 let mut provider_mismatch = bindings.clone();
21291 provider_mismatch
21292 .values_mut()
21293 .next()
21294 .unwrap()
21295 .provider = agent("codex");
21296 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21297 let mut mode_mismatch = profile;
21298 mode_mismatch.mode = SessionMode::Inline;
21299 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21300
21301 shared
21302 .environment_profiles
21303 .write()
21304 .unwrap_or_else(|poisoned| poisoned.into_inner())
21305 .clear();
21306 assert!(shared
21307 .snapshot()
21308 .launch_inventory
21309 .unwrap()
21310 .spawn_profiles
21311 .unwrap()
21312 .is_empty());
21313 }
21314
21315 #[test]
21316 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21317 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21318 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21319 }));
21320 let shared = terminal_test_shared();
21321 let bundle = ResolvedBundleReceipt {
21322 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21323 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21324 digest: crate::protocol::SpawnBundleDigest::new(format!(
21325 "sha256:{}",
21326 "0".repeat(64),
21327 ))
21328 .unwrap(),
21329 };
21330 let mut bundled = record("claude", "bundle-record");
21331 bundled.bundle = Some(bundle.clone());
21332 let record_id = bundled.record_id.clone();
21333 shared.insert_record(bundled.clone()).unwrap();
21334 assert!(request_requires_session_bundle(
21335 &shared,
21336 &NodeRequest::ForgetSessionRecord {
21337 record_id: record_id.clone(),
21338 },
21339 ));
21340
21341 let snapshot = snapshot_for_wire(
21342 &shared, false, true, true, true, true, false, false, false, true,
21343 );
21344 assert!(snapshot.session_records[0].bundle.is_none());
21345 let launch = snapshot.launch_inventory.as_ref().unwrap();
21346 assert!(launch.spawn_profiles.is_some());
21347 assert!(launch.bundles.is_none());
21348 let event = project_event_without_session_bundle(NodeEventEnvelope {
21349 sequence: 1,
21350 event: NodeEvent::SessionRecordUpserted { record: bundled },
21351 });
21352 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21353 panic!("record projection changed the event kind");
21354 };
21355 assert!(record.bundle.is_none());
21356
21357 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21358 let mut reply = ResponseEnvelope {
21359 request_id: 1,
21360 result: Ok(NodeResponse::SpawnSpecAccepted {
21361 receipt: resolved.receipt_with_materialization(
21362 shared.incarnation_id,
21363 terminal_address(17),
21364 None,
21365 Some(bundle),
21366 None,
21367 ),
21368 }),
21369 };
21370 project_response_without_session_bundle(&mut reply);
21371 assert_eq!(
21372 reply.result.unwrap_err().code,
21373 NodeFailureCode::UnsupportedCapability,
21374 );
21375 }
21376
21377 #[test]
21378 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21379 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21380 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21381 }));
21382 let context_id = SpawnContextId::new("context-review").unwrap();
21383 let context = ResolvedContextPackReceipt {
21384 id: context_id.clone(),
21385 digest: crate::protocol::SpawnContextDigest::new(format!(
21386 "sha256:{}",
21387 "c".repeat(64),
21388 ))
21389 .unwrap(),
21390 lineage: ContextPackLineageReceipt {
21391 source_node_id: NodeId::new("node-source").unwrap(),
21392 source_session: terminal_address(1),
21393 source_provider: agent("kimi"),
21394 },
21395 source_message_count: 2,
21396 retained_message_count: 2,
21397 byte_len: 256,
21398 truncated: false,
21399 };
21400 assert!(context.is_valid());
21401 let mut bound_record = record("codex", "context-record");
21402 bound_record.context_id = Some(context_id);
21403 bound_record.context = Some(context.clone());
21404 let session = gate4agent_types::SessionSnapshot {
21405 instance_id: AgentInstanceId(91),
21406 agent_id: agent("kimi"),
21407 transport: TransportKind::Pty,
21408 generation: SessionGeneration(3),
21409 status: SessionStatus::Running,
21410 pending_operation: None,
21411 pending_input: None,
21412 process_id: Some(9001),
21413 terminal_size: None,
21414 terminal_frame: None,
21415 terminal_stale: None,
21416 session_options: None,
21417 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21418 history: gate4agent_types::HistorySnapshot {
21419 pending: None,
21420 candidates: vec![HistoryCandidateSummary {
21421 id: "candidate-opaque".to_owned(),
21422 session_id_hint: "session-hint".to_owned(),
21423 modified_at_unix_ms: Some(1),
21424 }],
21425 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21426 loaded: Some(HistorySessionRecord {
21427 session_id: "private-vendor-session".to_owned(),
21428 title: Some("private title".to_owned()),
21429 cwd: Some(r"C:\private\history-root".to_owned()),
21430 model: Some("private-model".to_owned()),
21431 message_count: 2,
21432 completed_turn_count: None,
21433 total_tokens: 19,
21434 messages: vec![
21435 gate4agent_types::HistoryMessageRecord {
21436 role: gate4agent_types::HistoryMessageRole::User,
21437 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21438 },
21439 gate4agent_types::HistoryMessageRecord {
21440 role: gate4agent_types::HistoryMessageRole::Assistant,
21441 text: "private answer".to_owned(),
21442 },
21443 ],
21444 }),
21445 last_error: None,
21446 },
21447 resume: gate4agent_types::ResumeSnapshot::default(),
21448 foreground: gate4agent_types::ForegroundSnapshot::default(),
21449 provider: gate4agent_types::ProviderSnapshot::default(),
21450 screen_state: gate4agent_types::PtyScreenState::default(),
21451 };
21452 let original = NodeSnapshot {
21453 node_id: NodeId::new("node-terminal-test").unwrap(),
21454 enabled_providers: vec![agent("kimi"), agent("codex")],
21455 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21456 workspaces: vec![WorkspaceSnapshot {
21457 workspace_id: WorkspaceId::new("primary").unwrap(),
21458 canonical_root: opaque_windows_path(
21459 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21460 ),
21461 sessions: vec![session],
21462 worktree_service_mode: None,
21463 managed_worktree_profiles: None,
21464 }],
21465 session_records: vec![bound_record],
21466 managed_worktrees: Vec::new(),
21467 launch_inventory: None,
21468 agent_progress: Vec::new(),
21469 };
21470
21471 let mut negotiated = original.clone();
21472 project_snapshot_history_for_wire(&mut negotiated, true);
21473 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21474 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21475 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21476 assert!(!serde_json::to_string(&negotiated)
21477 .unwrap()
21478 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21479
21480 let mut legacy = original;
21481 project_snapshot_history_for_wire(&mut legacy, false);
21482 clear_snapshot_context_packs(&mut legacy);
21483 assert_eq!(
21484 legacy.workspaces[0].sessions[0].history,
21485 gate4agent_types::HistorySnapshot::default(),
21486 );
21487 assert!(legacy.session_records[0].context_id.is_none());
21488 assert!(legacy.session_records[0].context.is_none());
21489 let encoded = serde_json::to_string(&legacy).unwrap();
21490 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21491 assert!(!encoded.contains("context-review"));
21492
21493 let mut reply = ResponseEnvelope {
21494 request_id: 7,
21495 result: Ok(NodeResponse::ContextPackExported { context }),
21496 };
21497 project_response_without_context_pack(&mut reply);
21498 assert_eq!(
21499 reply.result.unwrap_err().code,
21500 NodeFailureCode::UnsupportedCapability,
21501 );
21502 }
21503
21504 #[test]
21505 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21506 let shared = terminal_test_shared();
21507 let history = HistorySessionRecord {
21508 session_id: "fixture-history".to_owned(),
21509 title: None,
21510 cwd: None,
21511 model: None,
21512 message_count: 1,
21513 completed_turn_count: None,
21514 total_tokens: 0,
21515 messages: vec![gate4agent_types::HistoryMessageRecord {
21516 role: gate4agent_types::HistoryMessageRole::User,
21517 text: "bounded handoff".to_owned(),
21518 }],
21519 };
21520 let pack = NodeContextPack::export(
21521 ContextPackLineageReceipt {
21522 source_node_id: shared.node_id.clone(),
21523 source_session: terminal_address(1),
21524 source_provider: agent("claude"),
21525 },
21526 &history,
21527 )
21528 .unwrap();
21529 let context = shared
21530 .context_catalog
21531 .write()
21532 .unwrap_or_else(|poisoned| poisoned.into_inner())
21533 .insert(pack)
21534 .unwrap();
21535 let mut retained = record("codex", "context-retained-record");
21536 retained.context_id = Some(context.id.clone());
21537 retained.context = Some(context.clone());
21538 let record_id = retained.record_id.clone();
21539 shared.insert_record(retained).unwrap();
21540 assert_eq!(
21541 shared.forget_context_pack(&context.id).unwrap_err().code,
21542 NodeFailureCode::ContextPackBusy,
21543 );
21544 assert!(shared
21545 .context_catalog
21546 .read()
21547 .unwrap_or_else(|poisoned| poisoned.into_inner())
21548 .get(&context.id)
21549 .is_some());
21550 shared
21551 .session_records
21552 .lock()
21553 .unwrap_or_else(|poisoned| poisoned.into_inner())
21554 .records
21555 .remove(&record_id);
21556
21557 let mut spec = spawn_spec_fixture();
21558 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21559 value: context.id.clone(),
21560 };
21561 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21562 let receipt = resolved.receipt_with_materialization(
21563 shared.incarnation_id,
21564 terminal_address(2),
21565 None,
21566 None,
21567 Some(context.clone()),
21568 );
21569 assert!(receipt.context_binding_is_valid());
21570 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21571
21572 shared.forget_context_pack(&context.id).unwrap();
21573 assert!(shared
21574 .context_catalog
21575 .read()
21576 .unwrap_or_else(|poisoned| poisoned.into_inner())
21577 .get(&context.id)
21578 .is_none());
21579 assert!(shared
21580 .replay_spawn_spec(&spec, Instant::now())
21581 .unwrap()
21582 .is_none());
21583 }
21584
21585 #[test]
21586 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21587 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21588 let address = terminal_address(1);
21589 let selection = shared
21590 .select_environment_profile(
21591 address.session.instance_id,
21592 &agent("claude"),
21593 SessionMode::Pty,
21594 Some(&environment_profile),
21595 )
21596 .unwrap()
21597 .unwrap();
21598 shared.bind_session_with_policy(
21599 &address,
21600 ProviderRuntimePolicy::raw_pty(),
21601 Some(environment_profile.clone()),
21602 );
21603 selection.retain();
21604 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21605 assert!(matches!(
21606 runtime.remove_native_launch_profile(&native_id),
21607 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21608 ));
21609 assert!(shared.remove_binding(&address).is_some());
21610 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21611
21612 let mismatched = ResolvedEnvironmentProfileReceipt {
21613 profile_id: environment_profile.profile_id,
21614 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21615 "local-claude-r2",
21616 )
21617 .unwrap(),
21618 network_allowlist: None,
21619 browser_profile_id: None,
21620 };
21621 let error = match shared.select_environment_profile(
21622 AgentInstanceId(42),
21623 &agent("claude"),
21624 SessionMode::Pty,
21625 Some(&mismatched),
21626 ) {
21627 Ok(_) => panic!("mismatched environment profile revision was selected"),
21628 Err(error) => error,
21629 };
21630 assert_eq!(
21631 error.code,
21632 NodeFailureCode::EnvironmentProfileBindingMismatch,
21633 );
21634 assert!(!shared
21635 .session_bindings
21636 .lock()
21637 .unwrap_or_else(|poisoned| poisoned.into_inner())
21638 .contains_key(&AgentInstanceId(42)));
21639 }
21640
21641 #[test]
21642 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21643 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21644 let address = terminal_address(1);
21645 let selection = shared
21646 .select_environment_profile(
21647 address.session.instance_id,
21648 &agent("claude"),
21649 SessionMode::Pty,
21650 Some(&environment_profile),
21651 )
21652 .unwrap()
21653 .unwrap();
21654 let overlay = NativeInstanceLaunchOverlay::new(
21655 agent("claude"),
21656 TransportKind::Pty,
21657 vec![EnvMutation {
21658 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21659 value: Some(OsString::from("fixture-secret")),
21660 }],
21661 vec![
21662 OsString::from("--plugin-dir"),
21663 std::env::current_dir().unwrap().into_os_string(),
21664 ],
21665 )
21666 .unwrap();
21667 let instance_overlay = shared
21668 .install_prepared_launch_overlay(
21669 address.session.instance_id,
21670 PreparedNativeLaunchOverlay::Instance(overlay),
21671 )
21672 .unwrap()
21673 .unwrap();
21674 let bundle = ResolvedBundleReceipt {
21675 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21676 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21677 digest: crate::protocol::SpawnBundleDigest::new(format!(
21678 "sha256:{}",
21679 "0".repeat(64),
21680 ))
21681 .unwrap(),
21682 };
21683 shared.bind_session_with_materialization(
21684 &address,
21685 ProviderRuntimePolicy::raw_pty(),
21686 Some(environment_profile),
21687 Some(bundle),
21688 None,
21689 None,
21690 );
21691 selection.retain();
21692 instance_overlay.retain();
21693
21694 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21695 assert!(matches!(
21696 runtime.remove_native_launch_profile(&native_id),
21697 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21698 ));
21699 assert!(shared.remove_binding(&address).is_some());
21700 assert!(!shared
21701 .native_launch_profile_control
21702 .as_ref()
21703 .unwrap()
21704 .clear_native_instance_launch_overlay(address.session.instance_id));
21705 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21706 }
21707
21708 #[test]
21709 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21710 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21711 let address = SessionAddress {
21712 workspace_id: WorkspaceId::new("primary").unwrap(),
21713 session: SessionKey {
21714 instance_id: AgentInstanceId(201),
21715 generation: SessionGeneration(1),
21716 },
21717 };
21718 let error = match shared.prepare_session_materialization(
21719 &address,
21720 &agent("claude"),
21721 SessionMode::Pty,
21722 Some(&receipt),
21723 None,
21724 None,
21725 None,
21726 ) {
21727 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21728 Err(error) => error,
21729 };
21730 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21731 assert!(shared.handle.snapshot().sessions.is_empty());
21732 assert!(shared
21733 .session_bindings
21734 .lock()
21735 .unwrap_or_else(|poisoned| poisoned.into_inner())
21736 .is_empty());
21737 assert!(shared
21738 .materializations
21739 .lock()
21740 .unwrap_or_else(|poisoned| poisoned.into_inner())
21741 .is_empty());
21742 drop(shared);
21743 drop(runtime);
21744 std::fs::remove_dir_all(root).unwrap();
21745 }
21746
21747 #[test]
21748 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21749 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21750 let address = SessionAddress {
21751 workspace_id: WorkspaceId::new("primary").unwrap(),
21752 session: SessionKey {
21753 instance_id: AgentInstanceId(202),
21754 generation: SessionGeneration(1),
21755 },
21756 };
21757 let (overlay, materialization_guard) = shared
21758 .prepare_session_materialization(
21759 &address,
21760 &agent("claude"),
21761 SessionMode::Pty,
21762 Some(&receipt),
21763 None,
21764 None,
21765 None,
21766 )
21767 .unwrap()
21768 .unwrap();
21769 let materialization_id = materialization_guard.id().unwrap().clone();
21770 let materialization_root = shared
21771 .materializations
21772 .lock()
21773 .unwrap_or_else(|poisoned| poisoned.into_inner())
21774 .get(&materialization_id)
21775 .unwrap()
21776 .root()
21777 .to_path_buf();
21778 let selection = shared
21779 .select_environment_profile(
21780 address.session.instance_id,
21781 &agent("claude"),
21782 SessionMode::Pty,
21783 Some(&receipt),
21784 )
21785 .unwrap()
21786 .unwrap();
21787 assert!(shared
21788 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21789 .unwrap()
21790 .is_none());
21791 shared.bind_session_with_materialization(
21792 &address,
21793 ProviderRuntimePolicy::raw_pty(),
21794 Some(receipt),
21795 None,
21796 None,
21797 Some(materialization_id),
21798 );
21799 selection.retain();
21800 materialization_guard.retain();
21801
21802 assert!(materialization_root.is_dir());
21803 assert!(shared
21804 .session_bindings
21805 .lock()
21806 .unwrap_or_else(|poisoned| poisoned.into_inner())
21807 .contains_key(&address.session.instance_id));
21808
21809 let removed = shared.remove_binding(&address).unwrap();
21810 shared
21811 .cleanup_session_owned_materialization(&address, &removed)
21812 .unwrap();
21813 assert!(!materialization_root.exists());
21814 assert!(shared
21815 .materializations
21816 .lock()
21817 .unwrap_or_else(|poisoned| poisoned.into_inner())
21818 .is_empty());
21819 drop(shared);
21820 drop(runtime);
21821 std::fs::remove_dir_all(root).unwrap();
21822 }
21823
21824 #[tokio::test]
21825 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21826 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21827 let address = SessionAddress {
21828 workspace_id: WorkspaceId::new("primary").unwrap(),
21829 session: SessionKey {
21830 instance_id: AgentInstanceId(203),
21831 generation: SessionGeneration(1),
21832 },
21833 };
21834 let (overlay, materialization_guard) = shared
21835 .prepare_session_materialization(
21836 &address,
21837 &agent("claude"),
21838 SessionMode::Pty,
21839 Some(&receipt),
21840 None,
21841 None,
21842 None,
21843 )
21844 .unwrap()
21845 .unwrap();
21846 let materialization_id = materialization_guard.id().unwrap().clone();
21847 let materialization_root = shared
21848 .materializations
21849 .lock()
21850 .unwrap_or_else(|poisoned| poisoned.into_inner())
21851 .get(&materialization_id)
21852 .unwrap()
21853 .root()
21854 .to_path_buf();
21855 let selection = shared
21856 .select_environment_profile(
21857 address.session.instance_id,
21858 &agent("claude"),
21859 SessionMode::Pty,
21860 Some(&receipt),
21861 )
21862 .unwrap()
21863 .unwrap();
21864 assert!(shared
21865 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21866 .unwrap()
21867 .is_none());
21868 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21869 let record_id = shared
21870 .bind_spawn_session_with_materialization(
21871 &address,
21872 agent("claude"),
21873 SessionMode::Pty,
21874 policy,
21875 SpawnRecordPolicy::ProviderIdentityOnly,
21876 Some(receipt.clone()),
21877 None,
21878 None,
21879 Some(materialization_id.clone()),
21880 )
21881 .unwrap()
21882 .unwrap();
21883 selection.retain();
21884 materialization_guard.retain();
21885 assert_eq!(
21886 shared
21887 .materializations
21888 .lock()
21889 .unwrap_or_else(|poisoned| poisoned.into_inner())
21890 .get(&materialization_id)
21891 .unwrap()
21892 .owner(),
21893 &MaterializationOwner::Record {
21894 record_id: record_id.clone(),
21895 },
21896 );
21897
21898 let removed = shared.remove_binding(&address).unwrap();
21899 shared
21900 .cleanup_session_owned_materialization(&address, &removed)
21901 .unwrap();
21902 assert!(materialization_root.is_dir());
21903 assert!(shared
21904 .resolve_record_materialization(
21905 &record_id,
21906 &agent("claude"),
21907 SessionMode::Pty,
21908 Some(&receipt),
21909 None,
21910 None,
21911 )
21912 .unwrap()
21913 .is_some());
21914 {
21915 let mut records = shared
21916 .session_records
21917 .lock()
21918 .unwrap_or_else(|poisoned| poisoned.into_inner());
21919 let record = records.records.get_mut(&record_id).unwrap();
21920 record.active_session = None;
21921 record.state = ManagedSessionState::Unavailable;
21922 }
21923 shared.forget_session_record(&record_id).await.unwrap();
21924 assert!(!materialization_root.exists());
21925 assert!(shared
21926 .materializations
21927 .lock()
21928 .unwrap_or_else(|poisoned| poisoned.into_inner())
21929 .is_empty());
21930 drop(shared);
21931 drop(runtime);
21932 std::fs::remove_dir_all(root).unwrap();
21933 }
21934
21935 #[test]
21936 fn record_materialization_revalidation_failure_persists_recovery_required() {
21937 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21938 let state_path = root.join("node-state-v6.json");
21939 shared.state_path = Some(state_path.clone());
21940 let (_address, _record_id, materialization_id, materialization_root) =
21941 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21942 std::fs::write(
21943 materialization_root.join(".gate4agent-materialization-owner"),
21944 b"tampered",
21945 )
21946 .unwrap();
21947
21948 let error = match shared.resolve_record_materialization(
21949 &_record_id,
21950 &agent("claude"),
21951 SessionMode::Pty,
21952 Some(&receipt),
21953 None,
21954 None,
21955 ) {
21956 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21957 Err(error) => error,
21958 };
21959 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21960 assert_eq!(
21961 shared
21962 .materializations
21963 .lock()
21964 .unwrap_or_else(|poisoned| poisoned.into_inner())
21965 .get(&materialization_id)
21966 .unwrap()
21967 .state(),
21968 MaterializationState::RecoveryRequired,
21969 );
21970 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21971 assert_eq!(loaded.materializations.len(), 1);
21972 assert_eq!(
21973 loaded.materializations[0].state(),
21974 MaterializationState::RecoveryRequired,
21975 );
21976 drop(shared);
21977 drop(runtime);
21978 std::fs::remove_dir_all(root).unwrap();
21979 }
21980
21981 #[test]
21982 fn transient_record_materialization_resolution_failure_remains_ready() {
21983 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21984 let (_address, record_id, materialization_id, _materialization_root) =
21985 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21986 deny.store(true, Ordering::Release);
21987
21988 let error = match shared.resolve_record_materialization(
21989 &record_id,
21990 &agent("claude"),
21991 SessionMode::Pty,
21992 Some(&receipt),
21993 None,
21994 None,
21995 ) {
21996 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21997 Err(error) => error,
21998 };
21999 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22000 assert_eq!(
22001 shared
22002 .materializations
22003 .lock()
22004 .unwrap_or_else(|poisoned| poisoned.into_inner())
22005 .get(&materialization_id)
22006 .unwrap()
22007 .state(),
22008 MaterializationState::Ready,
22009 );
22010 drop(shared);
22011 drop(runtime);
22012 std::fs::remove_dir_all(root).unwrap();
22013 }
22014
22015 #[test]
22016 fn startup_missing_record_materialization_is_not_resumable() {
22017 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22018 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22019 shared
22020 .insert_record(ManagedSessionRecord {
22021 record_id: record_id.clone(),
22022 display_name: "missing materialization".to_owned(),
22023 provider: agent("claude"),
22024 mode: SessionMode::Pty,
22025 state: ManagedSessionState::Dormant,
22026 workspace_id: WorkspaceId::new("primary").unwrap(),
22027 canonical_root: opaque_windows_path(
22028 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22029 ),
22030 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22031 key: gate4agent_types::ProviderSessionKey::SessionId,
22032 id: "provider-session-missing-materialization".to_owned(),
22033 transcript_path: None,
22034 }),
22035 active_session: None,
22036 environment_profile: Some(receipt),
22037 bundle: None,
22038 context_id: None,
22039 context: None,
22040 exported_context: None,
22041 task_binding: None,
22042 created_at_unix_ms: 1,
22043 updated_at_unix_ms: 1,
22044 last_error: None,
22045 })
22046 .unwrap();
22047
22048 shared.reconcile_materializations();
22049 let record = shared.record(&record_id).unwrap();
22050 assert_eq!(record.state, ManagedSessionState::Unavailable);
22051 assert!(record.provider_session.is_none());
22052 assert_eq!(
22053 record.last_error.as_deref(),
22054 Some("environment-profile-unavailable"),
22055 );
22056 drop(shared);
22057 drop(runtime);
22058 std::fs::remove_dir_all(root).unwrap();
22059 }
22060
22061 #[tokio::test]
22062 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22063 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22064 let lease = install_managed_lease(&shared);
22065 let address = SessionAddress {
22066 workspace_id: WorkspaceId::new("primary").unwrap(),
22067 session: SessionKey {
22068 instance_id: AgentInstanceId(204),
22069 generation: SessionGeneration(1),
22070 },
22071 };
22072 let (overlay, materialization_guard) = shared
22073 .prepare_session_materialization(
22074 &address,
22075 &agent("claude"),
22076 SessionMode::Pty,
22077 Some(&receipt),
22078 None,
22079 None,
22080 Some(lease.lease_id.clone()),
22081 )
22082 .unwrap()
22083 .unwrap();
22084 let materialization_id = materialization_guard.id().unwrap().clone();
22085 let materialization_root = shared
22086 .materializations
22087 .lock()
22088 .unwrap_or_else(|poisoned| poisoned.into_inner())
22089 .get(&materialization_id)
22090 .unwrap()
22091 .root()
22092 .to_path_buf();
22093 let selection = shared
22094 .select_environment_profile(
22095 address.session.instance_id,
22096 &agent("claude"),
22097 SessionMode::Pty,
22098 Some(&receipt),
22099 )
22100 .unwrap()
22101 .unwrap();
22102 assert!(shared
22103 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22104 .unwrap()
22105 .is_none());
22106 shared.bind_session_with_materialization(
22107 &address,
22108 ProviderRuntimePolicy::raw_pty(),
22109 Some(receipt),
22110 None,
22111 None,
22112 Some(materialization_id),
22113 );
22114 selection.retain();
22115 materialization_guard.retain();
22116
22117 let blocked = shared
22118 .cleanup_managed_worktree(&lease.lease_id, false)
22119 .await
22120 .unwrap_err();
22121 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22122 assert!(materialization_root.is_dir());
22123 let removed = shared.remove_binding(&address).unwrap();
22124 shared
22125 .cleanup_session_owned_materialization(&address, &removed)
22126 .unwrap();
22127 assert!(!materialization_root.exists());
22128 drop(shared);
22129 drop(runtime);
22130 std::fs::remove_dir_all(root).unwrap();
22131 }
22132
22133 #[tokio::test]
22134 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22135 let git_root = temporary_workspace_root("raw-managed-inventory");
22136 let source = git_root.join("source");
22137 let allocation = git_root.join("allocation");
22138 std::fs::create_dir_all(&source).unwrap();
22139 std::fs::create_dir_all(&allocation).unwrap();
22140 run_test_git(&source, &["init"]);
22141 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22142 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22143 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22144 run_test_git(&source, &["add", "seed.txt"]);
22145 run_test_git(&source, &["commit", "-m", "seed"]);
22146 let profile = ManagedWorktreeProfile::new(
22147 WorktreeProfileId::new("default").unwrap(),
22148 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22149 &allocation,
22150 "gate4agent",
22151 "HEAD",
22152 ManagedWorktreeRetention::RemoveWhenReleased,
22153 )
22154 .unwrap();
22155 let workspace = WorkspaceConfig::new(
22156 WorkspaceId::new("primary").unwrap(),
22157 &source,
22158 )
22159 .unwrap()
22160 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22161 .with_managed_worktree_profile(profile.clone())
22162 .unwrap();
22163 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22164 materialization_test_shared_with_workspace(false, workspace);
22165 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22166 let source_text = source.to_string_lossy().into_owned();
22167 let target_text = target.to_string_lossy().into_owned();
22168 let created = create_git_worktree(
22169 &source_text,
22170 &target_text,
22171 "gate4agent/mw-raw-inventory",
22172 Some("HEAD"),
22173 )
22174 .await
22175 .unwrap();
22176 let lease = ManagedWorktreeLeaseRecord {
22177 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22178 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22179 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22180 profile_id: profile.profile_id().clone(),
22181 profile_revision: profile.revision().clone(),
22182 target_root: created.path.clone(),
22183 branch: "gate4agent/mw-raw-inventory".to_owned(),
22184 base_commit: created.head.clone(),
22185 expected_head: Some(created.head.clone()),
22186 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22187 state: ManagedWorktreeLeaseState::Ready,
22188 session_holders: Vec::new(),
22189 record_holders: Vec::new(),
22190 cleanup_failure: None,
22191 created_at_unix_ms: 1,
22192 updated_at_unix_ms: 1,
22193 };
22194 shared
22195 .workspaces
22196 .write()
22197 .unwrap_or_else(|poisoned| poisoned.into_inner())
22198 .insert(lease.workspace_id.clone(), created.path.clone());
22199 *shared
22200 .managed_worktrees
22201 .lock()
22202 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22203 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22204 let address = SessionAddress {
22205 workspace_id: lease.workspace_id.clone(),
22206 session: SessionKey {
22207 instance_id: AgentInstanceId(205),
22208 generation: SessionGeneration(1),
22209 },
22210 };
22211 let (overlay, materialization_guard) = shared
22212 .prepare_session_materialization(
22213 &address,
22214 &agent("claude"),
22215 SessionMode::Pty,
22216 Some(&receipt),
22217 None,
22218 None,
22219 Some(lease.lease_id.clone()),
22220 )
22221 .unwrap()
22222 .unwrap();
22223 let materialization_id = materialization_guard.id().unwrap().clone();
22224 let materialization_root = shared
22225 .materializations
22226 .lock()
22227 .unwrap_or_else(|poisoned| poisoned.into_inner())
22228 .get(&materialization_id)
22229 .unwrap()
22230 .root()
22231 .to_path_buf();
22232 let selection = shared
22233 .select_environment_profile(
22234 address.session.instance_id,
22235 &agent("claude"),
22236 SessionMode::Pty,
22237 Some(&receipt),
22238 )
22239 .unwrap()
22240 .unwrap();
22241 assert!(shared
22242 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22243 .unwrap()
22244 .is_none());
22245 let record_id = shared
22246 .bind_spawn_session_with_materialization(
22247 &address,
22248 agent("claude"),
22249 SessionMode::Pty,
22250 ProviderRuntimePolicy::raw_pty(),
22251 SpawnRecordPolicy::Always,
22252 Some(receipt.clone()),
22253 None,
22254 None,
22255 Some(materialization_id.clone()),
22256 )
22257 .unwrap()
22258 .unwrap();
22259 selection.retain();
22260 materialization_guard.retain();
22261 let record = shared.record(&record_id).unwrap();
22262 assert_eq!(record.state, ManagedSessionState::Live);
22263 assert!(record.provider_session.is_none());
22264 assert!(record.environment_profile.is_none());
22265 assert!(record.bundle.is_none());
22266 assert!(record.context_id.is_none());
22267 assert!(record.context.is_none());
22268 assert_eq!(record.active_session.as_ref(), Some(&address));
22269 assert_eq!(
22270 shared
22271 .materializations
22272 .lock()
22273 .unwrap_or_else(|poisoned| poisoned.into_inner())
22274 .get(&materialization_id)
22275 .unwrap()
22276 .owner(),
22277 &MaterializationOwner::Session {
22278 incarnation_id: shared.incarnation_id,
22279 instance_id: address.session.instance_id,
22280 generation: address.session.generation,
22281 },
22282 );
22283 {
22284 let mut bindings = shared
22285 .session_bindings
22286 .lock()
22287 .unwrap_or_else(|poisoned| poisoned.into_inner());
22288 bindings
22289 .get_mut(&address.session.instance_id)
22290 .unwrap()
22291 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22292 }
22293 let lease_snapshot = shared
22294 .managed_worktrees
22295 .lock()
22296 .unwrap_or_else(|poisoned| poisoned.into_inner())
22297 .bind_session(
22298 &lease.lease_id,
22299 ManagedWorktreeSessionHolder {
22300 incarnation_id: shared.incarnation_id,
22301 instance_id: address.session.instance_id,
22302 generation: address.session.generation,
22303 },
22304 None,
22305 unix_time_ms(),
22306 )
22307 .unwrap();
22308 assert_eq!(lease_snapshot.active_session_count, 1);
22309 assert_eq!(lease_snapshot.managed_record_count, 0);
22310 shared.persist_state().unwrap();
22311 assert!(shared
22312 .managed_worktrees
22313 .lock()
22314 .unwrap_or_else(|poisoned| poisoned.into_inner())
22315 .get(&lease.lease_id)
22316 .unwrap()
22317 .record_holders
22318 .is_empty());
22319 shared
22320 .handle
22321 .dispatch(shared.prepare_command(ControlCommand::Register {
22322 instance_id: address.session.instance_id,
22323 agent_id: agent("claude"),
22324 transport: TransportKind::Pty,
22325 }))
22326 .unwrap();
22327 runtime.tick().await;
22328 shared.reconcile_managed_record(
22329 &record_id,
22330 &address,
22331 &ControlEvent {
22332 sequence: 1,
22333 command_id: None,
22334 instance_id: address.session.instance_id,
22335 generation: address.session.generation,
22336 event: ControlEventKind::Removed,
22337 },
22338 false,
22339 false,
22340 );
22341 let remove = shared.remove_session(&address);
22342 let drive_runtime = async {
22343 runtime.tick().await;
22344 };
22345 let (removed, ()) = tokio::join!(remove, drive_runtime);
22346 removed.unwrap();
22347
22348 assert!(!materialization_root.exists());
22349 assert!(!Path::new(&created.path).exists());
22350 assert_eq!(
22351 shared
22352 .managed_worktrees
22353 .lock()
22354 .unwrap_or_else(|poisoned| poisoned.into_inner())
22355 .get(&lease.lease_id)
22356 .unwrap()
22357 .state,
22358 ManagedWorktreeLeaseState::Removed,
22359 );
22360 let renamed = shared
22361 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22362 .unwrap();
22363 assert_eq!(renamed.record_id, record_id);
22364 assert_eq!(renamed.display_name, "raw managed inventory");
22365 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22366 assert!(renamed.active_session.is_none());
22367
22368 drop(shared);
22369 drop(runtime);
22370 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22371 std::fs::remove_dir_all(git_root).unwrap();
22372 }
22373
22374 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22375 let root = std::env::current_dir().unwrap()
22376 .join("managed-test-target")
22377 .to_string_lossy()
22378 .into_owned();
22379 let lease = ManagedWorktreeLeaseRecord {
22380 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22381 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22382 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22383 profile_id: WorktreeProfileId::new("default").unwrap(),
22384 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22385 target_root: root.clone(),
22386 branch: "gate4agent/mw-test".to_owned(),
22387 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22388 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22389 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22390 state: ManagedWorktreeLeaseState::Ready,
22391 session_holders: Vec::new(),
22392 record_holders: Vec::new(),
22393 cleanup_failure: None,
22394 created_at_unix_ms: 1,
22395 updated_at_unix_ms: 1,
22396 };
22397 shared.workspaces.write()
22398 .unwrap_or_else(|poisoned| poisoned.into_inner())
22399 .insert(lease.workspace_id.clone(), root);
22400 *shared.managed_worktrees.lock()
22401 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22402 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22403 lease
22404 }
22405
22406 #[cfg(feature = "fixture")]
22407 #[test]
22408 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22409 let mut shared = terminal_test_shared();
22410 shared.fixture_semantic_hook_policy = true;
22411 let lease = install_managed_lease(&shared);
22412 let address = SessionAddress {
22413 workspace_id: lease.workspace_id.clone(),
22414 session: SessionKey {
22415 instance_id: AgentInstanceId(206),
22416 generation: SessionGeneration(1),
22417 },
22418 };
22419 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22420 let effective_runtime_policy =
22421 shared.effective_spawn_runtime_policy(pre_admission_policy);
22422 assert_ne!(effective_runtime_policy, pre_admission_policy);
22423 assert!(effective_runtime_policy.provider_session_identity);
22424
22425 let record_id = shared
22426 .bind_spawn_session(
22427 &address,
22428 agent("claude"),
22429 SessionMode::Pty,
22430 effective_runtime_policy,
22431 SpawnRecordPolicy::Always,
22432 None,
22433 )
22434 .unwrap()
22435 .unwrap();
22436 assert_eq!(
22437 shared.record(&record_id).unwrap().state,
22438 ManagedSessionState::IdentityPending,
22439 );
22440 let (record_holder, raw_inventory_record_id) = {
22441 let mut bindings = shared
22442 .session_bindings
22443 .lock()
22444 .unwrap_or_else(|poisoned| poisoned.into_inner());
22445 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22446 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22447 managed_spawn_record_ownership(binding, effective_runtime_policy)
22448 };
22449 assert_eq!(record_holder.as_ref(), Some(&record_id));
22450 assert!(raw_inventory_record_id.is_none());
22451 let snapshot = shared
22452 .managed_worktrees
22453 .lock()
22454 .unwrap_or_else(|poisoned| poisoned.into_inner())
22455 .bind_session(
22456 &lease.lease_id,
22457 ManagedWorktreeSessionHolder {
22458 incarnation_id: shared.incarnation_id,
22459 instance_id: address.session.instance_id,
22460 generation: address.session.generation,
22461 },
22462 record_holder,
22463 unix_time_ms(),
22464 )
22465 .unwrap();
22466 assert_eq!(snapshot.managed_record_count, 1);
22467 assert_eq!(
22468 shared
22469 .managed_worktrees
22470 .lock()
22471 .unwrap_or_else(|poisoned| poisoned.into_inner())
22472 .get(&lease.lease_id)
22473 .unwrap()
22474 .record_holders,
22475 vec![record_id.clone()],
22476 );
22477
22478 let identity = ProviderSessionIdentity {
22479 key: ProviderSessionKey::SessionId,
22480 id: "monitoring-fixture-managed-session".to_owned(),
22481 transcript_path: None,
22482 };
22483 shared.publish_control(ControlEvent {
22484 sequence: 1,
22485 command_id: None,
22486 instance_id: address.session.instance_id,
22487 generation: address.session.generation,
22488 event: ControlEventKind::ProviderEvent {
22489 sequence: 1,
22490 source: gate4agent_types::ProviderSource {
22491 family: AdapterFamily::Hook,
22492 binding: AdapterBinding::new(
22493 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22494 "fixture/v1",
22495 gate4agent_types::AdapterVerification::SyntheticFixture,
22496 )
22497 .unwrap(),
22498 },
22499 source_sequence: 1,
22500 event: ProviderEvent::SessionIdentityObserved {
22501 identity: identity.clone(),
22502 },
22503 },
22504 });
22505
22506 let live = shared.record(&record_id).unwrap();
22507 assert_eq!(live.state, ManagedSessionState::Live);
22508 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22509 assert_eq!(live.active_session.as_ref(), Some(&address));
22510 assert_eq!(
22511 shared
22512 .managed_worktrees
22513 .lock()
22514 .unwrap_or_else(|poisoned| poisoned.into_inner())
22515 .get(&lease.lease_id)
22516 .unwrap()
22517 .record_holders,
22518 vec![record_id],
22519 );
22520 }
22521
22522 fn managed_v2_test_shared(
22523 allocation_root: &Path,
22524 state_path: Option<PathBuf>,
22525 profile_revision: &str,
22526 ) -> NodeShared {
22527 let profile = ManagedWorktreeProfile::new(
22528 WorktreeProfileId::new("default").unwrap(),
22529 WorktreeProfileRevision::new(profile_revision).unwrap(),
22530 allocation_root,
22531 "gate4agent",
22532 "HEAD",
22533 ManagedWorktreeRetention::RemoveWhenReleased,
22534 )
22535 .unwrap();
22536 let workspace = WorkspaceConfig::new(
22537 WorkspaceId::new("primary").unwrap(),
22538 std::env::current_dir().unwrap(),
22539 )
22540 .unwrap()
22541 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22542 .with_managed_worktree_profile(profile)
22543 .unwrap();
22544 let catalog = active_registry().unwrap();
22545 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22546 let mut shared = NodeShared::new(
22547 handle,
22548 "fixture-token".to_owned(),
22549 NodeId::new("node-terminal-test").unwrap(),
22550 vec![workspace],
22551 vec![agent("claude")],
22552 );
22553 shared.state_path = state_path;
22554 shared
22555 }
22556
22557 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22558 let mut spawn_spec = spawn_spec_fixture();
22559 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22560 ManagedWorktreeSpawnRequestV2 {
22561 spawn_spec,
22562 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22563 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22564 }
22565 }
22566
22567 #[test]
22568 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22569 let shared = terminal_test_shared();
22570 let mut lease = install_managed_lease(&shared);
22571 let session = SessionAddress {
22572 workspace_id: lease.workspace_id.clone(),
22573 session: SessionKey {
22574 instance_id: AgentInstanceId(73),
22575 generation: SessionGeneration(4),
22576 },
22577 };
22578 lease.state = ManagedWorktreeLeaseState::InUse;
22579 lease.session_holders.push(ManagedWorktreeSessionHolder {
22580 incarnation_id: shared.incarnation_id,
22581 instance_id: session.session.instance_id,
22582 generation: session.session.generation,
22583 });
22584 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22585 assert!(resolved.target.worktree_id.is_none());
22586
22587 let receipt = managed_spawn_receipt(
22588 &resolved,
22589 shared.incarnation_id,
22590 session.clone(),
22591 lease.snapshot(),
22592 None,
22593 None,
22594 None,
22595 );
22596 assert!(resolved.target.worktree_id.is_none());
22597 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22598 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22599 assert_eq!(receipt.spawn.session, session);
22600 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22601 let encoded = serde_json::to_vec(&receipt).unwrap();
22602 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22603 assert_eq!(decoded, receipt);
22604 }
22605
22606 #[tokio::test]
22607 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22608 let root = temporary_workspace_root("managed-v2-revision");
22609 let allocation = root.join("allocation");
22610 std::fs::create_dir_all(&allocation).unwrap();
22611 let shared = managed_v2_test_shared(
22612 &allocation,
22613 Some(root.join("node-state.json")),
22614 "v1",
22615 );
22616 let mut request = managed_v2_request("managed-v2-revision");
22617 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22618
22619 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22620
22621 assert_eq!(
22622 failure.code,
22623 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22624 );
22625 assert!(shared
22626 .managed_worktrees
22627 .lock()
22628 .unwrap_or_else(|poisoned| poisoned.into_inner())
22629 .records()
22630 .is_empty());
22631 assert!(shared
22632 .managed_spawn_replays
22633 .lock()
22634 .unwrap_or_else(|poisoned| poisoned.into_inner())
22635 .is_empty());
22636 std::fs::remove_dir_all(root).unwrap();
22637 }
22638
22639 #[test]
22640 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22641 let root = temporary_workspace_root("managed-v2-concurrent");
22642 let allocation = root.join("allocation");
22643 std::fs::create_dir_all(&allocation).unwrap();
22644 let shared = Arc::new(managed_v2_test_shared(
22645 &allocation,
22646 Some(root.join("node-state.json")),
22647 "v1",
22648 ));
22649 let request = managed_v2_request("managed-v2-concurrent");
22650 let barrier = Arc::new(std::sync::Barrier::new(3));
22651 let mut joins = Vec::new();
22652 for _ in 0..2 {
22653 let shared = Arc::clone(&shared);
22654 let request = request.clone();
22655 let barrier = Arc::clone(&barrier);
22656 joins.push(std::thread::spawn(move || {
22657 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22658 barrier.wait();
22659 shared.acquire_durable_managed_spawn_v2(&request, digest)
22660 }));
22661 }
22662 barrier.wait();
22663 let results = joins
22664 .into_iter()
22665 .map(|join| join.join().unwrap())
22666 .collect::<Vec<_>>();
22667 assert_eq!(
22668 results
22669 .iter()
22670 .filter(|result| {
22671 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22672 })
22673 .count(),
22674 1,
22675 );
22676 assert_eq!(
22677 results
22678 .iter()
22679 .filter(|result| {
22680 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22681 })
22682 .count(),
22683 1,
22684 );
22685 assert_eq!(
22686 shared
22687 .managed_spawn_replays
22688 .lock()
22689 .unwrap_or_else(|poisoned| poisoned.into_inner())
22690 .len(),
22691 1,
22692 );
22693 assert!(shared
22694 .managed_worktrees
22695 .lock()
22696 .unwrap_or_else(|poisoned| poisoned.into_inner())
22697 .records()
22698 .is_empty());
22699 drop(shared);
22700 std::fs::remove_dir_all(root).unwrap();
22701 }
22702
22703 #[tokio::test]
22704 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22705 let root = temporary_workspace_root("managed-v2-linked-pending");
22706 let allocation = root.join("allocation");
22707 std::fs::create_dir_all(&allocation).unwrap();
22708 let state_path = root.join("node-state.json");
22709 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22710 let lease = install_managed_lease(&shared);
22711 let request = managed_v2_request("managed-v2-linked-pending");
22712 assert!(matches!(
22713 shared
22714 .acquire_durable_managed_spawn_v2(
22715 &request,
22716 managed_spawn_request_digest_v2(&request).unwrap(),
22717 )
22718 .unwrap(),
22719 DurableManagedSpawnReplayDecision::Reserved,
22720 ));
22721 {
22722 let _transaction = shared
22723 .state_transaction
22724 .lock()
22725 .unwrap_or_else(|poisoned| poisoned.into_inner());
22726 shared
22727 .link_durable_managed_spawn_v2_lease_locked(
22728 &request.spawn_spec.idempotency_key,
22729 &lease.lease_id,
22730 )
22731 .unwrap();
22732 shared.persist_state_locked().unwrap();
22733 }
22734 drop(shared);
22735
22736 let mut loaded = session_registry::load(
22737 Some(&state_path),
22738 &NodeId::new("node-terminal-test").unwrap(),
22739 )
22740 .unwrap();
22741 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22742 *reopened
22743 .managed_worktrees
22744 .lock()
22745 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22746 ManagedWorktreeRegistry::from_records(
22747 std::mem::take(&mut loaded.managed_worktrees),
22748 std::mem::take(&mut loaded.managed_worktree_tombstones),
22749 )
22750 .unwrap();
22751 *reopened
22752 .managed_spawn_replays
22753 .lock()
22754 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22755 .managed_spawn_replays
22756 .into_iter()
22757 .map(|record| (record.idempotency_key.clone(), record))
22758 .collect();
22759 let lease_count = reopened
22760 .managed_worktrees
22761 .lock()
22762 .unwrap_or_else(|poisoned| poisoned.into_inner())
22763 .records()
22764 .len();
22765
22766 let failure = reopened
22767 .spawn_managed_worktree_v2(request)
22768 .await
22769 .unwrap_err();
22770
22771 assert_eq!(
22772 failure.code,
22773 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22774 );
22775 assert_eq!(
22776 reopened
22777 .managed_worktrees
22778 .lock()
22779 .unwrap_or_else(|poisoned| poisoned.into_inner())
22780 .records()
22781 .len(),
22782 lease_count,
22783 );
22784 std::fs::remove_dir_all(root).unwrap();
22785 }
22786
22787 #[tokio::test]
22788 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22789 let root = temporary_workspace_root("managed-v2-reopen");
22790 let allocation = root.join("allocation");
22791 std::fs::create_dir_all(&allocation).unwrap();
22792 let state_path = root.join("node-state.json");
22793 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22794 let lease = install_managed_lease(&shared);
22795 let request = managed_v2_request("managed-v2-reopen");
22796 let session = SessionAddress {
22797 workspace_id: lease.workspace_id.clone(),
22798 session: SessionKey {
22799 instance_id: AgentInstanceId(91),
22800 generation: SessionGeneration(1),
22801 },
22802 };
22803 let snapshot = {
22804 let mut registry = shared
22805 .managed_worktrees
22806 .lock()
22807 .unwrap_or_else(|poisoned| poisoned.into_inner());
22808 let current = registry.get_mut(&lease.lease_id).unwrap();
22809 current.state = ManagedWorktreeLeaseState::InUse;
22810 current.session_holders.push(ManagedWorktreeSessionHolder {
22811 incarnation_id: shared.incarnation_id,
22812 instance_id: session.session.instance_id,
22813 generation: session.session.generation,
22814 });
22815 current.snapshot()
22816 };
22817 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22818 let receipt = managed_spawn_receipt(
22819 &resolved,
22820 shared.incarnation_id,
22821 session,
22822 snapshot,
22823 None,
22824 None,
22825 None,
22826 );
22827 assert!(matches!(
22828 shared
22829 .acquire_durable_managed_spawn_v2(
22830 &request,
22831 managed_spawn_request_digest_v2(&request).unwrap(),
22832 )
22833 .unwrap(),
22834 DurableManagedSpawnReplayDecision::Reserved,
22835 ));
22836 {
22837 let _transaction = shared
22838 .state_transaction
22839 .lock()
22840 .unwrap_or_else(|poisoned| poisoned.into_inner());
22841 shared
22842 .link_durable_managed_spawn_v2_lease_locked(
22843 &request.spawn_spec.idempotency_key,
22844 &lease.lease_id,
22845 )
22846 .unwrap();
22847 shared.persist_state_locked().unwrap();
22848 }
22849 shared
22850 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22851 .unwrap();
22852 let legacy_same_key = ManagedWorktreeSpawnRequest {
22853 spawn_spec: request.spawn_spec.clone(),
22854 worktree_profile_id: request.worktree_profile_id.clone(),
22855 };
22856 shared.remember_managed_spawn_attempt(
22857 Some(&request.spawn_spec.idempotency_key),
22858 legacy_same_key.clone(),
22859 Ok(receipt.clone()),
22860 Instant::now(),
22861 );
22862 assert!(shared
22863 .replay_managed_spawn(&legacy_same_key, Instant::now())
22864 .unwrap()
22865 .is_none());
22866 drop(shared);
22867
22868 let mut loaded = session_registry::load(
22869 Some(&state_path),
22870 &NodeId::new("node-terminal-test").unwrap(),
22871 )
22872 .unwrap();
22873 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22874 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22875 *reopened
22876 .managed_worktrees
22877 .lock()
22878 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22879 ManagedWorktreeRegistry::from_records(
22880 std::mem::take(&mut loaded.managed_worktrees),
22881 std::mem::take(&mut loaded.managed_worktree_tombstones),
22882 )
22883 .unwrap();
22884 *reopened
22885 .managed_spawn_replays
22886 .lock()
22887 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22888 .managed_spawn_replays
22889 .into_iter()
22890 .map(|record| (record.idempotency_key.clone(), record))
22891 .collect();
22892 let lease_count_before = reopened
22893 .managed_worktrees
22894 .lock()
22895 .unwrap_or_else(|poisoned| poisoned.into_inner())
22896 .records()
22897 .len();
22898
22899 assert_eq!(
22900 reopened
22901 .spawn_managed_worktree_v2(request.clone())
22902 .await
22903 .unwrap(),
22904 receipt,
22905 );
22906 assert_eq!(
22907 reopened
22908 .managed_worktrees
22909 .lock()
22910 .unwrap_or_else(|poisoned| poisoned.into_inner())
22911 .records()
22912 .len(),
22913 lease_count_before,
22914 );
22915 let mut changed = request;
22916 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22917 let failure = reopened
22918 .spawn_managed_worktree_v2(changed)
22919 .await
22920 .unwrap_err();
22921 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22922 assert_eq!(
22923 reopened
22924 .managed_worktrees
22925 .lock()
22926 .unwrap_or_else(|poisoned| poisoned.into_inner())
22927 .records()
22928 .len(),
22929 lease_count_before,
22930 );
22931 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22932 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22933 let failure = reopened
22934 .spawn_managed_worktree_v2(new_key)
22935 .await
22936 .unwrap_err();
22937 assert_eq!(
22938 failure.code,
22939 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22940 );
22941 assert_eq!(
22942 reopened
22943 .managed_worktrees
22944 .lock()
22945 .unwrap_or_else(|poisoned| poisoned.into_inner())
22946 .records()
22947 .len(),
22948 lease_count_before,
22949 );
22950 assert_eq!(
22951 reopened
22952 .managed_spawn_replays
22953 .lock()
22954 .unwrap_or_else(|poisoned| poisoned.into_inner())
22955 .len(),
22956 1,
22957 );
22958 std::fs::remove_dir_all(root).unwrap();
22959 }
22960
22961 #[test]
22962 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22963 let shared = terminal_test_shared();
22964 let request = ManagedWorktreeSpawnRequest {
22965 spawn_spec: spawn_spec_fixture(),
22966 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22967 };
22968 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22969 shared.remember_managed_spawn(
22970 request.clone(),
22971 Err(expected.clone()),
22972 Instant::now(),
22973 );
22974
22975 assert_eq!(
22976 shared
22977 .replay_managed_spawn(&request, Instant::now())
22978 .unwrap()
22979 .unwrap(),
22980 Err(expected),
22981 );
22982 assert!(shared
22983 .managed_spawn_replays
22984 .lock()
22985 .unwrap_or_else(|poisoned| poisoned.into_inner())
22986 .is_empty());
22987 }
22988
22989 #[tokio::test]
22990 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22991 let shared = terminal_test_shared();
22992 let lease = install_managed_lease(&shared);
22993 let raw = shared.spawn_session(
22994 lease.workspace_id.clone(),
22995 agent("claude"),
22996 SessionMode::Pty,
22997 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22998 None,
22999 ).await.unwrap_err();
23000 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23001 let mut spec = spawn_spec_fixture();
23002 spec.target.workspace_id = lease.workspace_id.clone();
23003 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23004 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23005 let mut selected = spawn_spec_fixture();
23006 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23007 selected.target.worktree_id = Some(lease.workspace_id);
23008 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23009 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23010 assert!(shared.handle.snapshot().sessions.is_empty());
23011 }
23012
23013 #[test]
23014 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23015 let shared = terminal_test_shared();
23016 let lease = install_managed_lease(&shared);
23017 let snapshot = snapshot_for_wire(
23018 &shared, true, true, true, false, true, true, false, false, true,
23019 );
23020 assert!(snapshot.managed_worktrees.is_empty());
23021 assert!(snapshot.workspaces.iter().all(|workspace| {
23022 workspace.managed_worktree_profiles.is_none()
23023 }));
23024 let mut reply = ResponseEnvelope {
23025 request_id: 1,
23026 result: Ok(NodeResponse::Resync {
23027 event_sequence: 1,
23028 oldest_available_sequence: 1,
23029 snapshot: shared.snapshot(),
23030 events: vec![NodeEventEnvelope {
23031 sequence: 1,
23032 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23033 }],
23034 }),
23035 };
23036 project_response_without_managed_worktrees(&mut reply);
23037 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23038 panic!("projection changed the response kind");
23039 };
23040 assert!(snapshot.managed_worktrees.is_empty());
23041 assert!(events.is_empty());
23042 let mut git = git_snapshot_for_parser();
23043 git.branch = Some(lease.branch.clone());
23044 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23045 lease_id: lease.lease_id.clone(),
23046 source_workspace_id: lease.source_workspace_id.clone(),
23047 branch: lease.branch.clone(),
23048 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23049 active_session_count: 1,
23050 managed_record_count: 0,
23051 });
23052 let mut inspection_reply = ResponseEnvelope {
23053 request_id: 2,
23054 result: Ok(NodeResponse::WorkspaceInspected {
23055 inspection: WorkspaceInspection {
23056 workspace_id: lease.workspace_id.clone(),
23057 entries: Vec::new(),
23058 tree_truncated: false,
23059 git,
23060 truncation: None,
23061 },
23062 }),
23063 };
23064 project_response_without_managed_worktrees(&mut inspection_reply);
23065 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23066 panic!("projection changed the inspection response kind");
23067 };
23068 assert!(inspection.git.managed_worktree.is_none());
23069 let failure = managed_git_worktree_failure(GitWorktreeError {
23070 kind: GitWorktreeErrorKind::Failed,
23071 message: r"git failed at C:\private\managed-target".to_owned(),
23072 });
23073 assert!(!failure.message.contains("private"));
23074 assert!(!failure.message.contains("managed-target"));
23075 }
23076
23077 #[tokio::test]
23078 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23079 let root = temporary_workspace_root("managed-restart");
23080 let source = root.join("source");
23081 let allocation = root.join("allocation");
23082 std::fs::create_dir_all(&source).unwrap();
23083 std::fs::create_dir_all(&allocation).unwrap();
23084 run_test_git(&source, &["init"]);
23085 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23086 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23087 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23088 run_test_git(&source, &["add", "seed.txt"]);
23089 run_test_git(&source, &["commit", "-m", "seed"]);
23090 let profile = ManagedWorktreeProfile::new(
23091 WorktreeProfileId::new("default").unwrap(),
23092 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23093 &allocation,
23094 "gate4agent",
23095 "HEAD",
23096 ManagedWorktreeRetention::Retain,
23097 ).unwrap();
23098 let workspace = WorkspaceConfig::new(
23099 WorkspaceId::new("primary").unwrap(),
23100 &source,
23101 ).unwrap()
23102 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23103 .with_managed_worktree_profile(profile.clone()).unwrap();
23104 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23105 let source_text = workspace.canonical_root().to_owned();
23106 let target_text = target.to_string_lossy().into_owned();
23107 let created = create_git_worktree(
23108 &source_text,
23109 &target_text,
23110 "gate4agent/mw-restart",
23111 Some("HEAD"),
23112 ).await.unwrap();
23113 let lease = ManagedWorktreeLeaseRecord {
23114 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23115 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23116 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23117 profile_id: profile.profile_id().clone(),
23118 profile_revision: profile.revision().clone(),
23119 target_root: created.path.clone(),
23120 branch: "gate4agent/mw-restart".to_owned(),
23121 base_commit: created.head.clone(),
23122 expected_head: None,
23123 retention: ManagedWorktreeRetention::Retain,
23124 state: ManagedWorktreeLeaseState::Allocating,
23125 session_holders: Vec::new(),
23126 record_holders: Vec::new(),
23127 cleanup_failure: None,
23128 created_at_unix_ms: 1,
23129 updated_at_unix_ms: 1,
23130 };
23131 let catalog = active_registry().unwrap();
23132 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23133 let shared = NodeShared::new_with_incarnation(
23134 handle,
23135 "fixture-token".to_owned(),
23136 NodeId::new("node-managed-restart").unwrap(),
23137 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23138 vec![workspace],
23139 vec![agent("claude")],
23140 ProviderRuntimeStatuses::default(),
23141 None,
23142 Vec::new(),
23143 Vec::new(),
23144 SpawnProfileRegistry::default(),
23145 None,
23146 None,
23147 None,
23148 None,
23149 Vec::new(),
23150 vec![lease.clone()],
23151 Vec::new(),
23152 Vec::new(),
23153 None,
23154 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23155 );
23156 shared.reconcile_managed_worktrees().await;
23157 let retained = shared.managed_worktrees.lock()
23158 .unwrap_or_else(|poisoned| poisoned.into_inner())
23159 .get(&lease.lease_id).unwrap().clone();
23160 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23161 assert!(shared.workspaces.read()
23162 .unwrap_or_else(|poisoned| poisoned.into_inner())
23163 .contains_key(&lease.workspace_id));
23164 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23165 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23166 assert!(!Path::new(&created.path).exists());
23167 std::fs::remove_dir_all(root).unwrap();
23168 }
23169
23170 fn run_test_git(root: &Path, arguments: &[&str]) {
23171 let output = std::process::Command::new("git")
23172 .arg("-C").arg(root).args(arguments).output().unwrap();
23173 assert!(
23174 output.status.success(),
23175 "git {:?} failed: {}",
23176 arguments,
23177 String::from_utf8_lossy(&output.stderr),
23178 );
23179 }
23180
23181 #[tokio::test]
23182 async fn spawn_spec_validation_fails_before_session_mutation() {
23183 let shared = terminal_test_shared();
23184 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23185 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23186 }));
23187 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23188 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23189 }));
23190 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23191 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23192 }));
23193
23194 let mut target_mismatch = spawn_spec_fixture();
23195 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23196 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23197 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23198
23199 let mut unknown_profile = spawn_spec_fixture();
23200 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23201 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23202 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23203 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23204
23205 let mut revision_mismatch = spawn_spec_fixture();
23206 revision_mismatch.idempotency_key =
23207 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23208 revision_mismatch.expected_profile_revision =
23209 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23210 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23211 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23212
23213 let mut unsupported_materializer = spawn_spec_fixture();
23214 unsupported_materializer.idempotency_key =
23215 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23216 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23217 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23218 };
23219 let failure = shared
23220 .spawn_from_spec(unsupported_materializer)
23221 .await
23222 .unwrap_err();
23223 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23224
23225 let failure = shared
23226 .spawn_session_with_deadline(
23227 WorkspaceId::new("primary").unwrap(),
23228 agent("claude"),
23229 SessionMode::Pty,
23230 gate4agent_types::TerminalSize {
23231 rows: 24,
23232 columns: 80,
23233 },
23234 None,
23235 None,
23236 None,
23237 None,
23238 None,
23239 None,
23240 SpawnRecordPolicy::ProviderIdentityOnly,
23241 Some(Instant::now()),
23242 &[],
23243 None,
23244 ApprovalLevel::default(),
23245 None,
23246 None,
23247 )
23248 .await
23249 .unwrap_err();
23250 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23251 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23252 assert!(shared.handle.snapshot().sessions.is_empty());
23253 }
23254
23255 #[test]
23256 fn worktree_selection_requires_its_separate_negotiated_capability() {
23257 let mut spec = spawn_spec_fixture();
23258 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23259 let request = NodeRequest::SpawnSpec { spec };
23260 let spawn_spec_capability =
23261 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23262 assert!(request_uses_unnegotiated_capability(
23263 &request,
23264 &[spawn_spec_capability.clone()],
23265 ));
23266 let worktree_capability =
23267 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23268 assert!(request_uses_unnegotiated_capability(
23269 &request,
23270 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23271 ));
23272 let profile_revision_capability =
23273 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23274 assert!(request_uses_unnegotiated_capability(
23275 &request,
23276 &[
23277 spawn_spec_capability.clone(),
23278 worktree_capability.clone(),
23279 profile_revision_capability.clone(),
23280 ],
23281 ));
23282 let bundle_materialization_capability =
23289 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23290 assert!(request_uses_unnegotiated_capability(
23291 &request,
23292 &[
23293 spawn_spec_capability.clone(),
23294 worktree_capability.clone(),
23295 profile_revision_capability.clone(),
23296 bundle_materialization_capability.clone(),
23297 ],
23298 ));
23299 let history_context_pack_capability =
23300 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23301 assert!(!request_uses_unnegotiated_capability(
23302 &request,
23303 &[
23304 spawn_spec_capability,
23305 worktree_capability,
23306 profile_revision_capability,
23307 bundle_materialization_capability,
23308 history_context_pack_capability,
23309 ],
23310 ));
23311 }
23312
23313 #[tokio::test]
23314 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23315 let mut shared = terminal_test_shared();
23316 let spec = spawn_spec_fixture();
23317 let receipt = shared
23318 .resolve_spawn_spec(&spec)
23319 .unwrap()
23320 .receipt(shared.incarnation_id, terminal_address(1));
23321 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23322
23323 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23324 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23325 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23326
23327 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23328
23329 let mut revision_conflicting = spec.clone();
23330 revision_conflicting.expected_profile_revision =
23331 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23332 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23333 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23334
23335 let mut stale_cache_miss = spec.clone();
23336 stale_cache_miss.idempotency_key =
23337 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23338 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23339 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23340
23341 let mut conflicting = spec;
23342 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23343 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23344 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23345 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23346 assert!(shared.handle.snapshot().sessions.is_empty());
23347 }
23348
23349 #[tokio::test]
23350 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23351 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23352 let mut spec = spawn_spec_fixture();
23353 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23354 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23355 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23356
23357 let failure = shared
23358 .create_worktree(
23359 WorkspaceId::new("primary").unwrap(),
23360 WorkspaceId::new("new-worktree").unwrap(),
23361 "not-an-absolute-path".to_owned(),
23362 "invalid branch".to_owned(),
23363 None,
23364 )
23365 .await
23366 .unwrap_err();
23367 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23368
23369 let failure = shared
23370 .remove_worktree(
23371 WorkspaceId::new("primary").unwrap(),
23372 "not-an-absolute-path".to_owned(),
23373 )
23374 .await
23375 .unwrap_err();
23376 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23377 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23378 assert!(shared.handle.snapshot().sessions.is_empty());
23379 }
23380
23381 #[tokio::test]
23382 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23383 let root = temporary_workspace_root("manual-worktree-compensation");
23384 let source = root.join("source");
23385 let target = root.join("target");
23386 let invalid_state_parent = root.join("state-parent-is-a-file");
23387 let invalid_state_path = invalid_state_parent.join("state.json");
23388 std::fs::create_dir_all(&source).unwrap();
23389 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23390 run_test_git(&source, &["init"]);
23391 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23392 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23393 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23394 run_test_git(&source, &["add", "seed.txt"]);
23395 run_test_git(&source, &["commit", "-m", "seed"]);
23396
23397 let catalog = active_registry().unwrap();
23398 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23399 let workspace = WorkspaceConfig::new(
23400 WorkspaceId::new("primary").unwrap(),
23401 &source,
23402 ).unwrap();
23403 let mut shared = NodeShared::new(
23404 handle,
23405 "fixture-token".to_owned(),
23406 NodeId::new("node-worktree-compensation").unwrap(),
23407 vec![workspace],
23408 vec![agent("claude")],
23409 );
23410 shared.state_path = Some(invalid_state_path);
23411
23412 let error = shared.create_worktree(
23413 WorkspaceId::new("primary").unwrap(),
23414 WorkspaceId::new("compensated").unwrap(),
23415 target.to_string_lossy().into_owned(),
23416 "gate4agent/compensated".to_owned(),
23417 Some("HEAD".to_owned()),
23418 ).await.unwrap_err();
23419
23420 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23421 assert!(!target.exists());
23422 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23423 .iter()
23424 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23425 std::fs::remove_dir_all(root).unwrap();
23426 }
23427
23428 #[test]
23429 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23430 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23431 NativeGitWorktreeSnapshot {
23432 path: path.to_owned(),
23433 head: "0123456789abcdef".to_owned(),
23434 branch: Some("refs/heads/test".to_owned()),
23435 is_bare,
23436 is_main,
23437 locked: false,
23438 lock_reason: None,
23439 prunable: false,
23440 prunable_reason: None,
23441 workspace_id: None,
23442 }
23443 }
23444
23445 let source = r"C:\repo";
23446 let selected = r"C:\repo-worktree";
23447 let listed = vec![
23448 worktree(source, true, false),
23449 worktree(selected, false, false),
23450 ];
23451 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23452
23453 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23454 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23455
23456 let bare = vec![
23457 worktree(source, true, false),
23458 worktree(selected, false, true),
23459 ];
23460 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23461 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23462
23463 let foreign_failure =
23464 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23465 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23466 }
23467
23468 fn terminal_frame(sequence: u64) -> TerminalFrame {
23469 TerminalFrame {
23470 sequence,
23471 size: gate4agent_types::TerminalSize {
23472 rows: 24,
23473 columns: 80,
23474 },
23475 cursor_row: 2,
23476 cursor_column: 3,
23477 contents: format!("frame-{sequence}"),
23478 formatted: vec![1, 2, 3],
23479 scrollback_formatted: Vec::new(),
23480 alternate_screen: false,
23481 mouse_protocol_enabled: false,
23482 mouse_protocol_encoding: Default::default(),
23483 produced_at_unix_ms: 0,
23484 screen_state: gate4agent_types::PtyScreenState::default(),
23485 bracketed_paste: None,
23486 }
23487 }
23488
23489 #[test]
23490 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23491 let shared = terminal_test_shared();
23492 let mut receiver = shared.terminal_event_tx.subscribe();
23493 let original = terminal_address(1);
23494
23495 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23496 let first = receiver.try_recv().unwrap();
23497 assert_eq!(first.len(), 1);
23498 assert!(matches!(
23499 &first[0].event,
23500 NodeEvent::TerminalFrame { address, frame }
23501 if address == &original && frame.sequence == 7
23502 ));
23503
23504 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23505 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23506 assert!(matches!(
23507 receiver.try_recv(),
23508 Err(broadcast::error::TryRecvError::Empty)
23509 ));
23510
23511 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23512 let advanced = receiver.try_recv().unwrap();
23513 assert_eq!(advanced[0].sequence, 2);
23514
23515 let resumed = terminal_address(2);
23516 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23517 let reset = receiver.try_recv().unwrap();
23518 assert_eq!(reset[0].sequence, 3);
23519 assert!(matches!(
23520 &reset[0].event,
23521 NodeEvent::TerminalFrame { address, frame }
23522 if address == &resumed && frame.sequence == 1
23523 ));
23524 shared.clear_terminal_frame_watermark(&resumed);
23525 assert!(!shared
23526 .terminal_frame_watermarks
23527 .lock()
23528 .unwrap_or_else(|poisoned| poisoned.into_inner())
23529 .contains_key(&resumed.session.instance_id));
23530 }
23531
23532 #[test]
23533 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23534 let shared = terminal_test_shared();
23535 let mut receiver = shared.terminal_event_tx.subscribe();
23536 let address = terminal_address(1);
23537 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23538
23539 for sequence in 1..=count {
23540 shared.publish_terminal_frame_candidates(vec![(
23541 address.clone(),
23542 terminal_frame(sequence),
23543 )]);
23544 }
23545
23546 let history = shared
23547 .history
23548 .lock()
23549 .unwrap_or_else(|poisoned| poisoned.into_inner());
23550 assert!(history.events.is_empty());
23551 assert_eq!(history.last_sequence, count);
23552 assert_eq!(history.replay_floor_sequence, 1);
23553 drop(history);
23554 let NodeResponse::Resync {
23555 event_sequence,
23556 oldest_available_sequence,
23557 events,
23558 ..
23559 } = shared.resync(0) else {
23560 unreachable!("resync helper returned another response")
23561 };
23562 assert_eq!(event_sequence, count);
23563 assert_eq!(oldest_available_sequence, 1);
23564 assert!(events.is_empty());
23565 assert!(matches!(
23566 resync_required_event(&shared).event,
23567 NodeEvent::ResyncRequired {
23568 oldest_available_sequence: 1,
23569 }
23570 ));
23571 assert!(matches!(
23572 receiver.try_recv(),
23573 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23574 ));
23575 let latest = receiver.try_recv().unwrap();
23576 assert_eq!(latest.len(), 1);
23577 assert_eq!(latest[0].sequence, count);
23578 }
23579
23580 #[test]
23581 fn real_durable_eviction_advances_authoritative_replay_floor() {
23582 let shared = terminal_test_shared();
23583 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23584 shared.publish(NodeEvent::ControllerChanged { controller: None });
23585 }
23586
23587 let NodeResponse::Resync {
23588 event_sequence,
23589 oldest_available_sequence,
23590 events,
23591 ..
23592 } = shared.resync(0) else {
23593 unreachable!("resync helper returned another response")
23594 };
23595 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23596 assert_eq!(oldest_available_sequence, 2);
23597 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23598 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23599 assert!(matches!(
23600 resync_required_event(&shared).event,
23601 NodeEvent::ResyncRequired {
23602 oldest_available_sequence: 2,
23603 }
23604 ));
23605 }
23606
23607 #[test]
23608 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23609 let shared = terminal_test_shared();
23610 let without_capability = terminal_event_subscription(&shared, false);
23611 let with_capability = terminal_event_subscription(&shared, true);
23612 assert!(without_capability.is_none());
23613 assert!(with_capability.is_some());
23614 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23615 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23616 }));
23617 }
23618
23619 #[test]
23620 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23621 let mut pending = Vec::new();
23622 let mut event_sequence = 0_u64;
23623 for frame_sequence in 1..=4 {
23624 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23625 event_sequence += 1;
23626 let mut address = terminal_address(1);
23627 address.session.instance_id = AgentInstanceId(instance);
23628 queue_connection_event(
23629 &mut pending,
23630 NodeEventEnvelope {
23631 sequence: event_sequence,
23632 event: NodeEvent::TerminalFrame {
23633 address,
23634 frame: terminal_frame(frame_sequence),
23635 },
23636 },
23637 0,
23638 );
23639 }
23640 }
23641 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23642 pending.sort_unstable_by_key(|event| event.sequence);
23643
23644 let mut uninterrupted = pending.clone();
23645 let first_burst = connection_event_burst_len(uninterrupted.len());
23646 uninterrupted.drain(..first_burst);
23647 let second_burst = connection_event_burst_len(uninterrupted.len());
23648 uninterrupted.drain(..second_burst);
23649 assert!(uninterrupted.is_empty());
23650
23651 let (frame_tx, mut frame_rx) =
23652 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23653 frame_tx
23654 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23655 request_id: 77,
23656 request: NodeRequest::Snapshot,
23657 })))
23658 .unwrap();
23659 let burst_len = connection_event_burst_len(pending.len());
23660 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23661 pending.drain(..burst_len);
23662 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23663 assert!(matches!(
23664 frame_rx.try_recv(),
23665 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23666 request_id: 77,
23667 request: NodeRequest::Snapshot,
23668 })))
23669 ));
23670 }
23671
23672 #[test]
23673 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23674 let mut pending = Vec::new();
23675 for sequence in [7_u64, 8] {
23676 let mut address = terminal_address(1);
23677 address.session.instance_id = AgentInstanceId(sequence);
23678 queue_connection_event(
23679 &mut pending,
23680 NodeEventEnvelope {
23681 sequence,
23682 event: NodeEvent::TerminalFrame {
23683 address,
23684 frame: terminal_frame(sequence),
23685 },
23686 },
23687 0,
23688 );
23689 }
23690 let mut discard_through = 0;
23691 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23692 assert!(pending.is_empty());
23693
23694 let replayed = NodeEventEnvelope {
23695 sequence: 8,
23696 event: NodeEvent::TerminalFrame {
23697 address: terminal_address(1),
23698 frame: terminal_frame(8),
23699 },
23700 };
23701 let advanced = NodeEventEnvelope {
23702 sequence: 9,
23703 event: NodeEvent::TerminalFrame {
23704 address: terminal_address(2),
23705 frame: terminal_frame(1),
23706 },
23707 };
23708 queue_connection_event_batch(
23709 &mut pending,
23710 &[replayed, advanced.clone()],
23711 discard_through,
23712 );
23713 assert_eq!(pending, vec![advanced]);
23714 }
23715
23716 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23717 ManagedSessionRecord {
23718 record_id: SessionRecordId::new(record_id).unwrap(),
23719 display_name: format!("{provider} record"),
23720 provider: agent(provider),
23721 mode: SessionMode::Pty,
23722 state: ManagedSessionState::Unavailable,
23723 workspace_id: WorkspaceId::new("primary").unwrap(),
23724 canonical_root: opaque_windows_path(
23725 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23726 ),
23727 provider_session: None,
23728 active_session: None,
23729 environment_profile: None,
23730 bundle: None,
23731 context_id: None,
23732 context: None,
23733 exported_context: None,
23734 task_binding: None,
23735 created_at_unix_ms: 1,
23736 updated_at_unix_ms: 1,
23737 last_error: None,
23738 }
23739 }
23740
23741 fn retention_test_record(
23742 workspace_id: &str,
23743 record_id: &str,
23744 state: ManagedSessionState,
23745 updated_at_unix_ms: u64,
23746 ) -> ManagedSessionRecord {
23747 ManagedSessionRecord {
23748 record_id: SessionRecordId::new(record_id).unwrap(),
23749 display_name: format!("{record_id} display"),
23750 provider: agent("claude"),
23751 mode: SessionMode::Pty,
23752 state,
23753 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23754 canonical_root: opaque_windows_path(
23755 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23756 ),
23757 provider_session: None,
23758 active_session: None,
23759 environment_profile: None,
23760 bundle: None,
23761 context_id: None,
23762 context: None,
23763 exported_context: None,
23764 task_binding: None,
23765 created_at_unix_ms: updated_at_unix_ms,
23766 updated_at_unix_ms,
23767 last_error: None,
23768 }
23769 }
23770
23771 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23779 vec![
23780 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23781 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23782 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23783 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23784 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23785 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23786 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23787 retention_test_record(
23788 "workspace-a",
23789 "decoy-pending",
23790 ManagedSessionState::IdentityPending,
23791 now - 500_000,
23792 ),
23793 retention_test_record(
23794 "workspace-a",
23795 "decoy-dormant",
23796 ManagedSessionState::Dormant,
23797 now - 500_000,
23798 ),
23799 ]
23800 }
23801
23802 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23803 let root = temporary_workspace_root("session-record-retention");
23804 std::fs::create_dir_all(&root).unwrap();
23805 let catalog = active_registry().unwrap();
23806 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23807 let workspace_a =
23808 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23809 let workspace_b =
23810 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23811 let mut shared = NodeShared::new(
23812 handle,
23813 "fixture-token".to_owned(),
23814 NodeId::new("node-retention-test").unwrap(),
23815 vec![workspace_a, workspace_b],
23816 vec![agent("claude")],
23817 );
23818 shared.state_path = Some(root.join("node-state-v10.json"));
23819 let now = unix_time_ms();
23820 for record in retention_fixture_records(now) {
23821 shared.insert_record(record).unwrap();
23822 }
23823 shared
23824 .persist_state_locked()
23825 .expect("fixture-only, empty-history persist against a fresh temp state path");
23826 (shared, runtime, root, now)
23827 }
23828
23829 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23830 shared
23831 .history
23832 .lock()
23833 .unwrap_or_else(|poisoned| poisoned.into_inner())
23834 .events
23835 .iter()
23836 .filter_map(|envelope| match &envelope.event {
23837 NodeEvent::SessionRecordRemoved { record_id } => {
23838 Some(record_id.as_str().to_owned())
23839 }
23840 _ => None,
23841 })
23842 .collect()
23843 }
23844
23845 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23846 shared
23847 .session_records
23848 .lock()
23849 .unwrap_or_else(|poisoned| poisoned.into_inner())
23850 .records
23851 .keys()
23852 .map(|record_id| record_id.as_str().to_owned())
23853 .collect()
23854 }
23855
23856 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23857 values.iter().map(|value| (*value).to_owned()).collect()
23858 }
23859
23860 #[tokio::test]
23861 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23862 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23863 shared.session_record_retention = SessionRecordRetentionConfig {
23864 age_ms: 0,
23865 keep_per_workspace: 1,
23866 };
23867 shared.retire_unavailable_session_records().await;
23868
23869 assert_eq!(
23870 retention_remaining_record_ids(&shared),
23871 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23872 );
23873 assert_eq!(
23874 retention_removed_record_ids(&shared),
23875 owned(&["a1", "a2", "b1", "b2"]),
23876 );
23877 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23878
23879 drop(shared);
23880 drop(runtime);
23881 std::fs::remove_dir_all(root).unwrap();
23882 }
23883
23884 #[tokio::test]
23890 async fn session_record_retention_both_set_retires_only_the_intersection() {
23891 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23892 shared.session_record_retention = SessionRecordRetentionConfig {
23893 age_ms: 250_000,
23894 keep_per_workspace: 1,
23895 };
23896 shared.retire_unavailable_session_records().await;
23897
23898 assert_eq!(
23899 retention_remaining_record_ids(&shared),
23900 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23901 );
23902 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23903 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23904
23905 drop(shared);
23906 drop(runtime);
23907 std::fs::remove_dir_all(root).unwrap();
23908 }
23909
23910 #[tokio::test]
23911 async fn session_record_retention_both_disabled_is_a_no_op() {
23912 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23913 shared.session_record_retention = SessionRecordRetentionConfig::default();
23914 shared.retire_unavailable_session_records().await;
23915
23916 assert_eq!(
23917 retention_remaining_record_ids(&shared),
23918 owned(&[
23919 "a1", "a2", "a3", "b1", "b2", "b3",
23920 "decoy-live", "decoy-pending", "decoy-dormant",
23921 ]),
23922 );
23923 assert!(retention_removed_record_ids(&shared).is_empty());
23924 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23925
23926 drop(shared);
23927 drop(runtime);
23928 std::fs::remove_dir_all(root).unwrap();
23929 }
23930
23931 #[test]
23932 fn session_record_context_export_selects_one_exact_history_identity() {
23933 let candidates = vec![
23934 HistoryCandidateSummary {
23935 id: "candidate-other".to_owned(),
23936 session_id_hint: "provider-other".to_owned(),
23937 modified_at_unix_ms: Some(1),
23938 },
23939 HistoryCandidateSummary {
23940 id: "candidate-exact".to_owned(),
23941 session_id_hint: "provider-exact".to_owned(),
23942 modified_at_unix_ms: Some(2),
23943 },
23944 ];
23945 assert_eq!(
23946 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23947 "candidate-exact",
23948 );
23949 }
23950
23951 #[test]
23952 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23953 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23954 id: id.to_owned(),
23955 session_id_hint: hint.to_owned(),
23956 modified_at_unix_ms: None,
23957 };
23958 let missing = select_history_candidate(
23959 &[candidate("candidate-other", "provider-other")],
23960 Some("provider-exact"),
23961 )
23962 .unwrap_err();
23963 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23964 let ambiguous = select_history_candidate(
23965 &[
23966 candidate("candidate-a", "provider-exact"),
23967 candidate("candidate-b", "provider-exact"),
23968 ],
23969 Some("provider-exact"),
23970 )
23971 .unwrap_err();
23972 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23973 }
23974
23975 #[test]
23983 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23984 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23985 id: id.to_owned(),
23986 session_id_hint: hint.to_owned(),
23987 modified_at_unix_ms: None,
23988 };
23989 assert_eq!(
23990 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23991 .unwrap(),
23992 "candidate-sole",
23993 );
23994 let missing = select_history_candidate(&[], None).unwrap_err();
23995 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23996 let ambiguous = select_history_candidate(
23997 &[
23998 candidate("candidate-a", "sidecar-session-a"),
23999 candidate("candidate-b", "sidecar-session-b"),
24000 ],
24001 None,
24002 )
24003 .unwrap_err();
24004 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24005 }
24006
24007 #[test]
24008 fn session_record_context_export_revalidation_rejects_record_change() {
24009 let session = terminal_address(1);
24010 let identity = ProviderSessionIdentity {
24011 key: ProviderSessionKey::SessionId,
24012 id: "provider-exact".to_owned(),
24013 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24014 };
24015 let mut expected = record("claude", "record-context-export");
24016 expected.state = ManagedSessionState::Live;
24017 expected.provider_session = Some(identity.clone());
24018 expected.active_session = Some(session.clone());
24019 let binding = SessionBinding {
24020 workspace_id: session.workspace_id.clone(),
24021 generation: session.session.generation,
24022 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24023 pending_resume: None,
24024 record_id: Some(expected.record_id.clone()),
24025 managed_worktree_lease_id: None,
24026 environment_profile: None,
24027 bundle: None,
24028 context: None,
24029 materialization_id: None,
24030 };
24031 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24032 assert!(session_record_context_export_binding_is_exact(
24033 &expected,
24034 &expected,
24035 Some(&identity),
24036 &session,
24037 &binding,
24038 &expected.provider,
24039 ¤t_root,
24040 false,
24041 ));
24042 let mut changed = expected.clone();
24043 changed.updated_at_unix_ms += 1;
24044 assert!(!session_record_context_export_binding_is_exact(
24045 &expected,
24046 &changed,
24047 Some(&identity),
24048 &session,
24049 &binding,
24050 &expected.provider,
24051 ¤t_root,
24052 false,
24053 ));
24054 assert!(!session_record_context_export_binding_is_exact(
24059 &expected,
24060 &changed,
24061 Some(&identity),
24062 &session,
24063 &binding,
24064 &expected.provider,
24065 ¤t_root,
24066 true,
24067 ));
24068 let mut detached = expected.clone();
24069 detached.state = ManagedSessionState::Dormant;
24070 detached.active_session = None;
24071 detached.updated_at_unix_ms += 1;
24072 assert!(!session_record_context_export_binding_is_exact(
24073 &expected,
24074 &detached,
24075 Some(&identity),
24076 &session,
24077 &binding,
24078 &expected.provider,
24079 ¤t_root,
24080 false,
24081 ));
24082 assert!(session_record_context_export_binding_is_exact(
24083 &expected,
24084 &detached,
24085 Some(&identity),
24086 &session,
24087 &binding,
24088 &expected.provider,
24089 ¤t_root,
24090 true,
24091 ));
24092 }
24093
24094 #[test]
24101 fn session_record_context_export_binding_is_exact_without_identity() {
24102 let session = terminal_address(1);
24103 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24104 expected.state = ManagedSessionState::Live;
24105 expected.active_session = Some(session.clone());
24106 let binding = SessionBinding {
24107 workspace_id: session.workspace_id.clone(),
24108 generation: session.session.generation,
24109 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24110 pending_resume: None,
24111 record_id: Some(expected.record_id.clone()),
24112 managed_worktree_lease_id: None,
24113 environment_profile: None,
24114 bundle: None,
24115 context: None,
24116 materialization_id: None,
24117 };
24118 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24119 assert!(session_record_context_export_binding_is_exact(
24120 &expected,
24121 &expected,
24122 None,
24123 &session,
24124 &binding,
24125 &expected.provider,
24126 ¤t_root,
24127 false,
24128 ));
24129 let mut drifted = expected.clone();
24133 drifted.provider_session = Some(ProviderSessionIdentity {
24134 key: ProviderSessionKey::SessionId,
24135 id: "unexpected-identity".to_owned(),
24136 transcript_path: None,
24137 });
24138 assert!(!session_record_context_export_binding_is_exact(
24139 &expected,
24140 &drifted,
24141 None,
24142 &session,
24143 &binding,
24144 &expected.provider,
24145 ¤t_root,
24146 false,
24147 ));
24148 }
24149
24150 #[test]
24151 fn session_record_context_export_preflight_requires_live_usable_source() {
24152 let mut source = record("claude", "record-context-export-preflight");
24153 source.state = ManagedSessionState::Live;
24154 assert!(session_record_context_export_source_is_usable(
24155 &source,
24156 &SessionStatus::Running,
24157 false,
24158 ));
24159 assert!(session_record_context_export_source_is_usable(
24160 &source,
24161 &SessionStatus::Exited { exit_code: Some(0) },
24162 false,
24163 ));
24164
24165 source.state = ManagedSessionState::Dormant;
24166 assert!(!session_record_context_export_source_is_usable(
24167 &source,
24168 &SessionStatus::Running,
24169 false,
24170 ));
24171 assert!(session_record_context_export_source_is_usable(
24175 &source,
24176 &SessionStatus::Exited { exit_code: Some(0) },
24177 true,
24178 ));
24179 assert!(!session_record_context_export_source_is_usable(
24180 &source,
24181 &SessionStatus::Exited { exit_code: Some(0) },
24182 false,
24183 ));
24184 source.state = ManagedSessionState::Live;
24185 assert!(!session_record_context_export_source_is_usable(
24186 &source,
24187 &SessionStatus::Starting,
24188 false,
24189 ));
24190 }
24191
24192 #[test]
24193 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24194 let catalog = active_registry().unwrap();
24195 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24196 let workspace = WorkspaceConfig::new(
24197 WorkspaceId::new("primary").unwrap(),
24198 std::env::current_dir().unwrap(),
24199 )
24200 .unwrap();
24201 let shared = NodeShared::new(
24202 handle,
24203 "fixture-token".to_owned(),
24204 NodeId::new("node-context-export-conflict").unwrap(),
24205 vec![workspace],
24206 vec![agent("claude")],
24207 );
24208 let history = HistorySessionRecord {
24209 session_id: "provider-exact".to_owned(),
24210 title: None,
24211 cwd: None,
24212 model: None,
24213 message_count: 1,
24214 completed_turn_count: None,
24215 total_tokens: 0,
24216 messages: vec![gate4agent_types::HistoryMessageRecord {
24217 role: gate4agent_types::HistoryMessageRole::User,
24218 text: "bounded context".to_owned(),
24219 }],
24220 };
24221 let pack = NodeContextPack::export(
24222 ContextPackLineageReceipt {
24223 source_node_id: shared.node_id.clone(),
24224 source_session: terminal_address(1),
24225 source_provider: agent("claude"),
24226 },
24227 &history,
24228 )
24229 .unwrap();
24230 let context_id = pack.receipt().id.clone();
24231 let session = terminal_address(1);
24232 let identity = ProviderSessionIdentity {
24233 key: ProviderSessionKey::SessionId,
24234 id: history.session_id.clone(),
24235 transcript_path: None,
24236 };
24237 let mut expected = record("claude", "record-context-export-final-conflict");
24238 expected.state = ManagedSessionState::Live;
24239 expected.provider_session = Some(identity.clone());
24240 expected.active_session = Some(session.clone());
24241 let mut changed = expected.clone();
24242 changed.updated_at_unix_ms += 1;
24243 shared.insert_record(changed).unwrap();
24244
24245 let error = shared
24246 .commit_context_pack_for_session_record(
24247 &expected,
24248 Some(&identity),
24249 &session,
24250 "candidate-exact",
24251 &history,
24252 pack,
24253 false,
24254 )
24255 .unwrap_err();
24256
24257 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24258 assert!(shared
24259 .context_catalog
24260 .read()
24261 .unwrap_or_else(|poisoned| poisoned.into_inner())
24262 .get(&context_id)
24263 .is_none());
24264 }
24265
24266 #[tokio::test]
24267 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24268 let root = temporary_workspace_root("session-task-binding");
24269 std::fs::create_dir_all(&root).unwrap();
24270 let state_path = root.join("node-state.json");
24271 let catalog = active_registry().unwrap();
24272 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24273 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24274 let node_id = NodeId::new("node-1").unwrap();
24275 let mut shared = NodeShared::new(
24276 handle,
24277 "fixture-token".to_owned(),
24278 node_id.clone(),
24279 vec![workspace],
24280 vec![agent("claude")],
24281 );
24282 shared.state_path = Some(state_path.clone());
24283 let record = record("claude", "sr-task");
24284 let record_id = record.record_id.clone();
24285 shared.insert_record(record).unwrap();
24286 shared.persist_state().unwrap();
24287 shared
24288 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24289 .unwrap();
24290
24291 let minted = process_request_inner(
24292 &shared,
24293 77,
24294 ClientRole::Operator,
24295 NodeRequest::SetSessionTask {
24296 record_id: record_id.clone(),
24297 expected_revision: 0,
24298 target: SessionTaskTargetV1::New,
24299 },
24300 )
24301 .await
24302 .unwrap();
24303 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24304 panic!("session task mutation returned another response");
24305 };
24306 let minted_binding = minted.task_binding.clone().unwrap();
24307 let minted_task_id = minted_binding.task_id.clone().unwrap();
24308 assert_eq!(minted_binding.revision, 1);
24309 assert!(minted_task_id.as_str().starts_with("task-"));
24310 assert_eq!(minted_task_id.as_str().len(), 29);
24311
24312 let event_count = shared.current_sequence();
24313 let repeated = process_request_inner(
24314 &shared,
24315 77,
24316 ClientRole::Operator,
24317 NodeRequest::SetSessionTask {
24318 record_id: record_id.clone(),
24319 expected_revision: 1,
24320 target: SessionTaskTargetV1::Existing {
24321 task_id: minted_task_id.clone(),
24322 },
24323 },
24324 )
24325 .await
24326 .unwrap();
24327 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24328 assert_eq!(shared.current_sequence(), event_count);
24329
24330 let stale = process_request_inner(
24331 &shared,
24332 77,
24333 ClientRole::Operator,
24334 NodeRequest::SetSessionTask {
24335 record_id: record_id.clone(),
24336 expected_revision: 0,
24337 target: SessionTaskTargetV1::Existing {
24338 task_id: minted_task_id,
24339 },
24340 },
24341 )
24342 .await
24343 .unwrap_err();
24344 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24345
24346 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24347 let assigned = process_request_inner(
24348 &shared,
24349 77,
24350 ClientRole::Operator,
24351 NodeRequest::SetSessionTask {
24352 record_id: record_id.clone(),
24353 expected_revision: 1,
24354 target: SessionTaskTargetV1::Existing {
24355 task_id: assigned_id.clone(),
24356 },
24357 },
24358 )
24359 .await
24360 .unwrap();
24361 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24362 panic!("session task assignment returned another response");
24363 };
24364 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24365 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24366
24367 let cleared = process_request_inner(
24368 &shared,
24369 77,
24370 ClientRole::Operator,
24371 NodeRequest::SetSessionTask {
24372 record_id: record_id.clone(),
24373 expected_revision: 2,
24374 target: SessionTaskTargetV1::Clear,
24375 },
24376 )
24377 .await
24378 .unwrap();
24379 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24380 panic!("session task clear returned another response");
24381 };
24382 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24383 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24384 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24385 assert_eq!(
24386 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24387 cleared.updated_at_unix_ms,
24388 );
24389
24390 let persisted = std::fs::read(&state_path).unwrap();
24391 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24392 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24393 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24394 std::fs::remove_dir_all(root).unwrap();
24395 }
24396
24397 #[test]
24398 fn session_task_capability_strips_snapshots_events_and_replies() {
24399 let mut bound = record("claude", "sr-task-projection");
24400 bound.updated_at_unix_ms = 2;
24401 bound.task_binding = Some(SessionTaskBindingV1 {
24402 revision: 1,
24403 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24404 changed_at_unix_ms: 2,
24405 });
24406 let request = NodeRequest::SetSessionTask {
24407 record_id: bound.record_id.clone(),
24408 expected_revision: 1,
24409 target: SessionTaskTargetV1::Clear,
24410 };
24411 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24412 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24413 }));
24414 assert!(!request_uses_unnegotiated_capability(
24415 &request,
24416 &baseline_capabilities().unwrap(),
24417 ));
24418 assert!(request_uses_unnegotiated_capability(&request, &[]));
24419
24420 let mut snapshot = terminal_test_shared().snapshot();
24421 snapshot.session_records.push(bound.clone());
24422 let mut reply = ResponseEnvelope {
24423 request_id: 1,
24424 result: Ok(NodeResponse::Resync {
24425 event_sequence: 1,
24426 oldest_available_sequence: 1,
24427 snapshot,
24428 events: vec![NodeEventEnvelope {
24429 sequence: 1,
24430 event: NodeEvent::SessionRecordUpserted {
24431 record: bound.clone(),
24432 },
24433 }],
24434 }),
24435 };
24436 project_response_without_session_task_binding(&mut reply);
24437 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24438 panic!("task projection changed the response kind");
24439 };
24440 assert!(snapshot.session_records[0].task_binding.is_none());
24441 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24442 panic!("task projection changed the event kind");
24443 };
24444 assert!(record.task_binding.is_none());
24445
24446 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24447 sequence: 2,
24448 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24449 });
24450 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24451 panic!("task event projection changed the event kind");
24452 };
24453 assert!(record.task_binding.is_none());
24454
24455 let mut direct = ResponseEnvelope {
24456 request_id: 2,
24457 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24458 };
24459 project_response_without_session_task_binding(&mut direct);
24460 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24461 panic!("task reply projection changed the response kind");
24462 };
24463 assert!(record.task_binding.is_none());
24464 }
24465
24466 #[test]
24467 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24468 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24469 let workspace = WorkspaceConfig::new(
24470 WorkspaceId::new("primary").unwrap(),
24471 std::env::current_dir().unwrap(),
24472 )
24473 .unwrap();
24474 let config = NodeServerConfig::new(
24475 DEFAULT_NODE_ENDPOINT,
24476 "fixture-token",
24477 NodeId::new("node-1").unwrap(),
24478 [workspace],
24479 )
24480 .unwrap();
24481 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24482 }
24483
24484 #[cfg(all(feature = "fixture", windows))]
24485 #[test]
24486 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24487 let root = temporary_workspace_root("provider-bundle-argv-hold");
24488 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24489 std::fs::create_dir_all(&root).unwrap();
24490 std::fs::create_dir_all(&outside).unwrap();
24491 let proof_path = root.join("provider.proof");
24492 let release_signal = root.join("release.signal");
24493 let config = NodeServerConfig::new(
24494 format!(
24495 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24496 std::process::id(),
24497 ),
24498 "fixture-token",
24499 NodeId::new("provider-bundle-hold-node").unwrap(),
24500 [WorkspaceConfig::new(
24501 WorkspaceId::new("primary").unwrap(),
24502 std::env::current_dir().unwrap(),
24503 )
24504 .unwrap()],
24505 )
24506 .unwrap()
24507 .with_spawn_profiles(SpawnProfileRegistry::new([
24508 crate::protocol::SpawnProfileDefaults {
24509 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24510 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24511 .unwrap(),
24512 provider: agent("kimi"),
24513 mode: SessionMode::Pty,
24514 terminal_size: gate4agent_types::TerminalSize {
24515 rows: 24,
24516 columns: 80,
24517 },
24518 prompt: None,
24519 bundle_id: None,
24520 context_id: None,
24521 environment_profile_id: None,
24522 },
24523 ])
24524 .unwrap());
24525 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24526 config,
24527 agent("kimi"),
24528 proof_path.clone(),
24529 release_signal.clone(),
24530 )
24531 .unwrap();
24532 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24533
24534 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24535 agent("kimi"),
24536 proof_path.clone(),
24537 Some(release_signal.clone()),
24538 )
24539 .unwrap();
24540 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24541 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24542 let release_wait = wrapper
24543 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24544 .unwrap();
24545 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24546 assert!(proof_write < release_wait);
24547 assert!(release_wait < fixture_ready);
24548 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24549 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24550 assert_eq!(
24551 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24552 &[
24553 proof_path.to_string_lossy().into_owned(),
24554 release_signal.to_string_lossy().into_owned(),
24555 ],
24556 );
24557
24558 let error = NodeServer::provider_bundle_argv_fixture_spec(
24559 agent("kimi"),
24560 proof_path,
24561 Some(outside.join("release.signal")),
24562 )
24563 .unwrap_err();
24564 assert!(matches!(
24565 error,
24566 NodeServerError::Registry(message)
24567 if message == "provider bundle argv release path must share the proof parent"
24568 ));
24569 drop(server);
24570 std::fs::remove_dir_all(root).unwrap();
24571 std::fs::remove_dir_all(outside).unwrap();
24572 }
24573
24574 #[cfg(feature = "fixture")]
24575 #[test]
24576 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24577 let root = temporary_workspace_root("context-fixture-registry");
24578 std::fs::create_dir_all(&root).unwrap();
24579 let proof_path = root.join("context.proof");
24580 let config = NodeServerConfig::new(
24581 format!(
24582 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24583 std::process::id(),
24584 ),
24585 "fixture-token",
24586 NodeId::new("context-fixture-node").unwrap(),
24587 [WorkspaceConfig::new(
24588 WorkspaceId::new("primary").unwrap(),
24589 std::env::current_dir().unwrap(),
24590 )
24591 .unwrap()],
24592 )
24593 .unwrap();
24594 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24595 let mut enabled = server
24596 .shared
24597 .enabled_providers
24598 .iter()
24599 .map(AgentId::as_str)
24600 .collect::<Vec<_>>();
24601 enabled.sort_unstable();
24602 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24603 for provider in &server.shared.enabled_providers {
24604 assert!(server
24605 .shared
24606 .provider_adapter_contracts
24607 .iter()
24608 .any(|contract| {
24609 &contract.provider == provider
24610 && contract.family == AdapterFamily::History
24611 }));
24612 }
24613 drop(server);
24614 std::fs::remove_dir_all(root).unwrap();
24615 }
24616
24617 #[cfg(feature = "fixture")]
24618 #[test]
24619 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24620 let root = temporary_workspace_root("monitoring-context-fixture");
24621 std::fs::create_dir_all(&root).unwrap();
24622 let proof_path = root.join("context.proof");
24623 let standard = NodeServer::context_pack_fixture_catalog(
24624 proof_path.clone(),
24625 None,
24626 false,
24627 ).unwrap();
24628 let monitoring = NodeServer::context_pack_fixture_catalog(
24629 proof_path.clone(),
24630 None,
24631 true,
24632 ).unwrap();
24633 let claude = AgentId::new("claude").unwrap();
24634 let standard_claude = standard.get(&claude).unwrap();
24635 let monitoring_claude = monitoring.get(&claude).unwrap();
24636 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24640 assert_eq!(
24641 monitoring_claude.capabilities.adapters.history,
24642 standard_claude.capabilities.adapters.history,
24643 );
24644 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24645 let codex = AgentId::new("codex").unwrap();
24646 let standard_codex = standard.get(&codex).unwrap();
24647 let monitoring_codex = monitoring.get(&codex).unwrap();
24648 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24649 assert_eq!(
24650 monitoring_codex.capabilities.adapters.history,
24651 standard_codex.capabilities.adapters.history,
24652 );
24653 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24654 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24655 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24656 .unwrap();
24657 for required in [
24658 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24659 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24660 "@('role', 'text')",
24661 "g4a-private-provider-session-canary",
24662 "private-provider-login-identity",
24663 "$rawProof",
24664 "$env:GATE4AGENT_HOOK_URL",
24665 "g4a-private-provider-session-canary",
24666 ] {
24667 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24668 }
24669 for provider in ["grok", "kimi"] {
24670 let provider = AgentId::new(provider).unwrap();
24671 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24672 }
24673
24674 let config = NodeServerConfig::new(
24675 format!(
24676 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24677 std::process::id(),
24678 ),
24679 "fixture-token",
24680 NodeId::new("monitoring-context-fixture-node").unwrap(),
24681 [WorkspaceConfig::new(
24682 WorkspaceId::new("primary").unwrap(),
24683 std::env::current_dir().unwrap(),
24684 )
24685 .unwrap()],
24686 )
24687 .unwrap();
24688 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24689 assert!(server.shared.fixture_semantic_hook_policy);
24690 drop(server);
24691 std::fs::remove_dir_all(root).unwrap();
24692 }
24693
24694 #[cfg(feature = "fixture")]
24695 #[test]
24696 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24697 let root = temporary_workspace_root("kimi-context-only-fixture");
24698 std::fs::create_dir_all(&root).unwrap();
24699 let proof_path = root.join("context.proof");
24700 let standard = NodeServer::context_pack_fixture_catalog(
24701 proof_path.clone(),
24702 None,
24703 false,
24704 ).unwrap();
24705 let kimi = AgentId::new("kimi").unwrap();
24706 let context_only = NodeServer::context_pack_fixture_catalog(
24707 proof_path,
24708 Some(&kimi),
24709 false,
24710 )
24711 .unwrap();
24712
24713 let codex = AgentId::new("codex").unwrap();
24714 assert_eq!(
24715 standard.get(&codex).unwrap().launch.fixed_args,
24716 context_only.get(&codex).unwrap().launch.fixed_args,
24717 );
24718 let script = context_only
24719 .get(&kimi)
24720 .unwrap()
24721 .launch
24722 .fixed_args
24723 .iter()
24724 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24725 .expect("Kimi context-only validation script is missing");
24726 for required in [
24727 "$bundleArgs.Count -ne 0",
24728 "GATE4AGENT_CONTEXT_ROOT",
24729 "context-pack.json",
24730 "$entries.Count -ne 1",
24731 "g4a-context-pack-v1",
24732 "$document.PSObject.Properties['cwd']",
24733 "'claude', 'codex', 'grok', 'kimi'",
24734 "$hasUser",
24735 "$hasAssistant",
24736 "SHA256",
24737 "context-only",
24738 ] {
24739 assert!(script.contains(required), "missing validation: {required}");
24740 }
24741 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24742 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24743 }
24744 assert!(!standard
24745 .get(&kimi)
24746 .unwrap()
24747 .launch
24748 .fixed_args
24749 .iter()
24750 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24751
24752 std::fs::remove_dir_all(root).unwrap();
24753 }
24754
24755 #[cfg(all(feature = "fixture", windows))]
24756 #[tokio::test]
24757 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24758 let root = temporary_workspace_root("kimi-context-only-spawn");
24759 let workspace_root = root.join("workspace");
24760 std::fs::create_dir_all(&workspace_root).unwrap();
24761 let materialization_root = root.join("private-materializations");
24762 let proof_path = root.join("context.proof");
24763 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24764 let workspace_id = WorkspaceId::new("primary").unwrap();
24765 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24766 let endpoint = format!(
24767 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24768 std::process::id(),
24769 unix_time_ms(),
24770 );
24771 let spawn_profiles = SpawnProfileRegistry::new([
24772 crate::protocol::SpawnProfileDefaults {
24773 profile_id: profile_id.clone(),
24774 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24775 provider: agent("kimi"),
24776 mode: SessionMode::Pty,
24777 terminal_size: gate4agent_types::TerminalSize {
24778 rows: 30,
24779 columns: 120,
24780 },
24781 prompt: None,
24782 bundle_id: None,
24783 context_id: None,
24784 environment_profile_id: None,
24785 },
24786 ])
24787 .unwrap();
24788 let mut config = NodeServerConfig::new(
24789 &endpoint,
24790 "fixture-token",
24791 node_id.clone(),
24792 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24793 )
24794 .unwrap()
24795 .with_spawn_profiles(spawn_profiles)
24796 .with_session_environment_materialization(
24797 materialization_root,
24798 Arc::new(FixtureSecretResolver {
24799 deny: Arc::new(AtomicBool::new(false)),
24800 }),
24801 )
24802 .unwrap();
24803 config.fixture_raw_pty_runtime = true;
24804 let server = NodeServer::new_context_only_proof_fixture(
24805 config,
24806 agent("kimi"),
24807 proof_path.clone(),
24808 )
24809 .unwrap();
24810 let shared = Arc::clone(&server.shared);
24811 let shutdown = server.shutdown_handle();
24812
24813 let history = HistorySessionRecord {
24814 session_id: "context-only-source-history".to_owned(),
24815 title: None,
24816 cwd: None,
24817 model: None,
24818 message_count: 2,
24819 completed_turn_count: None,
24820 total_tokens: 0,
24821 messages: vec![
24822 gate4agent_types::HistoryMessageRecord {
24823 role: gate4agent_types::HistoryMessageRole::User,
24824 text: "continue the bounded parent run through native C2".to_owned(),
24825 },
24826 gate4agent_types::HistoryMessageRecord {
24827 role: gate4agent_types::HistoryMessageRole::Assistant,
24828 text: "the exact parent context is ready for the child run".to_owned(),
24829 },
24830 ],
24831 };
24832 let pack = NodeContextPack::export(
24833 ContextPackLineageReceipt {
24834 source_node_id: node_id.clone(),
24835 source_session: SessionAddress {
24836 workspace_id: workspace_id.clone(),
24837 session: SessionKey {
24838 instance_id: AgentInstanceId(71),
24839 generation: SessionGeneration(1),
24840 },
24841 },
24842 source_provider: agent("codex"),
24843 },
24844 &history,
24845 )
24846 .unwrap();
24847 let expected_context_hash = digest(&SHA256, pack.bytes())
24848 .as_ref()
24849 .iter()
24850 .map(|byte| format!("{byte:02x}"))
24851 .collect::<String>();
24852 let context = shared
24853 .context_catalog
24854 .write()
24855 .unwrap_or_else(|poisoned| poisoned.into_inner())
24856 .insert(pack)
24857 .unwrap();
24858 let spec = SpawnSpec {
24859 target: crate::protocol::SpawnTarget {
24860 node_id,
24861 workspace_id: workspace_id.clone(),
24862 worktree_id: None,
24863 },
24864 profile_id,
24865 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24866 "target-kimi-r1",
24867 ).unwrap(),
24868 overrides: crate::protocol::SpawnOverrides {
24869 context_id: crate::protocol::SpawnOverride::Set {
24870 value: context.id.clone(),
24871 },
24872 ..crate::protocol::SpawnOverrides::default()
24873 },
24874 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24875 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24876 required_capabilities: SpawnRequiredCapabilities::default(),
24877 };
24878 let resolved = match shared.resolve_spawn_spec(&spec) {
24879 Ok(resolved) => resolved,
24880 Err(error) => panic!(
24881 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24882 error.code,
24883 error.message,
24884 ),
24885 };
24886 let resolved_context = match shared.resolve_context(&resolved) {
24887 Ok(Some(resolved_context)) => resolved_context,
24888 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24889 Err(error) => panic!(
24890 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24891 error.code,
24892 error.message,
24893 ),
24894 };
24895 assert_eq!(resolved_context, context);
24896 let runtime_policy = match shared
24897 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24898 .await
24899 {
24900 Ok(runtime_policy) => runtime_policy,
24901 Err(error) => panic!(
24902 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24903 error.code,
24904 error.message,
24905 ),
24906 };
24907 assert!(runtime_policy.raw_pty_lifecycle);
24908
24909 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24910 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24911 agent("kimi"),
24912 TransportKind::Pty,
24913 vec![EnvMutation {
24914 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24915 value: Some(OsString::from("must-remain-rejected")),
24916 }],
24917 )
24918 .unwrap();
24919 assert_eq!(
24920 launch_control
24921 .install_native_launch_environment_overlay(
24922 AgentInstanceId(72),
24923 generic_overlay,
24924 )
24925 .unwrap_err(),
24926 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24927 );
24928
24929 let diagnostic_address = SessionAddress {
24930 workspace_id: workspace_id.clone(),
24931 session: SessionKey {
24932 instance_id: AgentInstanceId(73),
24933 generation: SessionGeneration(1),
24934 },
24935 };
24936 let (diagnostic_overlay, diagnostic_materialization) = match shared
24937 .prepare_session_materialization(
24938 &diagnostic_address,
24939 &agent("kimi"),
24940 SessionMode::Pty,
24941 None,
24942 None,
24943 Some(&resolved_context),
24944 None,
24945 )
24946 {
24947 Ok(Some(prepared)) => prepared,
24948 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24949 Err(error) => panic!(
24950 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24951 error.code,
24952 error.message,
24953 ),
24954 };
24955 let diagnostic_overlay = diagnostic_overlay
24956 .expect("Kimi context-only materialization omitted its environment overlay");
24957 assert!(matches!(
24958 &diagnostic_overlay,
24959 PreparedNativeLaunchOverlay::Environment(_),
24960 ));
24961 match shared.install_prepared_launch_overlay(
24962 diagnostic_address.session.instance_id,
24963 diagnostic_overlay,
24964 ) {
24965 Ok(None) => {}
24966 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24967 Err(error) => panic!(
24968 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24969 error.code,
24970 error.message,
24971 ),
24972 }
24973 assert!(launch_control.clear_native_instance_launch_overlay(
24974 diagnostic_address.session.instance_id,
24975 ));
24976 drop(diagnostic_materialization);
24977 assert!(shared
24978 .materializations
24979 .lock()
24980 .unwrap_or_else(|poisoned| poisoned.into_inner())
24981 .is_empty());
24982
24983 let node_task = tokio::spawn(server.run());
24984 let ready_client = timeout(Duration::from_secs(5), async {
24985 loop {
24986 match gate4agent_node_wire::LocalNodeClient::connect(
24987 &endpoint,
24988 &shared.node_id,
24989 ClientRole::Observer,
24990 "fixture-token",
24991 )
24992 .await
24993 {
24994 Ok(client) => break client,
24995 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24996 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24997 }
24998 }
24999 })
25000 .await
25001 .expect("Kimi context-only Node did not become ready");
25002 assert_eq!(
25003 ready_client
25004 .hello()
25005 .snapshot
25006 .provider_runtime_statuses
25007 .iter()
25008 .find(|status| status.provider().as_str() == "kimi")
25009 .expect("Kimi runtime status is missing")
25010 .mode(),
25011 crate::protocol::ProviderRuntimeMode::RawPassthrough,
25012 );
25013 drop(ready_client);
25014
25015 let receipt = match shared.spawn_from_spec(spec).await {
25016 Ok(receipt) => receipt,
25017 Err(error) => panic!(
25018 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25019 error.code,
25020 error.message,
25021 ),
25022 };
25023 assert_eq!(receipt.provider, agent("kimi"));
25024 assert_eq!(receipt.context.as_ref(), Some(&context));
25025 assert!(receipt.context_binding_is_valid());
25026 timeout(Duration::from_secs(5), async {
25027 while !proof_path.is_file() {
25028 sleep(Duration::from_millis(10)).await;
25029 }
25030 })
25031 .await
25032 .expect("Kimi context-only fixture did not write its proof");
25033
25034 let proof = std::fs::read_to_string(&proof_path).unwrap();
25035 let lines = proof.lines().collect::<Vec<_>>();
25036 assert_eq!(lines.len(), 7);
25037 assert_eq!(lines[0], "context-only");
25038 assert!(Path::new(lines[1]).is_absolute());
25039 assert_ne!(
25040 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25041 std::fs::canonicalize(&workspace_root).unwrap(),
25042 );
25043 assert_eq!(
25044 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25045 std::fs::canonicalize(&workspace_root).unwrap(),
25046 );
25047 assert_eq!(lines[3], expected_context_hash);
25048 assert_eq!(lines[4], "g4a-context-pack-v1");
25049 assert_eq!(lines[5], "codex");
25050 assert_eq!(lines[6], "2");
25051 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25052 session.instance_id == receipt.session.session.instance_id
25053 && session.agent_id.as_str() == "kimi"
25054 }));
25055
25056 shutdown.request_shutdown().await.unwrap();
25057 timeout(Duration::from_secs(5), node_task)
25058 .await
25059 .unwrap()
25060 .unwrap()
25061 .unwrap();
25062 drop(shutdown);
25063 drop(shared);
25064 std::fs::remove_dir_all(root).unwrap();
25065 }
25066
25067 #[cfg(feature = "fixture")]
25068 #[test]
25069 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25070 let root = temporary_workspace_root("clean-exit-constructor");
25071 std::fs::create_dir_all(&root).unwrap();
25072 let config = construction_test_config("clean-exit-constructor")
25073 .with_state_path(root.join("node-state.json"))
25074 .unwrap();
25075 let server = NodeServer::new_clean_exit_fixture(
25076 config,
25077 root.clone(),
25078 root.join("started.marker"),
25079 root.join("release.signal"),
25080 )
25081 .unwrap();
25082 assert_eq!(
25083 server
25084 .shared
25085 .enabled_providers
25086 .iter()
25087 .map(AgentId::as_str)
25088 .collect::<Vec<_>>(),
25089 ["claude"],
25090 );
25091 drop(server);
25092 std::fs::remove_dir_all(root).unwrap();
25093 }
25094
25095 #[test]
25096 fn production_registry_provider_contract_manifest_is_exact() {
25097 let registry = active_registry().unwrap();
25098 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25099 assert_eq!(
25100 providers
25101 .iter()
25102 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25103 .collect::<Vec<_>>(),
25104 vec![
25105 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25106 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25107 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25108 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25109 ]
25110 );
25111 assert_eq!(
25112 adapters
25113 .iter()
25114 .map(|contract| (
25115 contract.provider.as_str(),
25116 contract.family,
25117 contract.adapter_id.as_str(),
25118 contract.revision.as_str(),
25119 ))
25120 .collect::<Vec<_>>(),
25121 vec![
25122 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25123 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25124 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25125 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25126 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25127 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25128 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25129 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25130 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25131 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25132 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25133 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25134 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25135 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25136 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25137 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25138 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25139 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25140 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25141 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25142 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25143 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25144 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25145 ]
25146 );
25147 }
25148
25149 fn construction_test_config(name: &str) -> NodeServerConfig {
25150 let workspace = WorkspaceConfig::new(
25151 WorkspaceId::new("primary").unwrap(),
25152 std::env::current_dir().unwrap(),
25153 )
25154 .unwrap();
25155 NodeServerConfig::new(
25156 format!(r"\\.\pipe\gate4agent-{name}"),
25157 "fixture-token",
25158 NodeId::new(name).unwrap(),
25159 [workspace],
25160 )
25161 .unwrap()
25162 }
25163
25164 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25165 let adapter = AdapterBinding::new(
25166 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25167 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25168 gate4agent_types::AdapterVerification::SyntheticFixture,
25169 )
25170 .unwrap();
25171 let agent_ids = ["claude", "codex", "kimi"]
25172 .into_iter()
25173 .map(|agent_id| AgentId::new(agent_id).unwrap())
25174 .collect::<Vec<_>>();
25175 let adapters = gate4agent_catalog::AdapterRegistry::new(
25176 [
25177 AdapterFamily::PtySemantic,
25178 AdapterFamily::Pipe,
25179 AdapterFamily::OneShot,
25180 AdapterFamily::Acp,
25181 AdapterFamily::Hook,
25182 AdapterFamily::ManagedHook,
25183 AdapterFamily::History,
25184 AdapterFamily::Resume,
25185 AdapterFamily::SessionOptions,
25186 AdapterFamily::CapabilityProbe,
25187 ]
25188 .into_iter()
25189 .map(|family| gate4agent_catalog::AdapterDescriptor {
25190 family,
25191 binding: adapter.clone(),
25192 agents: agent_ids.clone(),
25193 }),
25194 )
25195 .unwrap();
25196 let specs = agent_ids
25197 .iter()
25198 .map(|agent_id| {
25199 let agent_id = agent_id.as_str();
25200 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25201 spec.revision =
25202 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25203 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25204 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25205 adapter: adapter.clone(),
25206 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25207 launch_override: None,
25208 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25209 });
25210 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25211 adapter: adapter.clone(),
25212 launch_override: None,
25213 });
25214 spec.capabilities.adapters.hook = Some(adapter.clone());
25215 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25216 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25217 spec.capabilities.adapters.history = Some(adapter.clone());
25218 spec.capabilities.adapters.resume = Some(adapter.clone());
25219 spec.capabilities.adapters.session_options = Some(adapter.clone());
25220 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25221 spec
25222 })
25223 .collect::<Vec<_>>();
25224 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25225 }
25226
25227 #[test]
25235 fn node_server_construction_accepts_the_production_manifest() {
25236 let registry = active_registry().unwrap();
25237 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25238 assert_eq!(providers.len(), registry.iter().count());
25239 assert!(!providers.is_empty());
25240 let server = NodeServer::new_with_registry(
25241 construction_test_config("manifest-production-bound"),
25242 registry,
25243 )
25244 .unwrap();
25245 drop(server);
25246 }
25247
25248 #[test]
25249 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25250 let registry = maximum_size_provider_manifest_registry();
25251 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25252 assert_eq!(providers.len(), 3);
25253 assert_eq!(adapters.len(), 30);
25254 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25255 let error = match NodeServer::new_with_registry(
25256 construction_test_config("manifest-handshake-overflow"),
25257 registry,
25258 ) {
25259 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25260 Err(error) => error,
25261 };
25262 assert!(matches!(
25263 error,
25264 NodeServerError::ProviderContractManifest(message)
25265 if message.contains("authentication binding length")
25266 && message.contains("8192-byte limit")
25267 ));
25268 }
25269
25270 #[test]
25271 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25272 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25273 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25274 let registry = AgentRegistry::new([spec]).unwrap();
25275 assert!(matches!(
25276 provider_contract_manifest(®istry),
25277 Err(NodeServerError::ProviderContractManifest(message))
25278 if message.contains("exceeds the 128-byte limit")
25279 ));
25280 }
25281
25282 #[test]
25283 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25284 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25285 spec.revision = "invalid revision".to_owned();
25286 let registry = AgentRegistry::new([spec]).unwrap();
25287 assert!(matches!(
25288 provider_contract_manifest(®istry),
25289 Err(NodeServerError::ProviderContractManifest(message))
25290 if message.contains("bounded lowercase ASCII")
25291 ));
25292 }
25293
25294 #[test]
25295 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25296 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25297 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25298 let conflicting_binding = builtin_registry()
25299 .get_by_id("codex")
25300 .unwrap()
25301 .capabilities
25302 .adapters
25303 .one_shot
25304 .clone()
25305 .unwrap();
25306 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25307 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25308 pipe.adapter = transport_binding;
25309 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25310 let registry = AgentRegistry::new([spec]).unwrap();
25311 assert!(matches!(
25312 provider_contract_manifest(®istry),
25313 Err(NodeServerError::ProviderContractManifest(message))
25314 if message.contains("conflicting OneShot adapter bindings")
25315 ));
25316 }
25317
25318 #[test]
25319 fn public_node_failures_expose_only_fixed_categories() {
25320 let provider = failure(
25321 NodeFailureCode::BackendOperationFailed,
25322 r"provider bearer-secret failed at C:\private\session.jsonl",
25323 );
25324 assert_eq!(provider.message, "backend-operation-failed");
25325 assert!(!provider.message.contains("bearer-secret"));
25326 assert!(!provider.message.contains(r"C:\private"));
25327 let persistence = persistence_failure(io::Error::new(
25328 io::ErrorKind::Other,
25329 r"state write failed at C:\private\state-v1.json",
25330 ));
25331 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25332 assert!(!persistence.message.contains(r"C:\private"));
25333
25334 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25335 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25336 assert_eq!(
25337 generic.to_string(),
25338 "node durable state failed: durable-state-load-failed",
25339 );
25340 }
25341 }
25342
25343 #[test]
25344 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25345 let sequence = b"\x1b[1;5D".to_vec();
25346 assert_eq!(
25347 terminal_bytes_action(sequence.clone()).unwrap(),
25348 InputAction::TerminalBytes(sequence),
25349 );
25350 assert_eq!(
25351 terminal_bytes_action(Vec::new()).unwrap_err().code,
25352 NodeFailureCode::InvalidRequest,
25353 );
25354 assert_eq!(
25355 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25356 .unwrap_err()
25357 .code,
25358 NodeFailureCode::InvalidRequest,
25359 );
25360 }
25361
25362 #[test]
25363 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25364 let root = temporary_workspace_root("exclusive-state-owner");
25365 std::fs::create_dir_all(&root).unwrap();
25366 let workspace = WorkspaceConfig::new(
25367 WorkspaceId::new("primary").unwrap(),
25368 &root,
25369 )
25370 .unwrap();
25371 let state_path = root.join("state-v1.json");
25372 let endpoint = format!(
25373 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25374 std::process::id(),
25375 std::time::SystemTime::now()
25376 .duration_since(std::time::UNIX_EPOCH)
25377 .unwrap()
25378 .as_nanos(),
25379 );
25380 let config = NodeServerConfig::new(
25381 endpoint,
25382 "fixture-token",
25383 NodeId::new("node-1").unwrap(),
25384 [workspace],
25385 )
25386 .unwrap()
25387 .with_state_path(&state_path)
25388 .unwrap();
25389
25390 let first = NodeServer::new(config.clone()).unwrap();
25391 let error = match NodeServer::new(config.clone()) {
25392 Ok(_) => panic!("a second node server acquired the same durable state path"),
25393 Err(error) => error,
25394 };
25395 assert!(matches!(
25396 &error,
25397 NodeServerError::DurableState(error)
25398 if error.kind() == io::ErrorKind::WouldBlock
25399 ));
25400 assert_eq!(
25401 error.to_string(),
25402 "node durable state failed: durable-state-lock-failed",
25403 );
25404 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25405 drop(first);
25406 let second = NodeServer::new(config).unwrap();
25407 drop(second);
25408 std::fs::remove_dir_all(root).unwrap();
25409 }
25410
25411 #[test]
25412 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25413 let catalog = active_registry().unwrap();
25414 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25415 let workspace_id = WorkspaceId::new("primary").unwrap();
25416 let workspace = WorkspaceConfig::new(
25417 workspace_id.clone(),
25418 std::env::current_dir().unwrap(),
25419 )
25420 .unwrap();
25421 let shared = NodeShared::new(
25422 handle,
25423 "fixture-token".to_owned(),
25424 NodeId::new("node-1").unwrap(),
25425 vec![workspace.clone()],
25426 vec![agent("claude"), agent("codex"), agent("kimi")],
25427 );
25428 let instance_id = AgentInstanceId(17);
25429 let generation = SessionGeneration(3);
25430 assert!(shared.address_for(instance_id, generation).is_none());
25431 shared.bind_session(instance_id, workspace_id.clone(), generation);
25432 let address = SessionAddress {
25433 workspace_id,
25434 session: SessionKey { instance_id, generation },
25435 };
25436 assert_eq!(
25437 shared.workspace_root(&address.workspace_id).unwrap(),
25438 workspace.canonical_root,
25439 );
25440 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25441 shared.remove_binding(&address);
25442 assert!(shared.address_for(instance_id, generation).is_none());
25443 }
25444
25445 #[test]
25446 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25447 let address = SessionAddress {
25448 workspace_id: WorkspaceId::new("primary").unwrap(),
25449 session: SessionKey {
25450 instance_id: AgentInstanceId(7),
25451 generation: SessionGeneration(2),
25452 },
25453 };
25454 let record_id = SessionRecordId::new("record-a").unwrap();
25455 let size = gate4agent_types::TerminalSize {
25456 rows: 24,
25457 columns: 80,
25458 };
25459 let address_requests = [
25460 NodeRequest::Resume {
25461 session: address.clone(),
25462 terminal_size: size,
25463 initial_prompt: None,
25464 },
25465 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25466 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25467 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25468 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25469 NodeRequest::TerminalControl {
25470 session: address.clone(),
25471 control: TerminalControl::Enter,
25472 },
25473 NodeRequest::Resize { session: address.clone(), size },
25474 NodeRequest::Interrupt { session: address.clone() },
25475 NodeRequest::Stop { session: address.clone(), force: false },
25476 NodeRequest::Remove { session: address },
25477 ];
25478 for request in &address_requests {
25479 assert!(request_requires_open_provider_ids_with(
25480 request,
25481 |_| Some(agent("third-party-agent")),
25482 |_| Some(agent("claude")),
25483 ));
25484 assert!(!request_requires_open_provider_ids_with(
25485 request,
25486 |_| Some(agent("claude")),
25487 |_| Some(agent("claude")),
25488 ));
25489 assert!(request_requires_open_provider_ids_with(
25490 request,
25491 |_| None,
25492 |_| Some(agent("claude")),
25493 ));
25494 }
25495
25496 let record_requests = [
25497 NodeRequest::RenameSessionRecord {
25498 record_id: record_id.clone(),
25499 display_name: "renamed".to_owned(),
25500 },
25501 NodeRequest::ResumeSessionRecord {
25502 record_id: record_id.clone(),
25503 terminal_size: size,
25504 initial_prompt: None,
25505 },
25506 NodeRequest::ForgetSessionRecord { record_id },
25507 ];
25508 for request in &record_requests {
25509 assert!(request_requires_open_provider_ids_with(
25510 request,
25511 |_| Some(agent("claude")),
25512 |_| Some(agent("grok")),
25513 ));
25514 assert!(!request_requires_open_provider_ids_with(
25515 request,
25516 |_| Some(agent("claude")),
25517 |_| Some(agent("kimi")),
25518 ));
25519 assert!(request_requires_open_provider_ids_with(
25520 request,
25521 |_| Some(agent("claude")),
25522 |_| None,
25523 ));
25524 }
25525
25526 assert!(request_requires_open_provider_ids_with(
25527 &NodeRequest::Spawn {
25528 workspace_id: WorkspaceId::new("primary").unwrap(),
25529 provider: agent("third-party-agent"),
25530 mode: SessionMode::Pty,
25531 terminal_size: size,
25532 initial_prompt: None,
25533 },
25534 |_| None,
25535 |_| None,
25536 ));
25537 assert!(!request_requires_open_provider_ids_with(
25538 &NodeRequest::Snapshot,
25539 |_| None,
25540 |_| None,
25541 ));
25542 assert!(request_requires_open_provider_ids_with(
25543 &NodeRequest::ForgetContextPack {
25544 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25545 },
25546 |_| None,
25547 |_| None,
25548 ));
25549 }
25550
25551 #[test]
25552 fn legacy_projection_filters_open_record_replies_and_event_history() {
25553 let catalog = active_registry().unwrap();
25554 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25555 let workspace = WorkspaceConfig::new(
25556 WorkspaceId::new("primary").unwrap(),
25557 std::env::current_dir().unwrap(),
25558 )
25559 .unwrap();
25560 let shared = NodeShared::new(
25561 handle,
25562 "fixture-token".to_owned(),
25563 NodeId::new("node-1").unwrap(),
25564 vec![workspace],
25565 vec![agent("claude"), agent("grok")],
25566 );
25567 let legacy = record("claude", "legacy-record");
25568 let open = record("grok", "open-record");
25569 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25570 record: legacy.clone(),
25571 });
25572 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25573 record: open.clone(),
25574 });
25575 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25576 record_id: legacy.record_id.clone(),
25577 });
25578 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25579 record_id: open.record_id.clone(),
25580 });
25581 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25582 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25583 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25584 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25585
25586 let mut record_reply = ResponseEnvelope {
25587 request_id: 1,
25588 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25589 };
25590 project_response_legacy_provider_ids(&shared, &mut record_reply);
25591 assert!(matches!(
25592 record_reply.result,
25593 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25594 ));
25595
25596 let mut resync = ResponseEnvelope {
25597 request_id: 2,
25598 result: Ok(NodeResponse::Resync {
25599 event_sequence: open_removed.sequence,
25600 oldest_available_sequence: 1,
25601 snapshot: shared.snapshot(),
25602 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25603 }),
25604 };
25605 project_response_legacy_provider_ids(&shared, &mut resync);
25606 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25607 panic!("legacy projection changed the resync response kind");
25608 };
25609 assert!(snapshot.session_records.iter().all(|item| {
25610 provider_id_is_legacy(&item.provider)
25611 }));
25612 assert_eq!(events.len(), 2);
25613 assert!(events.iter().all(|event| matches!(
25614 &event.event,
25615 NodeEvent::SessionRecordUpserted { record }
25616 if provider_id_is_legacy(&record.provider)
25617 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25618 }
25619
25620 #[test]
25621 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25622 let catalog = active_registry().unwrap();
25623 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25624 let workspace_id = WorkspaceId::new("primary").unwrap();
25625 let workspace = WorkspaceConfig::new(
25626 workspace_id.clone(),
25627 std::env::current_dir().unwrap(),
25628 )
25629 .unwrap();
25630 let shared = NodeShared::new(
25631 handle,
25632 "fixture-token".to_owned(),
25633 NodeId::new("node-1").unwrap(),
25634 vec![workspace],
25635 vec![agent("claude")],
25636 );
25637 for index in 0..CONTROL_SESSIONS_MAX {
25638 shared.bind_session(
25639 AgentInstanceId((index + 1) as u64),
25640 workspace_id.clone(),
25641 SessionGeneration(1),
25642 );
25643 }
25644 let error = shared.ensure_binding_capacity().unwrap_err();
25645 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25646 }
25647
25648 #[tokio::test]
25649 async fn provider_spawn_admission_rejects_before_session_mutation() {
25650 let catalog = active_registry().unwrap();
25651 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25652 let workspace = WorkspaceConfig::new(
25653 WorkspaceId::new("primary").unwrap(),
25654 std::env::current_dir().unwrap(),
25655 )
25656 .unwrap();
25657 let mut shared = NodeShared::new(
25658 handle,
25659 "fixture-token".to_owned(),
25660 NodeId::new("node-provider-admission").unwrap(),
25661 vec![workspace],
25662 vec![agent("claude")],
25663 );
25664 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25665 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25666 ])
25667 .unwrap();
25668 let terminal_size = gate4agent_types::TerminalSize {
25669 rows: 24,
25670 columns: 80,
25671 };
25672
25673 let unavailable = shared
25674 .spawn_session(
25675 WorkspaceId::new("primary").unwrap(),
25676 agent("claude"),
25677 SessionMode::Pty,
25678 terminal_size,
25679 None,
25680 )
25681 .await
25682 .unwrap_err();
25683 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25684 assert_eq!(unavailable.message, "backend-operation-failed");
25685
25686 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25687 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25688 agent("claude"),
25689 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25690 ),
25691 ])
25692 .unwrap();
25693 let semantic = shared
25694 .spawn_session(
25695 WorkspaceId::new("primary").unwrap(),
25696 agent("claude"),
25697 SessionMode::Inline,
25698 terminal_size,
25699 None,
25700 )
25701 .await
25702 .unwrap_err();
25703 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25704 assert_eq!(semantic.message, "unsupported-capability");
25705
25706 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25707 assert!(shared
25708 .session_bindings
25709 .lock()
25710 .unwrap_or_else(|poisoned| poisoned.into_inner())
25711 .is_empty());
25712 assert!(shared
25713 .session_records
25714 .lock()
25715 .unwrap_or_else(|poisoned| poisoned.into_inner())
25716 .records
25717 .is_empty());
25718 assert!(shared.handle.snapshot().sessions.is_empty());
25719 }
25720
25721 #[tokio::test]
25722 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25723 let catalog = active_registry().unwrap();
25724 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25725 let workspace_id = WorkspaceId::new("primary").unwrap();
25726 let workspace = WorkspaceConfig::new(
25727 workspace_id.clone(),
25728 std::env::current_dir().unwrap(),
25729 )
25730 .unwrap();
25731 let shared = NodeShared::new(
25732 handle,
25733 "fixture-token".to_owned(),
25734 NodeId::new("node-raw-spawn-record").unwrap(),
25735 vec![workspace],
25736 vec![agent("claude")],
25737 );
25738 let address = SessionAddress {
25739 workspace_id,
25740 session: SessionKey {
25741 instance_id: AgentInstanceId(71),
25742 generation: SessionGeneration::default(),
25743 },
25744 };
25745
25746 let record_id = shared
25747 .bind_spawn_session(
25748 &address,
25749 agent("claude"),
25750 SessionMode::Pty,
25751 ProviderRuntimePolicy::raw_pty(),
25752 SpawnRecordPolicy::Always,
25753 None,
25754 )
25755 .unwrap()
25756 .unwrap();
25757 shared
25758 .handle
25759 .dispatch(shared.prepare_command(ControlCommand::Register {
25760 instance_id: address.session.instance_id,
25761 agent_id: agent("claude"),
25762 transport: TransportKind::Pty,
25763 }))
25764 .unwrap();
25765 runtime.tick().await;
25766
25767 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25768 session.instance_id == address.session.instance_id
25769 && session.generation == address.session.generation
25770 }));
25771 let record = shared.record(&record_id).unwrap();
25772 assert_eq!(record.display_name, "claude #1");
25773 assert_eq!(record.state, ManagedSessionState::Live);
25774 assert_eq!(record.active_session.as_ref(), Some(&address));
25775 assert!(record.provider_session.is_none());
25776 let binding_record_id = shared
25777 .session_bindings
25778 .lock()
25779 .unwrap_or_else(|poisoned| poisoned.into_inner())
25780 .get(&address.session.instance_id)
25781 .and_then(|binding| binding.record_id.clone());
25782 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25783
25784 let renamed = shared
25785 .rename_session_record(&record_id, "raw review".to_owned())
25786 .unwrap();
25787 assert_eq!(renamed.record_id, record_id);
25788 assert_eq!(renamed.display_name, "raw review");
25789 shared.reconcile_managed_record(
25790 &record_id,
25791 &address,
25792 &ControlEvent {
25793 sequence: 1,
25794 command_id: None,
25795 instance_id: address.session.instance_id,
25796 generation: address.session.generation,
25797 event: ControlEventKind::Running { process_id: None },
25798 },
25799 false,
25800 false,
25801 );
25802 let running = shared.record(&record_id).unwrap();
25803 assert_eq!(running.state, ManagedSessionState::Live);
25804 assert_eq!(running.active_session.as_ref(), Some(&address));
25805 assert_eq!(running.display_name, "raw review");
25806
25807 shared.reconcile_managed_record(
25808 &record_id,
25809 &address,
25810 &ControlEvent {
25811 sequence: 2,
25812 command_id: None,
25813 instance_id: address.session.instance_id,
25814 generation: address.session.generation,
25815 event: ControlEventKind::Exited {
25816 exit_code: Some(0),
25817 forced: false,
25818 },
25819 },
25820 false,
25821 false,
25822 );
25823 let detached = shared.record(&record_id).unwrap();
25824 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25825 assert!(detached.active_session.is_none());
25826 assert_eq!(detached.display_name, "raw review");
25827 let renamed_detached = shared
25828 .rename_session_record(&record_id, "raw review detached".to_owned())
25829 .unwrap();
25830 assert_eq!(renamed_detached.record_id, record_id);
25831 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25832 assert!(renamed_detached.active_session.is_none());
25833 assert_eq!(renamed_detached.display_name, "raw review detached");
25834 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25835 }
25836
25837 #[test]
25838 fn legacy_raw_spawn_binding_remains_untracked() {
25839 let catalog = active_registry().unwrap();
25840 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25841 let workspace_id = WorkspaceId::new("primary").unwrap();
25842 let workspace = WorkspaceConfig::new(
25843 workspace_id.clone(),
25844 std::env::current_dir().unwrap(),
25845 )
25846 .unwrap();
25847 let shared = NodeShared::new(
25848 handle,
25849 "fixture-token".to_owned(),
25850 NodeId::new("node-legacy-raw-spawn").unwrap(),
25851 vec![workspace],
25852 vec![agent("claude")],
25853 );
25854 let address = SessionAddress {
25855 workspace_id,
25856 session: SessionKey {
25857 instance_id: AgentInstanceId(72),
25858 generation: SessionGeneration::default(),
25859 },
25860 };
25861
25862 let record_id = shared
25863 .bind_spawn_session(
25864 &address,
25865 agent("claude"),
25866 SessionMode::Pty,
25867 ProviderRuntimePolicy::raw_pty(),
25868 SpawnRecordPolicy::ProviderIdentityOnly,
25869 None,
25870 )
25871 .unwrap();
25872
25873 assert!(record_id.is_none());
25874 let binding_record_id = shared
25875 .session_bindings
25876 .lock()
25877 .unwrap_or_else(|poisoned| poisoned.into_inner())
25878 .get(&address.session.instance_id)
25879 .and_then(|binding| binding.record_id.clone());
25880 assert!(binding_record_id.is_none());
25881 assert!(shared.snapshot().session_records.is_empty());
25882 }
25883
25884 #[tokio::test]
25885 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25886 let catalog = active_registry().unwrap();
25887 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25888 let workspace_id = WorkspaceId::new("primary").unwrap();
25889 let workspace = WorkspaceConfig::new(
25890 workspace_id.clone(),
25891 std::env::current_dir().unwrap(),
25892 )
25893 .unwrap();
25894 let mut shared = NodeShared::new(
25895 handle,
25896 "fixture-token".to_owned(),
25897 NodeId::new("node-raw-semantic-gate").unwrap(),
25898 vec![workspace],
25899 vec![agent("claude")],
25900 );
25901 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25902 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25903 ])
25904 .unwrap();
25905 shared
25906 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25907 .unwrap();
25908 let address = SessionAddress {
25909 workspace_id,
25910 session: SessionKey {
25911 instance_id: AgentInstanceId(72),
25912 generation: SessionGeneration::default(),
25913 },
25914 };
25915 shared.bind_session_with_policy(
25916 &address,
25917 ProviderRuntimePolicy::raw_pty(),
25918 None,
25919 );
25920 shared
25921 .handle
25922 .dispatch(shared.prepare_command(ControlCommand::Register {
25923 instance_id: address.session.instance_id,
25924 agent_id: agent("claude"),
25925 transport: TransportKind::Pty,
25926 }))
25927 .unwrap();
25928 runtime.tick().await;
25929 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25930 let revision_before = shared.handle.snapshot().revision;
25931 let history_sequence_before = shared.current_sequence();
25932 let terminal_size = gate4agent_types::TerminalSize {
25933 rows: 24,
25934 columns: 80,
25935 };
25936
25937 for request in [
25938 NodeRequest::Prompt {
25939 session: address.clone(),
25940 text: "prompt".to_owned(),
25941 },
25942 NodeRequest::Paste {
25943 session: address.clone(),
25944 text: "paste".to_owned(),
25945 },
25946 NodeRequest::Resume {
25952 session: address.clone(),
25953 terminal_size,
25954 initial_prompt: Some("resume".to_owned()),
25955 },
25956 ] {
25957 let error = process_request_inner(
25958 &shared,
25959 77,
25960 ClientRole::Operator,
25961 request,
25962 )
25963 .await
25964 .unwrap_err();
25965 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25966 }
25967
25968 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25969 assert_eq!(shared.handle.snapshot().revision, revision_before);
25970 assert_eq!(shared.current_sequence(), history_sequence_before);
25971 let bindings = shared
25972 .session_bindings
25973 .lock()
25974 .unwrap_or_else(|poisoned| poisoned.into_inner());
25975 let binding = bindings.get(&address.session.instance_id).unwrap();
25976 assert_eq!(binding.generation, address.session.generation);
25977 assert!(binding.pending_resume.is_none());
25978 assert!(binding.record_id.is_none());
25979 }
25980
25981 #[tokio::test]
25982 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25983 let catalog = active_registry().unwrap();
25984 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25985 let primary = WorkspaceConfig::new(
25986 WorkspaceId::new("primary").unwrap(),
25987 std::env::current_dir().unwrap(),
25988 )
25989 .unwrap();
25990 let shared = NodeShared::new(
25991 handle,
25992 "fixture-token".to_owned(),
25993 NodeId::new("node-1").unwrap(),
25994 vec![primary],
25995 vec![agent("claude")],
25996 );
25997 shared
25998 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25999 .unwrap();
26000 let before = shared.snapshot();
26001 let error = process_request_inner(
26002 &shared,
26003 77,
26004 ClientRole::Operator,
26005 NodeRequest::RegisterWorkspace {
26006 workspace_id: WorkspaceId::new("foreign").unwrap(),
26007 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26008 },
26009 )
26010 .await
26011 .unwrap_err();
26012 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26013 assert_eq!(error.message, "invalid-request");
26014 assert_eq!(shared.snapshot().workspaces, before.workspaces);
26015 }
26016
26017 #[tokio::test]
26018 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26019 let catalog = active_registry().unwrap();
26020 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26021 let primary_id = WorkspaceId::new("primary").unwrap();
26022 let primary = WorkspaceConfig::new(
26023 primary_id.clone(),
26024 std::env::current_dir().unwrap(),
26025 )
26026 .unwrap();
26027 let shared = NodeShared::new(
26028 handle,
26029 "fixture-token".to_owned(),
26030 NodeId::new("node-1").unwrap(),
26031 vec![primary.clone()],
26032 vec![agent("claude")],
26033 );
26034 let secondary_id = WorkspaceId::new("secondary").unwrap();
26035 let secondary = shared
26036 .register_workspace(
26037 secondary_id.clone(),
26038 std::env::temp_dir().to_string_lossy().into_owned(),
26039 )
26040 .await
26041 .unwrap();
26042 assert_eq!(secondary.workspace_id, secondary_id);
26043 assert!(secondary.sessions.is_empty());
26044 assert_eq!(shared.snapshot().workspaces.len(), 2);
26045
26046 let duplicate_id = shared
26047 .register_workspace(
26048 secondary_id.clone(),
26049 primary.canonical_root().to_owned(),
26050 )
26051 .await
26052 .unwrap_err();
26053 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26054 let duplicate_root = shared
26055 .register_workspace(
26056 WorkspaceId::new("duplicate-root").unwrap(),
26057 windows_path_text(&secondary.canonical_root).to_owned(),
26058 )
26059 .await
26060 .unwrap_err();
26061 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26062
26063 let address = SessionAddress {
26064 workspace_id: secondary_id.clone(),
26065 session: SessionKey {
26066 instance_id: AgentInstanceId(41),
26067 generation: SessionGeneration(1),
26068 },
26069 };
26070 shared.bind_session(
26071 address.session.instance_id,
26072 secondary_id.clone(),
26073 address.session.generation,
26074 );
26075 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26076 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26077 shared.remove_binding(&address);
26078 shared.unregister_workspace(&secondary_id).unwrap();
26079 assert_eq!(shared.snapshot().workspaces.len(), 1);
26080
26081 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26082 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26083 let history = shared
26084 .history
26085 .lock()
26086 .unwrap_or_else(|poisoned| poisoned.into_inner());
26087 assert!(matches!(
26088 &history.events[0],
26089 NodeEventEnvelope {
26090 sequence: 1,
26091 event: NodeEvent::WorkspaceAdded { workspace },
26092 } if workspace.workspace_id == secondary_id
26093 ));
26094 assert!(matches!(
26095 &history.events[1],
26096 NodeEventEnvelope {
26097 sequence: 2,
26098 event: NodeEvent::WorkspaceRemoved { workspace_id },
26099 } if workspace_id == &secondary_id
26100 ));
26101 }
26102
26103 #[tokio::test]
26104 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26105 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26106 std::fs::create_dir_all(&secondary_root).unwrap();
26107 let catalog = active_registry().unwrap();
26108 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26109 let primary_id = WorkspaceId::new("primary").unwrap();
26110 let secondary_id = WorkspaceId::new("secondary").unwrap();
26111 let shared = NodeShared::new(
26112 handle,
26113 "fixture-token".to_owned(),
26114 NodeId::new("node-session-reference-index").unwrap(),
26115 vec![
26116 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26117 .unwrap(),
26118 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26119 ],
26120 vec![agent("claude"), agent("codex")],
26121 );
26122 let identity = ProviderSessionIdentity {
26123 key: gate4agent_types::ProviderSessionKey::SessionId,
26124 id: "provider-session-42".to_owned(),
26125 transcript_path: None,
26126 };
26127 let index_request = |workspace_id: WorkspaceId,
26128 provider: AgentId,
26129 identity: ProviderSessionIdentity,
26130 display_name: &str| {
26131 NodeRequest::IndexProviderSession {
26132 workspace_id,
26133 provider,
26134 identity,
26135 display_name: display_name.to_owned(),
26136 }
26137 };
26138 let request = index_request(
26139 primary_id.clone(),
26140 agent("claude"),
26141 identity.clone(),
26142 "release shepherd",
26143 );
26144 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26145 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26146 }));
26147 let observer = process_request_inner(
26148 &shared,
26149 77,
26150 ClientRole::Observer,
26151 request.clone(),
26152 )
26153 .await
26154 .unwrap_err();
26155 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26156 let uncontrolled = process_request_inner(
26157 &shared,
26158 77,
26159 ClientRole::Operator,
26160 request.clone(),
26161 )
26162 .await
26163 .unwrap_err();
26164 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26165 shared
26166 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26167 .unwrap();
26168
26169 let indexed = process_request_inner(
26170 &shared,
26171 77,
26172 ClientRole::Operator,
26173 request.clone(),
26174 )
26175 .await
26176 .unwrap();
26177 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26178 panic!("provider session reference index returned another response");
26179 };
26180 assert_eq!(record.state, ManagedSessionState::Dormant);
26181 assert_eq!(record.mode, SessionMode::Pty);
26182 assert_eq!(record.workspace_id, primary_id);
26183 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26184 assert!(record.active_session.is_none());
26185 assert!(record.environment_profile.is_none());
26186 assert!(record.bundle.is_none());
26187 assert!(record.context.is_none());
26188
26189 let repeated = process_request_inner(
26190 &shared,
26191 77,
26192 ClientRole::Operator,
26193 index_request(
26194 primary_id.clone(),
26195 agent("claude"),
26196 identity.clone(),
26197 "ignored replacement",
26198 ),
26199 )
26200 .await
26201 .unwrap();
26202 assert!(matches!(
26203 repeated,
26204 NodeResponse::ProviderSessionIndexed { record: ref existing }
26205 if existing.record_id == record.record_id
26206 && existing.display_name == "release shepherd"
26207 ));
26208 let conflicting_workspace = process_request_inner(
26209 &shared,
26210 77,
26211 ClientRole::Operator,
26212 index_request(
26213 secondary_id,
26214 agent("claude"),
26215 identity.clone(),
26216 "release shepherd",
26217 ),
26218 )
26219 .await
26220 .unwrap_err();
26221 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26222 let conflicting_provider = process_request_inner(
26223 &shared,
26224 77,
26225 ClientRole::Operator,
26226 index_request(
26227 primary_id.clone(),
26228 agent("codex"),
26229 identity.clone(),
26230 "release shepherd",
26231 ),
26232 )
26233 .await
26234 .unwrap_err();
26235 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26236 let transcript_rejected = process_request_inner(
26237 &shared,
26238 77,
26239 ClientRole::Operator,
26240 index_request(
26241 primary_id,
26242 agent("claude"),
26243 ProviderSessionIdentity {
26244 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26245 ..identity
26246 },
26247 "release shepherd",
26248 ),
26249 )
26250 .await
26251 .unwrap_err();
26252 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26253 let upserts = shared
26254 .history
26255 .lock()
26256 .unwrap_or_else(|poisoned| poisoned.into_inner())
26257 .events
26258 .iter()
26259 .filter(|event| matches!(
26260 &event.event,
26261 NodeEvent::SessionRecordUpserted { record: published }
26262 if published.record_id == record.record_id
26263 ))
26264 .count();
26265 assert_eq!(upserts, 1);
26266 std::fs::remove_dir_all(secondary_root).unwrap();
26267 }
26268
26269 #[tokio::test]
26270 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26271 let fixture_root = temporary_workspace_root("standalone-independent");
26272 let target = fixture_root.join("independent");
26273 std::fs::create_dir_all(&fixture_root).unwrap();
26274 let catalog = active_registry().unwrap();
26275 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26276 let selected = WorkspaceConfig::new(
26277 WorkspaceId::new("primary").unwrap(),
26278 std::env::current_dir().unwrap(),
26279 )
26280 .unwrap();
26281 let selected_root = selected.canonical_root().to_owned();
26282 let shared = NodeShared::new(
26283 handle,
26284 "fixture-token".to_owned(),
26285 NodeId::new("node-standalone").unwrap(),
26286 vec![selected],
26287 vec![agent("claude")],
26288 );
26289 let workspace_id = WorkspaceId::new("independent").unwrap();
26290 let request = NodeRequest::CreateStandaloneWorkspace {
26291 workspace_id: workspace_id.clone(),
26292 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26293 initial_branch: Some("main".to_owned()),
26294 };
26295 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26296 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26297 }));
26298 assert!(!request_uses_unnegotiated_capability(
26299 &request,
26300 &baseline_capabilities().unwrap(),
26301 ));
26302 assert!(request_uses_unnegotiated_capability(&request, &[]));
26303
26304 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26305 .await
26306 .unwrap_err();
26307 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26308 assert!(!target.exists());
26309 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26310 .await
26311 .unwrap_err();
26312 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26313 assert!(!target.exists());
26314 shared
26315 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26316 .unwrap();
26317
26318 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26319 .await
26320 .unwrap();
26321 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26322 panic!("standalone workspace creation returned another response");
26323 };
26324 assert_eq!(workspace.workspace_id, workspace_id);
26325 assert!(target.join(".git").is_dir());
26326 let branch = run_git_bounded(
26327 target.to_str().unwrap(),
26328 &["branch", "--show-current"],
26329 GIT_OUTPUT_MAX_BYTES,
26330 )
26331 .await
26332 .unwrap();
26333 assert!(branch.success);
26334 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26335 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26336 assert_eq!(worktrees.len(), 1);
26337 let listed_root = WorkspaceConfig::new(
26338 WorkspaceId::new("listed-independent").unwrap(),
26339 &worktrees[0].path,
26340 )
26341 .unwrap();
26342 assert!(platform::roots_equal(
26343 listed_root.canonical_root(),
26344 windows_path_text(&workspace.canonical_root),
26345 ));
26346 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26347 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26348 snapshot.workspace_id == workspace_id
26349 && snapshot.canonical_root == workspace.canonical_root
26350 }));
26351 assert!(shared
26352 .history
26353 .lock()
26354 .unwrap_or_else(|poisoned| poisoned.into_inner())
26355 .events
26356 .iter()
26357 .any(|event| matches!(
26358 &event.event,
26359 NodeEvent::WorkspaceAdded { workspace }
26360 if workspace.workspace_id == workspace_id
26361 )));
26362 std::fs::remove_dir_all(fixture_root).unwrap();
26363 }
26364
26365 #[tokio::test]
26366 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26367 let fixture_root = temporary_workspace_root("standalone-compensation");
26368 let created_target = fixture_root.join("created-target");
26369 let existing_target = fixture_root.join("existing-target");
26370 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26371 let invalid_state_path = invalid_state_parent.join("state.json");
26372 std::fs::create_dir_all(&existing_target).unwrap();
26373 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26374 let catalog = active_registry().unwrap();
26375 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26376 let primary = WorkspaceConfig::new(
26377 WorkspaceId::new("primary").unwrap(),
26378 std::env::current_dir().unwrap(),
26379 )
26380 .unwrap();
26381 let mut shared = NodeShared::new(
26382 handle,
26383 "fixture-token".to_owned(),
26384 NodeId::new("node-standalone-compensation").unwrap(),
26385 vec![primary],
26386 vec![agent("claude")],
26387 );
26388 shared.state_path = Some(invalid_state_path);
26389
26390 let created_recovery = shared
26391 .create_standalone_workspace(
26392 WorkspaceId::new("compensated").unwrap(),
26393 created_target.to_string_lossy().into_owned(),
26394 Some("main".to_owned()),
26395 )
26396 .await
26397 .unwrap_err();
26398 assert_eq!(
26399 created_recovery.code,
26400 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26401 );
26402 assert!(created_target.join(".git").is_dir());
26403 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26404 workspace.workspace_id.as_str() != "compensated"
26405 }));
26406
26407 let existing_recovery = shared
26408 .create_standalone_workspace(
26409 WorkspaceId::new("restored").unwrap(),
26410 existing_target.to_string_lossy().into_owned(),
26411 None,
26412 )
26413 .await
26414 .unwrap_err();
26415 assert_eq!(
26416 existing_recovery.code,
26417 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26418 );
26419 assert!(existing_target.is_dir());
26420 assert!(existing_target.join(".git").is_dir());
26421 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26422 workspace.workspace_id.as_str() != "restored"
26423 }));
26424
26425 let registered_empty = fixture_root.join("registered-empty");
26426 std::fs::create_dir(®istered_empty).unwrap();
26427 let catalog = active_registry().unwrap();
26428 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26429 let registered = WorkspaceConfig::new(
26430 WorkspaceId::new("registered-empty").unwrap(),
26431 ®istered_empty,
26432 )
26433 .unwrap();
26434 let duplicate_shared = NodeShared::new(
26435 handle,
26436 "fixture-token".to_owned(),
26437 NodeId::new("node-standalone-duplicate-root").unwrap(),
26438 vec![registered],
26439 vec![agent("claude")],
26440 );
26441 let duplicate = duplicate_shared
26442 .create_standalone_workspace(
26443 WorkspaceId::new("duplicate-root").unwrap(),
26444 registered_empty.to_string_lossy().into_owned(),
26445 None,
26446 )
26447 .await
26448 .unwrap_err();
26449 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26450 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26451 std::fs::remove_dir_all(fixture_root).unwrap();
26452 }
26453
26454 #[test]
26455 fn managed_record_id_collision_preserves_the_existing_record() {
26456 let catalog = active_registry().unwrap();
26457 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26458 let workspace = WorkspaceConfig::new(
26459 WorkspaceId::new("primary").unwrap(),
26460 std::env::current_dir().unwrap(),
26461 )
26462 .unwrap();
26463 let shared = NodeShared::new(
26464 handle,
26465 "fixture-token".to_owned(),
26466 NodeId::new("node-1").unwrap(),
26467 vec![workspace.clone()],
26468 vec![agent("claude")],
26469 );
26470 let record_id = SessionRecordId::new("sr-collision").unwrap();
26471 let original = ManagedSessionRecord {
26472 record_id: record_id.clone(),
26473 display_name: "original".to_owned(),
26474 provider: agent("claude"),
26475 mode: SessionMode::Pty,
26476 state: ManagedSessionState::Dormant,
26477 workspace_id: workspace.workspace_id,
26478 canonical_root: opaque_windows_path(workspace.canonical_root),
26479 provider_session: None,
26480 active_session: None,
26481 environment_profile: None,
26482 bundle: None,
26483 context_id: None,
26484 context: None,
26485 exported_context: None,
26486 task_binding: None,
26487 created_at_unix_ms: 1,
26488 updated_at_unix_ms: 1,
26489 last_error: None,
26490 };
26491 shared.insert_record(original.clone()).unwrap();
26492 let mut collision = original.clone();
26493 collision.display_name = "replacement".to_owned();
26494 let error = shared.insert_record(collision).unwrap_err();
26495 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26496 assert_eq!(shared.record(&record_id).unwrap(), original);
26497 }
26498
26499 #[test]
26500 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26501 let catalog = active_registry().unwrap();
26502 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26503 let workspace = WorkspaceConfig::new(
26504 WorkspaceId::new("primary").unwrap(),
26505 std::env::current_dir().unwrap(),
26506 )
26507 .unwrap();
26508 let shared = NodeShared::new(
26509 handle,
26510 "fixture-token".to_owned(),
26511 NodeId::new("node-1").unwrap(),
26512 vec![workspace.clone()],
26513 vec![agent("claude")],
26514 );
26515 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26516 shared
26517 .insert_record(ManagedSessionRecord {
26518 record_id: record_id.clone(),
26519 display_name: "runtime error".to_owned(),
26520 provider: agent("claude"),
26521 mode: SessionMode::Pty,
26522 state: ManagedSessionState::IdentityPending,
26523 workspace_id: workspace.workspace_id,
26524 canonical_root: opaque_windows_path(workspace.canonical_root),
26525 provider_session: None,
26526 active_session: None,
26527 environment_profile: None,
26528 bundle: None,
26529 context_id: None,
26530 context: None,
26531 exported_context: None,
26532 task_binding: None,
26533 created_at_unix_ms: 1,
26534 updated_at_unix_ms: 1,
26535 last_error: None,
26536 })
26537 .unwrap();
26538
26539 shared
26540 .mark_record_error(
26541 &record_id,
26542 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26543 )
26544 .unwrap();
26545 let record = shared.record(&record_id).unwrap();
26546 assert_eq!(record.state, ManagedSessionState::Unavailable);
26547 assert_eq!(
26548 record.last_error.as_deref(),
26549 Some("provider-runtime-failed"),
26550 );
26551 let snapshot = shared.snapshot();
26552 assert_eq!(snapshot.session_records, vec![record]);
26553 }
26554
26555 #[test]
26564 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26565 let catalog = active_registry().unwrap();
26566 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26567 let workspace = WorkspaceConfig::new(
26568 WorkspaceId::new("primary").unwrap(),
26569 std::env::current_dir().unwrap(),
26570 )
26571 .unwrap();
26572 let shared = NodeShared::new(
26573 handle,
26574 "fixture-token".to_owned(),
26575 NodeId::new("node-1").unwrap(),
26576 vec![workspace],
26577 vec![agent("claude")],
26578 );
26579
26580 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26581
26582 shared.record_rejected_command(
26583 CommandId(1),
26584 "agent 'claude' does not support transport Acp".to_owned(),
26585 true,
26586 );
26587 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26589
26590 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26591 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26592 assert!(reason.unsupported_transport);
26593
26594 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26596
26597 shared.record_rejected_command(
26602 CommandId(3),
26603 "backend busy".to_owned(),
26604 false,
26605 );
26606 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26607 assert!(!unclassified.unsupported_transport);
26608 }
26609
26610 #[test]
26611 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26612 let catalog = active_registry().unwrap();
26613 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26614 let workspace = WorkspaceConfig::new(
26615 WorkspaceId::new("primary").unwrap(),
26616 std::env::current_dir().unwrap(),
26617 )
26618 .unwrap();
26619 let shared = NodeShared::new(
26620 handle,
26621 "fixture-token".to_owned(),
26622 NodeId::new("node-identity-policy").unwrap(),
26623 vec![workspace.clone()],
26624 vec![agent("claude")],
26625 );
26626 let address = SessionAddress {
26627 workspace_id: workspace.workspace_id.clone(),
26628 session: SessionKey {
26629 instance_id: AgentInstanceId(90),
26630 generation: SessionGeneration(1),
26631 },
26632 };
26633 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26634 shared
26635 .insert_record(ManagedSessionRecord {
26636 record_id: record_id.clone(),
26637 display_name: "policy denied".to_owned(),
26638 provider: agent("claude"),
26639 mode: SessionMode::Pty,
26640 state: ManagedSessionState::IdentityPending,
26641 workspace_id: workspace.workspace_id,
26642 canonical_root: opaque_windows_path(workspace.canonical_root),
26643 provider_session: None,
26644 active_session: Some(address.clone()),
26645 environment_profile: None,
26646 bundle: None,
26647 context_id: None,
26648 context: None,
26649 exported_context: None,
26650 task_binding: None,
26651 created_at_unix_ms: 1,
26652 updated_at_unix_ms: 1,
26653 last_error: None,
26654 })
26655 .unwrap();
26656 let event = ControlEvent {
26657 sequence: 1,
26658 command_id: None,
26659 instance_id: address.session.instance_id,
26660 generation: address.session.generation,
26661 event: ControlEventKind::ProviderEvent {
26662 sequence: 1,
26663 source: gate4agent_types::ProviderSource {
26664 family: AdapterFamily::PtySemantic,
26665 binding: AdapterBinding::new(
26666 gate4agent_types::AdapterId::new("claude").unwrap(),
26667 "fixture/v1",
26668 gate4agent_types::AdapterVerification::SyntheticFixture,
26669 )
26670 .unwrap(),
26671 },
26672 source_sequence: 1,
26673 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26674 identity: gate4agent_types::ProviderSessionIdentity {
26675 key: gate4agent_types::ProviderSessionKey::SessionId,
26676 id: "provider-session".to_owned(),
26677 transcript_path: None,
26678 },
26679 },
26680 },
26681 };
26682
26683 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26684
26685 let record = shared.record(&record_id).unwrap();
26686 assert!(record.provider_session.is_none());
26687 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26688 assert_eq!(record.active_session.as_ref(), Some(&address));
26689 }
26690
26691 #[test]
26692 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26693 let secondary_root = temporary_workspace_root("identity-scope");
26694 std::fs::create_dir_all(&secondary_root).unwrap();
26695 let catalog = active_registry().unwrap();
26696 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26697 let primary = WorkspaceConfig::new(
26698 WorkspaceId::new("primary").unwrap(),
26699 std::env::current_dir().unwrap(),
26700 )
26701 .unwrap();
26702 let secondary = WorkspaceConfig::new(
26703 WorkspaceId::new("secondary").unwrap(),
26704 &secondary_root,
26705 )
26706 .unwrap();
26707 let shared = NodeShared::new(
26708 handle,
26709 "fixture-token".to_owned(),
26710 NodeId::new("node-1").unwrap(),
26711 vec![primary.clone(), secondary.clone()],
26712 vec![agent("claude")],
26713 );
26714 let identity = gate4agent_types::ProviderSessionIdentity {
26715 key: gate4agent_types::ProviderSessionKey::SessionId,
26716 id: "provider-session".to_owned(),
26717 transcript_path: None,
26718 };
26719 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26720 shared
26721 .insert_record(ManagedSessionRecord {
26722 record_id: dormant_id.clone(),
26723 display_name: "dormant".to_owned(),
26724 provider: agent("claude"),
26725 mode: SessionMode::Pty,
26726 state: ManagedSessionState::Dormant,
26727 workspace_id: primary.workspace_id,
26728 canonical_root: opaque_windows_path(primary.canonical_root),
26729 provider_session: Some(identity.clone()),
26730 active_session: None,
26731 environment_profile: None,
26732 bundle: None,
26733 context_id: None,
26734 context: None,
26735 exported_context: None,
26736 task_binding: None,
26737 created_at_unix_ms: 1,
26738 updated_at_unix_ms: 1,
26739 last_error: None,
26740 })
26741 .unwrap();
26742 let address = SessionAddress {
26743 workspace_id: secondary.workspace_id.clone(),
26744 session: SessionKey {
26745 instance_id: AgentInstanceId(91),
26746 generation: SessionGeneration(1),
26747 },
26748 };
26749 let current_id = SessionRecordId::new("sr-current").unwrap();
26750 shared
26751 .insert_record(ManagedSessionRecord {
26752 record_id: current_id.clone(),
26753 display_name: "current".to_owned(),
26754 provider: agent("claude"),
26755 mode: SessionMode::Pty,
26756 state: ManagedSessionState::IdentityPending,
26757 workspace_id: secondary.workspace_id,
26758 canonical_root: opaque_windows_path(secondary.canonical_root),
26759 provider_session: None,
26760 active_session: Some(address.clone()),
26761 environment_profile: None,
26762 bundle: None,
26763 context_id: None,
26764 context: None,
26765 exported_context: None,
26766 task_binding: None,
26767 created_at_unix_ms: 2,
26768 updated_at_unix_ms: 2,
26769 last_error: None,
26770 })
26771 .unwrap();
26772 shared.bind_managed_session(
26773 &address,
26774 current_id.clone(),
26775 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26776 None,
26777 );
26778 let event = ControlEvent {
26779 sequence: 1,
26780 command_id: None,
26781 instance_id: address.session.instance_id,
26782 generation: address.session.generation,
26783 event: ControlEventKind::ProviderEvent {
26784 sequence: 1,
26785 source: gate4agent_types::ProviderSource {
26786 family: gate4agent_types::AdapterFamily::PtySemantic,
26787 binding: gate4agent_types::AdapterBinding::new(
26788 gate4agent_types::AdapterId::new("claude").unwrap(),
26789 "fixture/v1",
26790 gate4agent_types::AdapterVerification::SyntheticFixture,
26791 )
26792 .unwrap(),
26793 },
26794 source_sequence: 1,
26795 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26796 identity,
26797 },
26798 },
26799 };
26800 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26801
26802 let binding = shared
26803 .session_bindings
26804 .lock()
26805 .unwrap_or_else(|poisoned| poisoned.into_inner())
26806 .get(&address.session.instance_id)
26807 .cloned()
26808 .unwrap();
26809 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26810 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26811 let current = shared.record(¤t_id).unwrap();
26812 assert_eq!(current.state, ManagedSessionState::Unavailable);
26813 assert_eq!(current.active_session.as_ref(), Some(&address));
26814 assert_eq!(
26815 current.last_error.as_deref(),
26816 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26817 );
26818 std::fs::remove_dir_all(secondary_root).unwrap();
26819 }
26820
26821 #[tokio::test]
26822 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26823 let root = temporary_workspace_root("semantic-provider-session");
26824 std::fs::create_dir_all(&root).unwrap();
26825 let state_path = root.join("state-v1.json");
26826 let catalog = active_registry().unwrap();
26827 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26828 let workspace = WorkspaceConfig::new(
26829 WorkspaceId::new("primary").unwrap(),
26830 &root,
26831 )
26832 .unwrap();
26833 let node_id = NodeId::new("node-semantic-identity").unwrap();
26834 let shared = NodeShared::new_with_incarnation(
26835 handle,
26836 "fixture-token".to_owned(),
26837 node_id.clone(),
26838 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26839 vec![workspace.clone()],
26840 vec![agent("claude")],
26841 ProviderRuntimeStatuses::default(),
26842 None,
26843 Vec::new(),
26844 Vec::new(),
26845 SpawnProfileRegistry::default(),
26846 None,
26847 None,
26848 None,
26849 Some(state_path.clone()),
26850 Vec::new(),
26851 Vec::new(),
26852 Vec::new(),
26853 Vec::new(),
26854 None,
26855 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26856 );
26857 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26858 key: gate4agent_types::ProviderSessionKey::SessionId,
26859 id: "provider-session".to_owned(),
26860 transcript_path: None,
26861 };
26862 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26863 shared
26864 .insert_record(ManagedSessionRecord {
26865 record_id: durable_id.clone(),
26866 display_name: "durable".to_owned(),
26867 provider: agent("claude"),
26868 mode: SessionMode::Pty,
26869 state: ManagedSessionState::Dormant,
26870 workspace_id: workspace.workspace_id.clone(),
26871 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26872 provider_session: Some(persisted_identity.clone()),
26873 active_session: None,
26874 environment_profile: None,
26875 bundle: None,
26876 context_id: None,
26877 context: None,
26878 exported_context: None,
26879 task_binding: None,
26880 created_at_unix_ms: 1,
26881 updated_at_unix_ms: 1,
26882 last_error: None,
26883 })
26884 .unwrap();
26885 let address = SessionAddress {
26886 workspace_id: workspace.workspace_id.clone(),
26887 session: SessionKey {
26888 instance_id: AgentInstanceId(92),
26889 generation: SessionGeneration(2),
26890 },
26891 };
26892 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26893 shared
26894 .insert_record(ManagedSessionRecord {
26895 record_id: pending_id.clone(),
26896 display_name: "pending".to_owned(),
26897 provider: agent("claude"),
26898 mode: SessionMode::Pty,
26899 state: ManagedSessionState::IdentityPending,
26900 workspace_id: workspace.workspace_id.clone(),
26901 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26902 provider_session: None,
26903 active_session: Some(address.clone()),
26904 environment_profile: None,
26905 bundle: None,
26906 context_id: None,
26907 context: None,
26908 exported_context: None,
26909 task_binding: None,
26910 created_at_unix_ms: 2,
26911 updated_at_unix_ms: 2,
26912 last_error: None,
26913 })
26914 .unwrap();
26915 shared.bind_managed_session(
26916 &address,
26917 pending_id.clone(),
26918 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26919 None,
26920 );
26921 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26922 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26923 ..persisted_identity
26924 };
26925 let event = ControlEvent {
26926 sequence: 1,
26927 command_id: None,
26928 instance_id: address.session.instance_id,
26929 generation: address.session.generation,
26930 event: ControlEventKind::ProviderEvent {
26931 sequence: 1,
26932 source: gate4agent_types::ProviderSource {
26933 family: gate4agent_types::AdapterFamily::PtySemantic,
26934 binding: gate4agent_types::AdapterBinding::new(
26935 gate4agent_types::AdapterId::new("claude").unwrap(),
26936 "fixture/v1",
26937 gate4agent_types::AdapterVerification::SyntheticFixture,
26938 )
26939 .unwrap(),
26940 },
26941 source_sequence: 1,
26942 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26943 identity: observed_identity.clone(),
26944 },
26945 },
26946 };
26947 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26948
26949 let snapshot = shared.snapshot();
26950 assert_eq!(snapshot.session_records.len(), 1);
26951 let live = &snapshot.session_records[0];
26952 assert_eq!(live.record_id, durable_id);
26953 assert_eq!(live.state, ManagedSessionState::Live);
26954 assert_eq!(live.active_session.as_ref(), Some(&address));
26955 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26956 let binding = shared
26957 .session_bindings
26958 .lock()
26959 .unwrap_or_else(|poisoned| poisoned.into_inner())
26960 .get(&address.session.instance_id)
26961 .cloned()
26962 .unwrap();
26963 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26964
26965 let mut duplicate = live.clone();
26966 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26967 duplicate.display_name = "duplicate".to_owned();
26968 duplicate.state = ManagedSessionState::Dormant;
26969 duplicate.active_session = None;
26970 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26971 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26972 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26973 assert_eq!(shared.snapshot().session_records.len(), 1);
26974
26975 let terminal_size = gate4agent_types::TerminalSize {
26976 rows: 24,
26977 columns: 80,
26978 };
26979 let (first_busy, second_busy) = tokio::join!(
26980 shared.resume_session_record(&durable_id, terminal_size, None),
26981 shared.resume_session_record(&durable_id, terminal_size, None),
26982 );
26983 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26984 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26985 assert_eq!(busy.message, "session-record-busy");
26986 }
26987
26988 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26989 assert_eq!(loaded.records.len(), 1);
26990 assert_eq!(loaded.records[0].record_id, durable_id);
26991 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26992 assert_eq!(
26993 loaded.records[0]
26994 .provider_session
26995 .as_ref()
26996 .unwrap()
26997 .transcript_path,
26998 None,
26999 );
27000 let catalog = active_registry().unwrap();
27001 let (reloaded_handle, _reloaded_runtime) =
27002 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27003 let reloaded = NodeShared::new_with_incarnation(
27004 reloaded_handle,
27005 "fixture-token".to_owned(),
27006 node_id,
27007 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27008 vec![workspace],
27009 vec![agent("claude")],
27010 ProviderRuntimeStatuses::default(),
27011 None,
27012 Vec::new(),
27013 Vec::new(),
27014 SpawnProfileRegistry::default(),
27015 None,
27016 None,
27017 None,
27018 None,
27019 loaded.records,
27020 loaded.managed_worktrees,
27021 loaded.managed_worktree_tombstones,
27022 loaded.materializations,
27023 loaded.warning,
27024 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27025 );
27026 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27027 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27028 std::fs::remove_dir_all(root).unwrap();
27029 }
27030
27031 #[tokio::test]
27032 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27033 let secondary_root = temporary_workspace_root("workspace-restore");
27034 let other_root = temporary_workspace_root("workspace-conflict");
27035 std::fs::create_dir_all(&secondary_root).unwrap();
27036 std::fs::create_dir_all(&other_root).unwrap();
27037 let catalog = active_registry().unwrap();
27038 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27039 let primary = WorkspaceConfig::new(
27040 WorkspaceId::new("primary").unwrap(),
27041 std::env::current_dir().unwrap(),
27042 )
27043 .unwrap();
27044 let secondary = WorkspaceConfig::new(
27045 WorkspaceId::new("secondary").unwrap(),
27046 &secondary_root,
27047 )
27048 .unwrap();
27049 let shared = NodeShared::new(
27050 handle,
27051 "fixture-token".to_owned(),
27052 NodeId::new("node-1").unwrap(),
27053 vec![primary, secondary.clone()],
27054 vec![agent("claude")],
27055 );
27056 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27057 shared
27058 .insert_record(ManagedSessionRecord {
27059 record_id: record_id.clone(),
27060 display_name: "workspace record".to_owned(),
27061 provider: agent("claude"),
27062 mode: SessionMode::Pty,
27063 state: ManagedSessionState::Dormant,
27064 workspace_id: secondary.workspace_id.clone(),
27065 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27066 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27067 key: gate4agent_types::ProviderSessionKey::SessionId,
27068 id: "provider-session".to_owned(),
27069 transcript_path: None,
27070 }),
27071 active_session: None,
27072 environment_profile: None,
27073 bundle: None,
27074 context_id: None,
27075 context: None,
27076 exported_context: None,
27077 task_binding: None,
27078 created_at_unix_ms: 1,
27079 updated_at_unix_ms: 1,
27080 last_error: None,
27081 })
27082 .unwrap();
27083 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27084 let unavailable = shared.record(&record_id).unwrap();
27085 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27086 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27087
27088 let conflict = shared
27089 .register_workspace(
27090 secondary.workspace_id.clone(),
27091 other_root.to_string_lossy().into_owned(),
27092 )
27093 .await
27094 .unwrap_err();
27095 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27096 shared
27097 .register_workspace(
27098 secondary.workspace_id,
27099 secondary_root.to_string_lossy().into_owned(),
27100 )
27101 .await
27102 .unwrap();
27103 let restored = shared.record(&record_id).unwrap();
27104 assert_eq!(restored.state, ManagedSessionState::Dormant);
27105 assert!(restored.last_error.is_none());
27106 std::fs::remove_dir_all(secondary_root).unwrap();
27107 std::fs::remove_dir_all(other_root).unwrap();
27108 }
27109
27110 #[test]
27111 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27112 let error = validate_workspace_request_root(
27113 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27114 )
27115 .unwrap_err();
27116 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27117 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27118 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27119 }
27120
27121 #[test]
27122 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27123 let catalog = active_registry().unwrap();
27124 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27125 let workspace = WorkspaceConfig::new(
27126 WorkspaceId::new("primary").unwrap(),
27127 std::env::current_dir().unwrap(),
27128 )
27129 .unwrap();
27130 let shared = NodeShared::new(
27131 handle,
27132 "fixture-token".to_owned(),
27133 NodeId::new("node-1").unwrap(),
27134 vec![workspace],
27135 vec![agent("claude")],
27136 );
27137 let publishers = 4_u64;
27138 let events_per_publisher = 256_u64;
27139 let start = std::sync::Barrier::new(publishers as usize + 1);
27140 let finished = AtomicU64::new(0);
27141
27142 std::thread::scope(|scope| {
27143 for _ in 0..publishers {
27144 scope.spawn(|| {
27145 start.wait();
27146 for _ in 0..events_per_publisher {
27147 shared.publish(NodeEvent::ControllerChanged { controller: None });
27148 }
27149 finished.fetch_add(1, Ordering::Release);
27150 });
27151 }
27152 start.wait();
27153 while finished.load(Ordering::Acquire) != publishers {
27154 assert_resync_history_is_linearized(shared.resync(0));
27155 std::thread::yield_now();
27156 }
27157 });
27158 let response = shared.resync(0);
27159 assert_resync_history_is_linearized(response.clone());
27160 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27161 unreachable!("resync helper returned another response");
27162 };
27163 assert_eq!(event_sequence, publishers * events_per_publisher);
27164 assert_eq!(events.len() as u64, event_sequence);
27165 }
27166
27167 fn assert_resync_history_is_linearized(response: NodeResponse) {
27168 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27169 unreachable!("resync helper returned another response");
27170 };
27171 assert!(events.windows(2).all(|pair| {
27172 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27173 }));
27174 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27175 }
27176
27177 #[test]
27178 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27179 assert_eq!(
27180 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27181 r"C:\repo\worktree",
27182 );
27183 assert_eq!(
27184 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27185 r"\\server\share\repo",
27186 );
27187 assert!(validate_workspace_root(
27188 &WorkspaceId::new("network").unwrap(),
27189 r"\\server\share\repo",
27190 )
27191 .is_err());
27192 let workspace = WorkspaceConfig::new(
27193 WorkspaceId::new("primary").unwrap(),
27194 std::env::current_dir().unwrap(),
27195 )
27196 .unwrap();
27197 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27198 }
27199
27200 #[test]
27201 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27202 let catalog = active_registry().unwrap();
27203 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27204 let workspace_id = WorkspaceId::new("primary").unwrap();
27205 let workspace = WorkspaceConfig::new(
27206 workspace_id.clone(),
27207 std::env::current_dir().unwrap(),
27208 )
27209 .unwrap();
27210 let shared = NodeShared::new(
27211 handle,
27212 "fixture-token".to_owned(),
27213 NodeId::new("node-1").unwrap(),
27214 vec![workspace],
27215 vec![agent("claude")],
27216 );
27217 let instance_id = AgentInstanceId(17);
27218 let original = SessionAddress {
27219 workspace_id,
27220 session: SessionKey {
27221 instance_id,
27222 generation: SessionGeneration(3),
27223 },
27224 };
27225 shared.bind_session(
27226 instance_id,
27227 original.workspace_id.clone(),
27228 original.session.generation,
27229 );
27230 shared
27231 .arm_resume(
27232 &original,
27233 CommandId(41),
27234 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27235 )
27236 .unwrap();
27237
27238 shared.publish_control(ControlEvent {
27239 sequence: 1,
27240 command_id: None,
27241 instance_id,
27242 generation: SessionGeneration(4),
27243 event: ControlEventKind::Running { process_id: None },
27244 });
27245 assert_eq!(
27246 shared.address_for(instance_id, SessionGeneration(3)),
27247 Some(original.clone()),
27248 );
27249 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27250
27251 shared.publish_control(ControlEvent {
27252 sequence: 2,
27253 command_id: Some(CommandId(42)),
27254 instance_id,
27255 generation: SessionGeneration(3),
27256 event: ControlEventKind::CommandRejected {
27257 message: "unrelated command".to_owned(),
27258 },
27259 });
27260
27261 shared.publish_control(ControlEvent {
27262 sequence: 3,
27263 command_id: None,
27264 instance_id,
27265 generation: SessionGeneration(4),
27266 event: ControlEventKind::ResumeAuthorized {
27267 session: gate4agent_types::ResumeSessionSummary {
27268 key: gate4agent_types::ProviderSessionKey::SessionId,
27269 id: "fixture-session".to_owned(),
27270 },
27271 },
27272 });
27273 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27274 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27275 assert_eq!(rebound.workspace_id, original.workspace_id);
27276 let history = shared
27277 .history
27278 .lock()
27279 .unwrap_or_else(|poisoned| poisoned.into_inner());
27280 assert!(history.events.iter().any(|envelope| matches!(
27281 &envelope.event,
27282 NodeEvent::Control { address, event }
27283 if address == &rebound
27284 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27285 )));
27286 }
27287
27288 #[test]
27289 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27290 for agent in ["claude", "codex", "kimi"] {
27291 assert_eq!(
27292 prompt_framing(&AgentId::new(agent).unwrap()),
27293 PromptFraming::BracketedPaste
27294 );
27295 }
27296 }
27297
27298 fn generous_walk_deadline() -> Instant {
27299 Instant::now() + Duration::from_secs(30)
27300 }
27301
27302 #[test]
27303 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27304 let root = temporary_workspace_root("tree");
27305 std::fs::create_dir_all(root.join("src")).unwrap();
27306 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27307 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27308 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27309 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27310 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27311 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27317 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27318 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27322 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27323
27324 let (entries, truncated, _budget) =
27325 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27326 assert!(!truncated);
27327 assert!(entries.iter().any(|entry| {
27328 entry.relative_path.as_utf8() == Some("src")
27329 && entry.kind == WorkspaceEntryKind::Directory
27330 }));
27331 assert!(entries.iter().any(|entry| {
27332 entry.relative_path.as_utf8() == Some("src/lib.rs")
27333 && entry.kind == WorkspaceEntryKind::File
27334 }));
27335 assert!(entries.iter().any(|entry| {
27336 entry.relative_path.as_utf8() == Some("target-notes")
27337 && entry.kind == WorkspaceEntryKind::Directory
27338 }));
27339 assert!(entries.iter().any(|entry| {
27340 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27341 && entry.kind == WorkspaceEntryKind::File
27342 }));
27343 assert!(entries.iter().all(|entry| {
27344 let path = entry.relative_path.as_utf8().unwrap();
27345 !path.starts_with(".git")
27346 && !(path == "target" || path.starts_with("target/"))
27347 && !(path == "target-a2" || path.starts_with("target-a2/"))
27348 && !path.starts_with("node_modules")
27349 }));
27350
27351 std::fs::remove_dir_all(&root).unwrap();
27352 }
27353
27354 #[test]
27355 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27356 let root = temporary_workspace_root("tree-entry-cap");
27357 std::fs::create_dir_all(&root).unwrap();
27358 for index in 0..20 {
27359 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27360 }
27361
27362 let (entries, truncated, budget) =
27363 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27364 assert!(truncated);
27365 assert!(budget.entry_cap_exceeded);
27366 assert!(!budget.time_budget_exceeded);
27367 assert!(entries.len() <= 5);
27368
27369 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27370 .expect("an entry-cap-truncated walk must report a truncation marker");
27371 assert!(inspection_truncation.walk_entry_cap_exceeded);
27372 assert!(!inspection_truncation.walk_time_budget_exceeded);
27373 assert!(!inspection_truncation.git_time_budget_exceeded);
27374 assert!(inspection_truncation.entries_visited >= 5);
27375
27376 std::fs::remove_dir_all(&root).unwrap();
27377 }
27378
27379 #[test]
27380 fn workspace_walk_within_budget_reports_no_truncation() {
27381 let root = temporary_workspace_root("tree-no-truncation");
27382 std::fs::create_dir_all(&root).unwrap();
27383 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27384
27385 let (_entries, truncated, budget) =
27386 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27387 assert!(!truncated);
27388 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27389
27390 std::fs::remove_dir_all(&root).unwrap();
27391 }
27392
27393 #[test]
27394 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27395 let root = temporary_workspace_root("tree-prefix-siblings");
27396 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27397 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27398 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27399 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27400
27401 let (entries, truncated, _budget) =
27402 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27403 assert!(!truncated);
27404 let paths = entries
27405 .iter()
27406 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27407 .collect::<Vec<_>>();
27408 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27409 assert_eq!(
27410 paths,
27411 vec![
27412 "gate4agent-c2".to_owned(),
27413 "gate4agent-c2-client".to_owned(),
27414 "gate4agent-c2-client/lib.rs".to_owned(),
27415 "gate4agent-c2/src".to_owned(),
27416 "gate4agent-c2/src/lib.rs".to_owned(),
27417 ],
27418 );
27419
27420 std::fs::remove_dir_all(&root).unwrap();
27421 }
27422
27423 #[test]
27424 fn git_parsers_cap_status_and_commit_results() {
27425 let mut status_output = Vec::new();
27426 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27427 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27428 }
27429 let mut snapshot = git_snapshot_for_parser();
27430 parse_git_status(&status_output, &mut snapshot);
27431 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27432 assert!(snapshot.truncated);
27433 assert_eq!(snapshot.status[0].index_status, " ");
27434 assert_eq!(snapshot.status[0].worktree_status, "M");
27435
27436 snapshot.truncated = false;
27437 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27438 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27439 .collect::<String>();
27440 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27441 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27442 assert!(snapshot.truncated);
27443 }
27444
27445 #[test]
27446 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27447 let mut snapshot = git_snapshot_for_parser();
27448 parse_git_status(
27449 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27450 &mut snapshot,
27451 );
27452
27453 assert!(!snapshot.truncated);
27454 assert_eq!(snapshot.status.len(), 3);
27455 assert_eq!(snapshot.status[0].index_status, "R");
27456 assert_eq!(snapshot.status[0].worktree_status, " ");
27457 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27458 assert_eq!(
27459 snapshot.status[0]
27460 .previous_path
27461 .as_ref()
27462 .and_then(RepositoryPath::as_utf8),
27463 Some("src/previous name.rs"),
27464 );
27465 assert_eq!(snapshot.status[1].index_status, " ");
27466 assert_eq!(snapshot.status[1].worktree_status, "C");
27467 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27468 assert_eq!(
27469 snapshot.status[1]
27470 .previous_path
27471 .as_ref()
27472 .and_then(RepositoryPath::as_utf8),
27473 Some("src/original.rs"),
27474 );
27475 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27476 assert!(snapshot.status[2].previous_path.is_none());
27477 }
27478
27479 #[test]
27480 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27481 let mut snapshot = git_snapshot_for_parser();
27482 parse_git_status(
27483 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27484 &mut snapshot,
27485 );
27486
27487 assert!(snapshot.truncated);
27488 assert_eq!(snapshot.status.len(), 1);
27489 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27490 assert!(snapshot.status[0].previous_path.is_none());
27491 }
27492
27493 #[tokio::test]
27494 async fn observer_can_browse_host_directories_without_controller() {
27495 let root = temporary_workspace_root("host-directory-observer");
27496 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27497 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27498 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27499 let workspace_id = WorkspaceId::new("primary").unwrap();
27500 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27501 let catalog = active_registry().unwrap();
27502 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27503 let shared = NodeShared::new(
27504 handle,
27505 "fixture-token".to_owned(),
27506 NodeId::new("node-1").unwrap(),
27507 vec![workspace],
27508 vec![agent("claude")],
27509 );
27510 let request = NodeRequest::BrowseHostDirectories {
27511 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27512 after: None,
27513 };
27514 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27515 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27516 }));
27517 assert!(!request_uses_unnegotiated_capability(
27518 &request,
27519 &baseline_capabilities().unwrap(),
27520 ));
27521 assert!(request_uses_unnegotiated_capability(&request, &[]));
27522
27523 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27524 .await
27525 .unwrap();
27526 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27527 panic!("host directory browse returned another response");
27528 };
27529 assert_eq!(
27530 listing
27531 .entries
27532 .iter()
27533 .map(|entry| entry.display_name.as_str())
27534 .collect::<Vec<_>>(),
27535 vec!["directory-a", "directory-b"],
27536 );
27537 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27538 assert!(!listing.incomplete);
27539 std::fs::remove_dir_all(root).unwrap();
27540 }
27541
27542 #[tokio::test]
27543 async fn observer_can_inspect_only_a_registered_workspace() {
27544 let root = temporary_workspace_root("observer");
27545 std::fs::create_dir_all(root.join("src")).unwrap();
27546 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27547 let workspace_id = WorkspaceId::new("primary").unwrap();
27548 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27549 let catalog = active_registry().unwrap();
27550 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27551 let shared = NodeShared::new(
27552 handle,
27553 "fixture-token".to_owned(),
27554 NodeId::new("node-1").unwrap(),
27555 vec![workspace],
27556 vec![agent("claude")],
27557 );
27558
27559 let response = process_request_inner(
27560 &shared,
27561 77,
27562 ClientRole::Observer,
27563 NodeRequest::InspectWorkspace {
27564 workspace_id: workspace_id.clone(),
27565 },
27566 )
27567 .await
27568 .unwrap();
27569 let NodeResponse::WorkspaceInspected { inspection } = response else {
27570 panic!("workspace inspection returned another response");
27571 };
27572 assert_eq!(inspection.workspace_id, workspace_id);
27573 assert!(inspection.entries.iter().any(|entry| {
27574 entry.relative_path.as_utf8() == Some("src/main.rs")
27575 }));
27576
27577 let unknown = process_request_inner(
27578 &shared,
27579 77,
27580 ClientRole::Observer,
27581 NodeRequest::InspectWorkspace {
27582 workspace_id: WorkspaceId::new("unknown").unwrap(),
27583 },
27584 )
27585 .await
27586 .unwrap_err();
27587 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27588 std::fs::remove_dir_all(&root).unwrap();
27589 }
27590
27591 #[test]
27592 fn context_pack_source_accepts_only_running_or_successful_exit() {
27593 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27594 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27595 exit_code: Some(0),
27596 }));
27597 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27598 exit_code: Some(1),
27599 }));
27600 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27601 exit_code: None,
27602 }));
27603 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27604 message: "provider failed".to_owned(),
27605 }));
27606 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27607 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27608 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27609 }
27610
27611 #[test]
27612 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27613 let git = git_snapshot_for_parser();
27614 let first = ContextPackRepositoryObservation {
27615 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27616 git: git.clone(),
27617 files: Vec::new(),
27618 };
27619 let second = ContextPackRepositoryObservation {
27620 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27621 git,
27622 files: Vec::new(),
27623 };
27624
27625 let error = stable_context_pack_repository(first, second).unwrap_err();
27626
27627 assert_eq!(
27628 error.code,
27629 NodeFailureCode::ContextPackMaterializationFailed,
27630 );
27631 }
27632
27633 #[test]
27634 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27635 let git = git_snapshot_for_parser();
27636 let first = ContextPackRepositoryObservation {
27637 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27638 git: git.clone(),
27639 files: vec![ContextPackRepositoryFileSource::utf8(
27640 "README.md",
27641 "first\n".to_owned(),
27642 6,
27643 )],
27644 };
27645 let second = ContextPackRepositoryObservation {
27646 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27647 git,
27648 files: vec![ContextPackRepositoryFileSource::utf8(
27649 "README.md",
27650 "other\n".to_owned(),
27651 6,
27652 )],
27653 };
27654
27655 let error = stable_context_pack_repository(first, second).unwrap_err();
27656
27657 assert_eq!(
27658 error.code,
27659 NodeFailureCode::ContextPackMaterializationFailed,
27660 );
27661 }
27662
27663 #[tokio::test]
27664 async fn native_session_catalog_zero_active_session_fixture() {
27665 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27666 let history_root = temporary_workspace_root("native-catalog-history");
27667 let outside_root = temporary_workspace_root("native-catalog-outside");
27668 std::fs::create_dir_all(&workspace_root).unwrap();
27669 std::fs::create_dir_all(&history_root).unwrap();
27670 std::fs::create_dir_all(&outside_root).unwrap();
27671 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27672 [
27673 serde_json::json!({
27674 "type": "user",
27675 "sessionId": session_id,
27676 "cwd": cwd.to_str().unwrap(),
27677 "message": { "content": "raw user text must not cross the catalog wire" }
27678 }),
27679 serde_json::json!({
27680 "type": "assistant",
27681 "sessionId": session_id,
27682 "cwd": cwd.to_str().unwrap(),
27683 "message": { "model": "claude-sonnet", "content": "raw answer" }
27684 }),
27685 serde_json::json!({
27686 "type": "ai-title",
27687 "sessionId": session_id,
27688 "aiTitle": title
27689 }),
27690 ]
27691 .into_iter()
27692 .map(|record| record.to_string())
27693 .collect::<Vec<_>>()
27694 .join("\n")
27695 };
27696 std::fs::write(
27697 history_root.join("stable-session.jsonl"),
27698 transcript("stable-session", &workspace_root, "Workspace review"),
27699 )
27700 .unwrap();
27701 std::fs::write(
27702 history_root.join("outside-session.jsonl"),
27703 transcript("outside-session", &outside_root, "Outside review"),
27704 )
27705 .unwrap();
27706 let history = NativeHistoryConfig::new(vec![
27707 NativeHistoryRoot::new(
27708 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27709 HistorySourceLayout::SingleNdjson,
27710 history_root.clone(),
27711 )
27712 .unwrap(),
27713 ])
27714 .unwrap();
27715 let catalog = active_registry().unwrap();
27716 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27717 assert!(handle.snapshot().sessions.is_empty());
27718 let workspace_id = WorkspaceId::new("primary").unwrap();
27719 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27720 let mut shared = NodeShared::new(
27721 handle,
27722 "fixture-token".to_owned(),
27723 NodeId::new("node-native-catalog").unwrap(),
27724 vec![workspace],
27725 vec![agent("claude")],
27726 );
27727 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27728 NativeSessionCatalogAuthority::new(history),
27729 )));
27730 let route = NativeSessionCatalogRoute::workspace(
27731 workspace_id.clone(),
27732 agent("claude"),
27733 );
27734 let request = NodeRequest::CatalogNativeSessions {
27735 route: route.clone(),
27736 limit: 2,
27737 };
27738 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27739 .await
27740 .unwrap_err();
27741 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27742 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27743 .await
27744 .unwrap();
27745 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27746 panic!("native catalog returned a different response");
27747 };
27748 let summary = summary.expect("node must emit native session catalog summary metadata");
27749 assert_eq!(entries.len(), 1);
27750 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27751 assert_eq!(entries[0].external_group, None);
27752 assert_eq!(entries[0].record_id, None);
27753 assert_eq!(entries[0].title, None);
27754 assert_eq!(entries[0].model, None);
27755 assert_eq!(entries[0].message_count, 0);
27756 let encoded_entries = serde_json::to_string(&entries).unwrap();
27757 assert!(!encoded_entries.contains("stable-session"));
27758 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27759 let page_request = NodeRequest::PageNativeSessions {
27760 route: route.clone(),
27761 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27762 catalog_revision: summary.catalog_revision,
27763 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27764 after_selection_id: None,
27765 limit: 2,
27766 };
27767 let observer = process_request_inner(
27768 &shared,
27769 7,
27770 ClientRole::Observer,
27771 page_request.clone(),
27772 )
27773 .await
27774 .unwrap_err();
27775 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27776 let page_response = process_request_inner(
27777 &shared,
27778 7,
27779 ClientRole::Operator,
27780 page_request.clone(),
27781 )
27782 .await
27783 .unwrap();
27784 assert!(matches!(
27785 page_response,
27786 NodeResponse::NativeSessionsPaged { page, .. }
27787 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27788 ));
27789 let stale = process_request_inner(
27790 &shared,
27791 7,
27792 ClientRole::Operator,
27793 NodeRequest::PageNativeSessions {
27794 route: route.clone(),
27795 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27796 catalog_revision: summary.catalog_revision.wrapping_add(1),
27797 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27798 after_selection_id: None,
27799 limit: 2,
27800 },
27801 )
27802 .await
27803 .unwrap_err();
27804 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27805 let selection = NativeSessionSelection {
27806 route: route.clone(),
27807 catalog_revision: summary.catalog_revision,
27808 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27809 selection_id: entries[0].selection_id.clone(),
27810 };
27811 let preview_response = process_request_inner(
27812 &shared,
27813 7,
27814 ClientRole::Operator,
27815 NodeRequest::PreviewNativeSession {
27816 selection: selection.clone(),
27817 message_limit: 1,
27818 },
27819 )
27820 .await
27821 .unwrap();
27822 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27823 panic!("native preview returned a different response");
27824 };
27825 assert_eq!(preview.messages.len(), 1);
27826 assert!(preview.message_count_exact);
27827 assert_eq!(preview.messages[0].text, "raw answer");
27828 assert!(preview.truncated);
27829
27830 let index_request = NodeRequest::IndexNativeSession {
27831 selection: selection.clone(),
27832 display_name: "Saved Claude".to_owned(),
27833 };
27834 let observer = process_request_inner(
27835 &shared,
27836 7,
27837 ClientRole::Observer,
27838 index_request.clone(),
27839 )
27840 .await
27841 .unwrap_err();
27842 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27843 let without_controller = process_request_inner(
27844 &shared,
27845 7,
27846 ClientRole::Operator,
27847 index_request.clone(),
27848 )
27849 .await
27850 .unwrap_err();
27851 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27852 let acquired = process_request_inner(
27853 &shared,
27854 7,
27855 ClientRole::Operator,
27856 NodeRequest::AcquireController { lease_ms: 5_000 },
27857 )
27858 .await
27859 .unwrap();
27860 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27861 let indexed = process_request_inner(
27862 &shared,
27863 7,
27864 ClientRole::Operator,
27865 index_request,
27866 )
27867 .await
27868 .unwrap();
27869 assert!(indexed.native_session_index_contract_is_valid());
27870 let NodeResponse::NativeSessionIndexed {
27871 selection: echoed_selection,
27872 record,
27873 } = indexed else {
27874 panic!("native session index returned a different response");
27875 };
27876 assert_eq!(echoed_selection, selection);
27877 let recataloged = process_request_inner(
27878 &shared,
27879 7,
27880 ClientRole::Operator,
27881 NodeRequest::CatalogNativeSessions {
27882 route,
27883 limit: 2,
27884 },
27885 )
27886 .await
27887 .unwrap();
27888 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27889 panic!("native catalog returned a different response after indexing");
27890 };
27891 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27892
27893 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27894 let external_catalog = process_request_inner(
27895 &shared,
27896 7,
27897 ClientRole::Operator,
27898 NodeRequest::CatalogNativeSessions {
27899 route: unregistered_route.clone(),
27900 limit: 2,
27901 },
27902 )
27903 .await
27904 .unwrap();
27905 let external_json = serde_json::to_string(&external_catalog).unwrap();
27906 assert!(!external_json.contains("outside-session"));
27907 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27908 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27909 assert!(!external_json.contains("\"session_id\""));
27910 assert!(!external_json.contains("\"cwd\""));
27911 assert!(!external_json.contains("\"path\""));
27912 let NodeResponse::NativeSessionsCataloged {
27913 route: echoed_route,
27914 entries: external_entries,
27915 summary: Some(external_summary),
27916 } = external_catalog
27917 else {
27918 panic!("unregistered native catalog returned a different response");
27919 };
27920 assert_eq!(echoed_route, unregistered_route);
27921 assert_eq!(external_entries.len(), 1);
27922 assert_eq!(external_entries[0].record_id, None);
27923 let external_group = external_entries[0]
27924 .external_group
27925 .as_ref()
27926 .expect("unregistered session must have an external group");
27927 assert_eq!(
27928 external_group.kind,
27929 gate4agent_types::NativeSessionExternalGroupKind::Project,
27930 );
27931 assert!(external_group.group_id.starts_with("external-"));
27932 assert_eq!(
27933 external_group.display_name,
27934 outside_root.file_name().unwrap().to_string_lossy(),
27935 );
27936 let external_selection = NativeSessionSelection {
27937 route: unregistered_route,
27938 catalog_revision: external_summary.catalog_revision,
27939 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27940 selection_id: external_entries[0].selection_id.clone(),
27941 };
27942 let external_preview = process_request_inner(
27943 &shared,
27944 7,
27945 ClientRole::Operator,
27946 NodeRequest::PreviewNativeSession {
27947 selection: external_selection.clone(),
27948 message_limit: 1,
27949 },
27950 )
27951 .await
27952 .unwrap();
27953 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27954 assert!(!external_preview_json.contains("outside-session"));
27955 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27956 assert!(!external_preview_json.contains("\"session_id\""));
27957 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27958 panic!("unregistered native preview returned a different response");
27959 };
27960 assert_eq!(preview.messages.len(), 1);
27961 let external_index = process_request_inner(
27962 &shared,
27963 7,
27964 ClientRole::Operator,
27965 NodeRequest::IndexNativeSession {
27966 selection: external_selection,
27967 display_name: "External Claude".to_owned(),
27968 },
27969 )
27970 .await
27971 .unwrap_err();
27972 assert_eq!(
27973 external_index.code,
27974 NodeFailureCode::WorkspaceRegistrationRequired,
27975 );
27976 let record_response = process_request_inner(
27977 &shared,
27978 7,
27979 ClientRole::Operator,
27980 NodeRequest::PreviewSessionRecord {
27981 record_id: record.record_id.clone(),
27982 message_limit: 2,
27983 },
27984 )
27985 .await
27986 .unwrap();
27987 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27988 panic!("record preview returned a different response");
27989 };
27990 assert_eq!(record_id, record.record_id);
27991 assert_eq!(preview.messages.len(), 2);
27992 let encoded = serde_json::to_string(&preview).unwrap();
27993 assert!(!encoded.contains("stable-session"));
27994 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27995 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27996 panic!("record preview history summary returned a different response");
27997 };
27998 let summaries = events
27999 .iter()
28000 .filter_map(|envelope| match &envelope.event {
28001 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28002 if record_id == &record.record_id =>
28003 {
28004 Some(*summary)
28005 }
28006 _ => None,
28007 })
28008 .collect::<Vec<_>>();
28009 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28010 let summary_json = serde_json::to_string(&summaries).unwrap();
28011 for private in [
28012 "stable-session",
28013 "raw user text must not cross the catalog wire",
28014 "raw answer",
28015 "Workspace review",
28016 "claude-sonnet",
28017 ] {
28018 assert!(!summary_json.contains(private), "{private}");
28019 }
28020 let identity = record.provider_session.clone().unwrap();
28021 assert_eq!(
28022 shared.revalidate_session_record_preview(&record, &identity),
28023 Ok(())
28024 );
28025 shared.remove_record_memory(&record.record_id);
28026 assert_eq!(
28027 shared
28028 .revalidate_session_record_preview(&record, &identity)
28029 .unwrap_err()
28030 .code,
28031 NodeFailureCode::UnknownSessionRecord
28032 );
28033 let released = process_request_inner(
28034 &shared,
28035 7,
28036 ClientRole::Operator,
28037 NodeRequest::ReleaseController,
28038 )
28039 .await
28040 .unwrap();
28041 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28042 assert!(shared.controller_state().is_none());
28043 std::fs::remove_dir_all(workspace_root).unwrap();
28044 std::fs::remove_dir_all(history_root).unwrap();
28045 std::fs::remove_dir_all(outside_root).unwrap();
28046 }
28047
28048 #[tokio::test]
28049 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28050 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28051 std::fs::create_dir_all(&history_root).unwrap();
28052 let history = NativeHistoryConfig::new(vec![
28053 NativeHistoryRoot::new(
28054 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28055 HistorySourceLayout::SingleNdjson,
28056 history_root.clone(),
28057 )
28058 .unwrap(),
28059 ])
28060 .unwrap();
28061 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28062 let held = catalog.lock().unwrap();
28063 let result = timeout(
28064 Duration::from_secs(1),
28065 run_native_session_catalog_operation(
28066 Arc::clone(&catalog),
28067 "native session catalog",
28068 |_| Ok::<(), std::convert::Infallible>(()),
28069 ),
28070 )
28071 .await
28072 .expect("concurrent native catalog request must not wait for the operation deadline")
28073 .unwrap_err();
28074 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28075 drop(held);
28076 std::fs::remove_dir_all(history_root).unwrap();
28077 }
28078
28079 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28080 let unique = std::time::SystemTime::now()
28081 .duration_since(std::time::UNIX_EPOCH)
28082 .unwrap()
28083 .as_nanos();
28084 std::env::temp_dir().join(format!(
28085 "gate4agent-node-{label}-{}-{unique}",
28086 std::process::id(),
28087 ))
28088 }
28089
28090 fn git_snapshot_for_parser() -> GitSnapshot {
28091 GitSnapshot {
28092 is_repository: true,
28093 branch: Some("main".to_owned()),
28094 status: Vec::new(),
28095 recent_commits: Vec::new(),
28096 worktrees: Vec::new(),
28097 managed_worktree: None,
28098 truncated: false,
28099 diagnostic: None,
28100 }
28101 }
28102
28103 #[tokio::test]
28104 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28105 let root = temporary_workspace_root("managed-git-scope");
28106 std::fs::create_dir_all(&root).unwrap();
28107 run_test_git(&root, &["init"]);
28108 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28109 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28110 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28111 run_test_git(&root, &["add", "seed.txt"]);
28112 run_test_git(&root, &["commit", "-m", "seed"]);
28113 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28114
28115 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28116 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28117 let catalog = active_registry().unwrap();
28118 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28119 let shared = NodeShared::new(
28120 handle,
28121 "fixture-token".to_owned(),
28122 NodeId::new("node-1").unwrap(),
28123 vec![workspace],
28124 vec![agent("claude")],
28125 );
28126 let mut lease = ManagedWorktreeLeaseRecord {
28127 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28128 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28129 workspace_id: workspace_id.clone(),
28130 profile_id: WorktreeProfileId::new("default").unwrap(),
28131 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28132 target_root: root.to_string_lossy().into_owned(),
28133 branch: "gate4agent/a".to_owned(),
28134 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28135 expected_head: None,
28136 retention: ManagedWorktreeRetention::Retain,
28137 state: ManagedWorktreeLeaseState::InUse,
28138 session_holders: vec![ManagedWorktreeSessionHolder {
28139 incarnation_id: shared.incarnation_id,
28140 instance_id: AgentInstanceId(7),
28141 generation: SessionGeneration(1),
28142 }],
28143 record_holders: Vec::new(),
28144 cleanup_failure: None,
28145 created_at_unix_ms: 1,
28146 updated_at_unix_ms: 1,
28147 };
28148 *shared
28149 .managed_worktrees
28150 .lock()
28151 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28152 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28153
28154 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28155 let scope = inspection.git.managed_worktree.unwrap();
28156 assert_eq!(scope.lease_id, lease.lease_id);
28157 assert_eq!(scope.branch, "gate4agent/a");
28158 assert_eq!(scope.active_session_count, 1);
28159
28160 lease.branch = "gate4agent/b".to_owned();
28161 *shared
28162 .managed_worktrees
28163 .lock()
28164 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28165 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28166 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28167 assert!(mismatch.git.managed_worktree.is_none());
28168 std::fs::remove_dir_all(root).unwrap();
28169 }
28170
28171 #[tokio::test]
28172 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28173 let root = temporary_workspace_root("workspace-file-write-controller");
28174 std::fs::create_dir_all(root.join("src")).unwrap();
28175 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28176 let workspace_id = WorkspaceId::new("primary").unwrap();
28177 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28178 let catalog = active_registry().unwrap();
28179 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28180 let shared = NodeShared::new(
28181 handle,
28182 "fixture-token".to_owned(),
28183 NodeId::new("node-1").unwrap(),
28184 vec![workspace],
28185 vec![agent("claude")],
28186 );
28187 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28188 let expected_revision = workspace_file_revision(b"old\n");
28189 let request = NodeRequest::WriteWorkspaceFile {
28190 workspace_id: workspace_id.clone(),
28191 path: path.clone(),
28192 expected_revision: expected_revision.clone(),
28193 text: "new\n".to_owned(),
28194 };
28195 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28196 .await
28197 .unwrap_err();
28198 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28199 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28200 .await
28201 .unwrap_err();
28202 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28203 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28204 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28205 let NodeResponse::WorkspaceFileWritten { file } = response else {
28206 panic!("workspace file write returned another response");
28207 };
28208 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28209 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28210 let stale = process_request_inner(
28211 &shared,
28212 77,
28213 ClientRole::Operator,
28214 NodeRequest::WriteWorkspaceFile {
28215 workspace_id,
28216 path,
28217 expected_revision,
28218 text: "lost update\n".to_owned(),
28219 },
28220 )
28221 .await
28222 .unwrap_err();
28223 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28224 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28225 std::fs::remove_dir_all(root).unwrap();
28226 }
28227
28228 #[tokio::test]
28229 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28230 let root = temporary_workspace_root("workspace-entry-create-controller");
28231 std::fs::create_dir_all(root.join("src")).unwrap();
28232 let workspace_id = WorkspaceId::new("primary").unwrap();
28233 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28234 let catalog = active_registry().unwrap();
28235 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28236 let shared = NodeShared::new(
28237 handle,
28238 "fixture-token".to_owned(),
28239 NodeId::new("node-1").unwrap(),
28240 vec![workspace],
28241 vec![agent("claude")],
28242 );
28243 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28244 let request = NodeRequest::CreateWorkspaceFile {
28245 workspace_id: workspace_id.clone(),
28246 path: path.clone(),
28247 };
28248 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28249 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28250 }));
28251 assert!(!request_uses_unnegotiated_capability(
28252 &request,
28253 &baseline_capabilities().unwrap(),
28254 ));
28255 assert!(request_uses_unnegotiated_capability(&request, &[]));
28256 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28257 .await
28258 .unwrap_err();
28259 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28260 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28261 .await
28262 .unwrap_err();
28263 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28264 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28265
28266 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28267 .await
28268 .unwrap();
28269 let NodeResponse::WorkspaceFileCreated { file } = response else {
28270 panic!("workspace file create returned another response");
28271 };
28272 assert_eq!(file.workspace_id, workspace_id);
28273 assert_eq!(file.path, path);
28274 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28275 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28276
28277 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28278 .await
28279 .unwrap_err();
28280 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28281
28282 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28283 let response = process_request_inner(
28284 &shared,
28285 77,
28286 ClientRole::Operator,
28287 NodeRequest::CreateWorkspaceDirectory {
28288 workspace_id: workspace_id.clone(),
28289 path: directory_path.clone(),
28290 },
28291 )
28292 .await
28293 .unwrap();
28294 assert_eq!(
28295 response,
28296 NodeResponse::WorkspaceDirectoryCreated {
28297 workspace_id: workspace_id.clone(),
28298 entry: WorkspaceEntry {
28299 relative_path: directory_path,
28300 kind: WorkspaceEntryKind::Directory,
28301 },
28302 },
28303 );
28304 assert!(root.join("src/nested").is_dir());
28305
28306 let missing_parent = process_request_inner(
28307 &shared,
28308 77,
28309 ClientRole::Operator,
28310 NodeRequest::CreateWorkspaceFile {
28311 workspace_id: workspace_id.clone(),
28312 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28313 },
28314 )
28315 .await
28316 .unwrap_err();
28317 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28318 let non_directory_parent = process_request_inner(
28319 &shared,
28320 77,
28321 ClientRole::Operator,
28322 NodeRequest::CreateWorkspaceFile {
28323 workspace_id,
28324 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28325 },
28326 )
28327 .await
28328 .unwrap_err();
28329 assert_eq!(
28330 non_directory_parent.code,
28331 NodeFailureCode::RepositoryParentNotDirectory,
28332 );
28333 std::fs::remove_dir_all(root).unwrap();
28334 }
28335
28336 #[tokio::test]
28337 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28338 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28339 std::fs::create_dir_all(root.join("src")).unwrap();
28340 #[cfg(windows)]
28341 let root = std::fs::canonicalize(root).unwrap();
28342 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28343 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28344 let barrier = Arc::new(std::sync::Barrier::new(2));
28345 let done = Arc::new(AtomicBool::new(false));
28346 let task_commit_state = Arc::clone(&commit_state);
28347 let task_barrier = Arc::clone(&barrier);
28348 let task_done = Arc::clone(&done);
28349 let task_root = root.clone();
28350 let task = tokio::task::spawn_blocking(move || {
28351 task_barrier.wait();
28352 let result = create_workspace_file_on_disk(
28353 &task_root,
28354 &path,
28355 &task_commit_state,
28356 );
28357 task_done.store(true, Ordering::Release);
28358 result
28359 });
28360
28361 let error = settle_workspace_entry_create(
28362 task,
28363 Arc::clone(&commit_state),
28364 Duration::from_millis(10),
28365 "fixture deadline",
28366 "fixture task failed",
28367 )
28368 .await
28369 .unwrap_err();
28370 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28371 assert_eq!(
28372 commit_state.load(Ordering::Acquire),
28373 WORKSPACE_ENTRY_CREATE_CANCELED,
28374 );
28375
28376 barrier.wait();
28377 timeout(Duration::from_secs(1), async {
28378 while !done.load(Ordering::Acquire) {
28379 tokio::task::yield_now().await;
28380 }
28381 })
28382 .await
28383 .expect("canceled blocking create did not settle");
28384 assert!(!root.join("src/late.rs").exists());
28385 std::fs::remove_dir_all(root).unwrap();
28386 }
28387}
28388
28389#[cfg(test)]
28390mod provisional_spawn_tests {
28391 use super::*;
28392 use std::sync::Arc;
28393
28394 fn shared() -> NodeShared {
28395 let catalog = active_registry().unwrap();
28396 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28397 let workspace = WorkspaceConfig::new(
28398 WorkspaceId::new("primary").unwrap(),
28399 std::env::current_dir().unwrap(),
28400 )
28401 .unwrap()
28402 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28403 NodeShared::new(
28404 handle,
28405 "fixture-token".to_owned(),
28406 NodeId::new("node-provisional-test").unwrap(),
28407 vec![workspace],
28408 vec![AgentId::new("claude").unwrap()],
28409 )
28410 }
28411
28412 #[test]
28415 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28416 let shared = shared();
28417 let ws = WorkspaceId::new("primary").unwrap();
28418 let mut ids = Vec::with_capacity(50);
28419 for i in 1u64..=50 {
28420 let id = AgentInstanceId(i);
28421 ids.push(id);
28422 let address = SessionAddress {
28423 workspace_id: ws.clone(),
28424 session: SessionKey {
28425 instance_id: id,
28426 generation: SessionGeneration(1),
28427 },
28428 };
28429 shared.mark_spawn_provisional(address);
28430 }
28431 assert_eq!(
28432 shared
28433 .provisional_spawns
28434 .lock()
28435 .unwrap_or_else(|p| p.into_inner())
28436 .len(),
28437 50
28438 );
28439 for id in ids.iter().step_by(2) {
28440 shared.sessionize_spawn(*id);
28441 }
28442 assert_eq!(
28443 shared
28444 .provisional_spawns
28445 .lock()
28446 .unwrap_or_else(|p| p.into_inner())
28447 .len(),
28448 25
28449 );
28450 assert!(shared.take_due_provisional_spawns().is_empty());
28451 {
28452 let mut map = shared
28453 .provisional_spawns
28454 .lock()
28455 .unwrap_or_else(|p| p.into_inner());
28456 let past = Instant::now() - Duration::from_secs(91);
28457 for (_, (_, at)) in map.iter_mut() {
28458 *at = past;
28459 }
28460 }
28461 let due = shared.take_due_provisional_spawns();
28462 assert_eq!(due.len(), 25);
28463 let due_ids: std::collections::BTreeSet<_> =
28464 due.iter().map(|a| a.session.instance_id.0).collect();
28465 let expected: std::collections::BTreeSet<_> =
28466 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28467 assert_eq!(due_ids, expected);
28468 assert!(shared
28469 .provisional_spawns
28470 .lock()
28471 .unwrap_or_else(|p| p.into_inner())
28472 .is_empty());
28473 }
28474
28475 #[test]
28478 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28479 let shared = Arc::new(shared());
28480 let ws = WorkspaceId::new("primary").unwrap();
28481 let mut handles = Vec::new();
28482 for i in 1u64..=20 {
28483 let shared = Arc::clone(&shared);
28484 let ws = ws.clone();
28485 handles.push(std::thread::spawn(move || {
28486 let id = AgentInstanceId(i);
28487 let address = SessionAddress {
28488 workspace_id: ws,
28489 session: SessionKey {
28490 instance_id: id,
28491 generation: SessionGeneration(1),
28492 },
28493 };
28494 shared.mark_spawn_provisional(address);
28495 id
28496 }));
28497 }
28498 let mut ids = Vec::new();
28499 for h in handles {
28500 ids.push(h.join().unwrap());
28501 }
28502 ids.sort_by_key(|id| id.0);
28503 assert_eq!(ids.len(), 20);
28504 ids.dedup();
28505 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28506 assert_eq!(
28507 shared
28508 .provisional_spawns
28509 .lock()
28510 .unwrap_or_else(|p| p.into_inner())
28511 .len(),
28512 20
28513 );
28514 shared.sessionize_spawn(AgentInstanceId(7));
28515 let left: Vec<_> = shared
28516 .provisional_spawns
28517 .lock()
28518 .unwrap_or_else(|p| p.into_inner())
28519 .keys()
28520 .map(|id| id.0)
28521 .collect();
28522 assert!(!left.contains(&7));
28523 assert_eq!(left.len(), 19);
28524 }
28525}
28526
28527
28528#[cfg(unix)]
28529extern "C" {
28530 #[link_name = "geteuid"]
28531 fn libc_geteuid() -> u32;
28532}
28533
28534#[cfg(test)]
28535mod call_home_link_tests {
28536 use super::*;
28537
28538 #[test]
28539 fn call_home_backoff_is_capped_and_monotonic() {
28540 let mut last = Duration::ZERO;
28541 for attempt in 1..20 {
28542 let next = call_home_backoff(attempt);
28543 assert!(next >= last);
28544 assert!(next <= Duration::from_secs(8));
28545 last = next;
28546 }
28547 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28548 }
28549
28550 #[tokio::test]
28551 async fn call_home_socket_gets_keepalive() {
28552 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28553 let addr = listener.local_addr().unwrap();
28554 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28555 let client = client.unwrap();
28556 enable_call_home_keepalive(&client).unwrap();
28557 let sock = socket2::SockRef::from(&client);
28558 assert!(sock.keepalive().unwrap());
28559 #[cfg(any(target_os = "linux", target_os = "macos"))]
28560 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28561 }
28562}
28563
28564fn provider_session_exists(provider: &str, id: &str) -> bool {
28566 if provider != "claude" { return false; }
28567 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28568 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28569 let name = format!("{id}.jsonl");
28570 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())))
28571}
28572
28573fn overlay_with_extra_args(
28575 provider: &AgentId,
28576 mode: SessionMode,
28577 overlay: Option<PreparedNativeLaunchOverlay>,
28578 args: Vec<OsString>,
28579) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28580 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28581 Ok(match (overlay, args) {
28582 (prepared, args) if args.is_empty() => prepared,
28583 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28584 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28585 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28586 provider.clone(),
28587 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28588 Vec::new(),
28589 args,
28590 ).map_err(|_| bad())?)),
28591 })
28592}