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 node_wake;
213
214mod browser_bridge;
215
216use gate4agent_node_core::drive_loop_profile;
217
218#[path = "bundle_delivery.rs"]
219mod bundle_delivery;
220
221#[path = "context_pack_store.rs"]
222mod context_pack_store;
223
224#[cfg(windows)]
225pub use crate::platform::DEFAULT_NODE_ENDPOINT;
226
227const NODE_EVENT_HISTORY_MAX: usize = 4_096;
228const NODE_BROADCAST_CAPACITY: usize = 1_024;
229const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
230const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
236const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
243const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
244const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
245const MAX_PREAUTH_CONNECTIONS: usize = 32;
246const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
247const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
248const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
249const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
250const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
251const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
259const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
267const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
274const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
279const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
280const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
281const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
282const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
283const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
284const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
285const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
286const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
287const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
288const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
289const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
290const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
291const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
294const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
301const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
304const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
305const GIT_STATUS_MAX_ENTRIES: usize = 128;
306const GIT_COMMIT_MAX_ENTRIES: usize = 12;
307const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
308const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
309const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
310const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
311const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
312const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
313const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
314const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
315const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
316const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
317 "provider-session-identity-allocation-failed";
318const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
319const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
320const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
321const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
322const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
323const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
324const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
325 "durable-state-path-semantics-unsupported";
326
327fn opaque_windows_path(value: String) -> OpaqueHostPath {
328 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
329}
330
331fn agent_progress_from_provider_snapshot(
332 address: SessionAddress,
333 provider: &ProviderSnapshot,
334) -> Option<SessionAgentProgress> {
335 let mut truncated = false;
336 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
337 let mut active_tool_labels = Vec::with_capacity(
338 provider
339 .active_tools
340 .len()
341 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
342 );
343 for tool in &provider.active_tools {
344 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
345 truncated = true;
346 break;
347 }
348 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
349 active_tool_labels.push(label);
350 }
351 }
352 let attention = provider
353 .interactions
354 .iter()
355 .find(|interaction| {
356 matches!(
357 interaction.status,
358 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
359 )
360 })
361 .map(|interaction| AgentProgressAttentionV1 {
362 kind: match interaction.interaction_kind {
363 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
364 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
365 },
366 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
367 });
368 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
369 let usage = (provider.completed_turns > 0
370 || provider.usage.input_tokens > 0
371 || provider.usage.output_tokens > 0
372 || provider.usage.cache_read_tokens > 0
373 || provider.usage.cache_write_tokens > 0
374 || provider.usage.reasoning_tokens > 0)
375 .then_some(AgentProgressUsageV1 {
376 input_tokens: provider.usage.input_tokens,
377 output_tokens: provider.usage.output_tokens,
378 cache_read_tokens: provider.usage.cache_read_tokens,
379 cache_write_tokens: provider.usage.cache_write_tokens,
380 reasoning_tokens: provider.usage.reasoning_tokens,
381 });
382 let progress = AgentProgressV1 {
383 provider_sequence: provider.sequence,
384 activity: provider.activity,
385 completed_turns: provider.completed_turns,
386 usage,
387 current: AgentProgressCurrentV1::from(provider.activity),
388 active_tool_labels,
389 active_tool_count,
390 attention,
391 subagent_count,
392 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
393 gap_count: provider.gap_count,
394 stale: provider.stale,
395 truncated,
396 };
397 let entry = SessionAgentProgress { address, progress };
398 serde_json::to_vec(&entry)
399 .ok()
400 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
401 .map(|_| entry)
402}
403
404fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
405 match u32::try_from(count) {
406 Ok(count) => count,
407 Err(_) => {
408 *truncated = true;
409 u32::MAX
410 }
411 }
412}
413
414fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
415 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
416 return None;
417 }
418 Some(match event {
419 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
420 ProviderEvent::SessionIdentityObserved { .. } => {
421 AgentProgressEventKindV1::SessionIdentityObserved
422 }
423 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
424 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
425 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
426 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
427 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
428 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
429 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
430 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
431 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
432 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
433 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
434 ProviderEvent::InteractionRequested { .. } => {
435 AgentProgressEventKindV1::InteractionRequested
436 }
437 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
438 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
439 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
440 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
441 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
442 ProviderEvent::UnrecognizedNotification { .. } => {
443 AgentProgressEventKindV1::UnrecognizedNotification
444 }
445 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
446 ProviderEvent::UserMessage { .. }
457 | ProviderEvent::Plan { .. }
458 | ProviderEvent::AvailableCommandsUpdated { .. }
459 | ProviderEvent::ModeChanged { .. }
460 | ProviderEvent::SessionInfoUpdated { .. }
461 | ProviderEvent::UsageUpdated { .. }
462 | ProviderEvent::ConfigOptionsUpdated { .. } => {
463 AgentProgressEventKindV1::UnrecognizedNotification
464 }
465 })
466}
467
468fn provider_family_is_live(family: AdapterFamily) -> bool {
481 match family {
482 AdapterFamily::PtySemantic
483 | AdapterFamily::Hook
484 | AdapterFamily::ManagedHook
485 | AdapterFamily::Pipe
486 | AdapterFamily::OneShot
487 | AdapterFamily::Acp => true,
488 AdapterFamily::History
489 | AdapterFamily::Resume
490 | AdapterFamily::SessionOptions
491 | AdapterFamily::CapabilityProbe => false,
492 }
493}
494
495fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
496 match by {
497 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
498 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
499 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
500 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
501 }
502}
503
504fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
516 match authority {
517 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
518 BlockAuthorityV1::HostPolicy => "denied by host policy",
519 BlockAuthorityV1::HostDeadline => {
520 "denied by host policy after the operator deadline expired"
521 }
522 BlockAuthorityV1::Operator => "declined by the operator",
523 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
524 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
525 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
526 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
527 BlockAuthorityV1::UserRejected => "rejected by the user",
528 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
529 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
530 }
531}
532
533fn host_request_denied_block(
536 method: &str,
537 by: ProviderHostDecisionAuthority,
538 reason: Option<&str>,
539) -> (String, BlockAuthorityV1, String) {
540 let authority = block_authority(by);
541 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
542 let (reason_text, _) =
546 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
547 (tool_class_label(method), authority, reason_text)
548}
549
550fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
569 match kind? {
570 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
571 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
572 _ => None,
573 }
574}
575
576fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
588 let trimmed = text.trim();
589 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
590 return (trimmed.to_owned(), None);
591 };
592 let tail = after_reason.trim_start();
593 let Some((reason, help)) = tail.split_once(". ") else {
594 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
598 return (reason.to_owned(), None);
599 };
600 let help = help.trim();
601 if help.is_empty() {
602 (reason.trim().to_owned(), None)
603 } else {
604 (reason.trim().to_owned(), Some(help.to_owned()))
605 }
606}
607
608fn tool_class_from_denial_help(help: Option<&str>) -> String {
618 help.and_then(|text| text.split("add a ").nth(1))
619 .and_then(|tail| tail.split(' ').next())
620 .filter(|word| !word.is_empty())
621 .map(str::to_owned)
622 .unwrap_or_else(|| "Tool".to_owned())
623}
624
625fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
633 fn names_quota(s: &str) -> bool {
634 let lower = s.to_ascii_lowercase();
635 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
636 }
637 vendor_code.is_some_and(names_quota) || names_quota(message)
638}
639
640fn provider_session_end_block(
656 stop_reason: &ProviderStopReason,
657) -> Option<(BlockAuthorityV1, String, String)> {
658 match stop_reason {
659 ProviderStopReason::Refusal => Some((
660 BlockAuthorityV1::ProviderRefusal,
661 "refusal".to_owned(),
662 "the provider ended the turn with stopReason: refusal".to_owned(),
663 )),
664 ProviderStopReason::ProviderError { message, vendor_code, .. }
665 if provider_error_is_quota(vendor_code.as_deref(), message) =>
666 {
667 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
668 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
669 }
670 _ => None,
671 }
672}
673
674fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
715 let ControlEventKind::ProviderEvent {
716 sequence: provider_sequence,
717 source,
718 source_sequence,
719 event: provider_event,
720 } = &event.event
721 else {
722 return None;
723 };
724 if !provider_family_is_live(source.family) {
725 return None;
726 }
727 let kind = match provider_event {
728 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
729 text: text.clone(),
730 is_delta: *is_delta,
731 },
732 ProviderEvent::Thinking { text } => {
733 AgentStreamChunkKindV1::Thinking { text: text.clone() }
734 }
735 ProviderEvent::InteractionRequested {
736 interaction_kind,
737 tool_name,
738 title,
739 prompt,
740 options,
741 ..
742 } => AgentStreamChunkKindV1::InteractionPrompt {
743 correlation_id: interaction_correlation(
744 event.instance_id,
745 event.generation,
746 *provider_sequence,
747 ),
748 interaction_kind: *interaction_kind,
749 tool_name: tool_name.clone(),
750 title: title.clone(),
759 prompt: prompt.clone(),
760 options: options
761 .iter()
762 .map(|option| AgentStreamInteractionOptionV1 {
763 option_id: option.option_id.clone(),
764 name: option.name.clone(),
765 kind: option.kind.clone(),
766 })
767 .collect(),
768 },
769 ProviderEvent::ConfigOptionsUpdated { options } => {
770 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
771 }
772 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
783 current: Some(mode_id.clone()),
784 available: available
785 .iter()
786 .map(|mode| AgentStreamNamedIdV1 {
787 id: mode.id.clone(),
788 name: mode.name.clone(),
789 description: mode.description.clone(),
790 })
791 .collect(),
792 },
793 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
797 let ProviderHostRequestDecision::Denied { by } = decision else {
798 return None;
799 };
800 let (tool_class, authority, reason_text) =
801 host_request_denied_block(method, *by, reason.as_deref());
802 AgentStreamChunkKindV1::Blocked {
803 correlation_id: None,
804 tool_class,
805 authority,
806 reason_kind: None,
807 reason: reason_text,
808 help: None,
809 }
810 }
811 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
815 let (authority, reason_kind) =
816 provider_tool_block_authority(non_execution_kind.as_deref())?;
817 let (reason, help) = split_provider_denial_text(output);
818 let (reason, _) =
819 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
820 let help = help.map(|help| {
821 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
822 });
823 AgentStreamChunkKindV1::Blocked {
824 correlation_id: Some(tool_correlation(event, source, id)),
825 tool_class: tool_class_from_denial_help(help.as_deref()),
826 authority,
827 reason_kind: Some(reason_kind.to_owned()),
828 reason,
829 help,
830 }
831 }
832 ProviderEvent::SessionEnded { stop_reason, .. } => {
836 let (authority, reason_kind, reason) =
837 stop_reason.as_ref().and_then(provider_session_end_block)?;
838 let (reason, _) =
839 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
840 AgentStreamChunkKindV1::Blocked {
841 correlation_id: None,
842 tool_class: "Session".to_owned(),
843 authority,
844 reason_kind: Some(reason_kind),
845 reason,
846 help: None,
847 }
848 }
849 ProviderEvent::SessionStarted { .. }
850 | ProviderEvent::SessionIdentityObserved { .. }
851 | ProviderEvent::TurnStarted { .. }
852 | ProviderEvent::WorkingObserved
853 | ProviderEvent::ToolStarted { .. }
854 | ProviderEvent::TurnCompleted { .. }
855 | ProviderEvent::ContextWindowUsage { .. }
856 | ProviderEvent::TurnInterrupted
857 | ProviderEvent::Error { .. }
858 | ProviderEvent::Ready
859 | ProviderEvent::InteractionResolved { .. }
860 | ProviderEvent::SubagentStarted { .. }
861 | ProviderEvent::SubagentStopped { .. }
862 | ProviderEvent::RateLimited { .. }
863 | ProviderEvent::UnrecognizedNotification { .. }
864 | ProviderEvent::UserMessage { .. }
865 | ProviderEvent::Plan { .. }
866 | ProviderEvent::AvailableCommandsUpdated { .. }
867 | ProviderEvent::SessionInfoUpdated { .. }
868 | ProviderEvent::UsageUpdated { .. } => return None,
869 };
870 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
871}
872
873async fn run_native_session_catalog_operation<T, E, F>(
874 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
875 operation: &'static str,
876 execute: F,
877) -> Result<T, NodeFailure>
878where
879 T: Send + 'static,
880 E: std::fmt::Display + Send + 'static,
881 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
882{
883 let task = tokio::task::spawn_blocking(move || {
884 let mut catalog = match catalog.try_lock() {
885 Ok(catalog) => catalog,
886 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
887 Err(std::sync::TryLockError::WouldBlock) => {
888 return Err(failure(
889 NodeFailureCode::BackendBusy,
890 "native session catalog operation is already running",
891 ));
892 }
893 };
894 execute(&mut catalog).map_err(|error| {
895 failure(
896 NodeFailureCode::BackendOperationFailed,
897 &format!("{operation} failed: {error}"),
898 )
899 })
900 });
901 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
902 .await
903 .map_err(|_| {
904 failure(
905 NodeFailureCode::BackendBusy,
906 &format!("{operation} exceeded its bounded deadline"),
907 )
908 })?
909 .map_err(|_| {
910 failure(
911 NodeFailureCode::BackendOperationFailed,
912 &format!("{operation} task failed"),
913 )
914 })?
915}
916
917fn windows_path_text(path: &OpaqueHostPath) -> &str {
918 path.as_utf8()
919 .expect("Windows node only creates UTF-8 opaque host paths")
920}
921
922fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
923 path.as_utf8()
924 .map(str::to_owned)
925 .ok_or_else(|| failure(
926 NodeFailureCode::InvalidRequest,
927 "this node requires UTF-8 host paths",
928 ))
929}
930
931fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
932 GitWorktreeSnapshot {
933 path: opaque_windows_path(worktree.path),
934 head: worktree.head,
935 branch: worktree.branch,
936 is_bare: worktree.is_bare,
937 is_main: worktree.is_main,
938 locked: worktree.locked,
939 lock_reason: worktree.lock_reason,
940 prunable: worktree.prunable,
941 prunable_reason: worktree.prunable_reason,
942 workspace_id: worktree.workspace_id.take(),
943 }
944}
945
946#[derive(Clone, Debug, Eq, PartialEq)]
947pub struct WorkspaceConfig {
948 workspace_id: WorkspaceId,
949 canonical_root: String,
950 worktree_service_mode: WorktreeServiceMode,
951 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
952}
953
954impl WorkspaceConfig {
955 pub fn new(
956 workspace_id: WorkspaceId,
957 root: impl AsRef<Path>,
958 ) -> Result<Self, NodeServerError> {
959 let root = root.as_ref();
960 if !root.is_absolute() {
961 return Err(NodeServerError::InvalidWorkspaceRoot {
962 workspace_id,
963 path: root.to_string_lossy().into_owned(),
964 message: "path is not absolute".to_owned(),
965 });
966 }
967 let canonical = std::fs::canonicalize(root).map_err(|error| {
968 NodeServerError::InvalidWorkspaceRoot {
969 workspace_id: workspace_id.clone(),
970 path: root.to_string_lossy().into_owned(),
971 message: error.to_string(),
972 }
973 })?;
974 if !canonical.is_dir() {
975 return Err(NodeServerError::InvalidWorkspaceRoot {
976 workspace_id,
977 path: root.to_string_lossy().into_owned(),
978 message: "path is not a directory".to_owned(),
979 });
980 }
981 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
982 NodeServerError::InvalidWorkspaceRoot {
983 workspace_id: workspace_id.clone(),
984 path: path.to_string_lossy().into_owned(),
985 message: "canonical path is not valid Unicode".to_owned(),
986 }
987 })?;
988 let canonical_root = platform::normalize_canonical_root(canonical_root);
989 validate_workspace_root(&workspace_id, &canonical_root)?;
990 Ok(Self {
991 workspace_id,
992 canonical_root,
993 worktree_service_mode: WorktreeServiceMode::Manual,
994 managed_worktree_profiles: BTreeMap::new(),
995 })
996 }
997
998 pub fn workspace_id(&self) -> &WorkspaceId {
999 &self.workspace_id
1000 }
1001
1002 pub fn canonical_root(&self) -> &str {
1003 &self.canonical_root
1004 }
1005
1006 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1007 self.worktree_service_mode = mode;
1008 self
1009 }
1010
1011 pub fn with_managed_worktree_profile(
1012 mut self,
1013 profile: ManagedWorktreeProfile,
1014 ) -> Result<Self, NodeServerError> {
1015 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1016 NodeServerError::InvalidManagedWorktreeProfile {
1017 workspace_id: self.workspace_id.clone(),
1018 profile_id: profile.profile_id().clone(),
1019 message,
1020 }
1021 })?;
1022 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1023 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1024 workspace_id: self.workspace_id,
1025 profile_id: profile.profile_id().clone(),
1026 });
1027 }
1028 if self.managed_worktree_profiles.len()
1029 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1030 {
1031 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1032 workspace_id: self.workspace_id,
1033 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1034 });
1035 }
1036 self.managed_worktree_profiles
1037 .insert(profile.profile_id().clone(), profile);
1038 Ok(self)
1039 }
1040}
1041
1042#[derive(Clone)]
1043pub struct NodeServerConfig {
1044 pub endpoint: String,
1045 api_listen: Option<std::net::SocketAddr>,
1046 bridge_listen: Option<std::net::SocketAddr>,
1049 bridge_token: Option<String>,
1052 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1055 call_home: Option<std::net::SocketAddr>,
1058 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1062 pub node_id: NodeId,
1063 pub workspaces: Vec<WorkspaceConfig>,
1064 access_token: String,
1065 pub runtime: NativeRuntimeConfig,
1066 session_record_retention: SessionRecordRetentionConfig,
1067 state_path: Option<PathBuf>,
1068 spawn_profiles: SpawnProfileRegistry,
1069 session_environment: Option<NodeSessionEnvironmentConfig>,
1070 history: Option<NativeHistoryConfig>,
1071 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1072 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1076 #[cfg(feature = "fixture")]
1077 fixture_raw_pty_runtime: bool,
1078}
1079
1080#[derive(Clone)]
1081struct NodeSessionEnvironmentConfig {
1082 root: PathBuf,
1083 resolver: Arc<dyn NodeSecretResolver>,
1084}
1085
1086impl NodeServerConfig {
1087 pub fn new(
1088 endpoint: impl Into<String>,
1089 access_token: impl Into<String>,
1090 node_id: NodeId,
1091 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1092 ) -> Result<Self, NodeServerError> {
1093 let endpoint = endpoint.into();
1094 let access_token = access_token.into();
1095 if !platform::validate_endpoint(&endpoint) {
1096 return Err(NodeServerError::InvalidEndpoint);
1097 }
1098 if access_token.is_empty() || access_token.len() > 4_096 {
1099 return Err(NodeServerError::InvalidAccessToken);
1100 }
1101 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1102 if workspaces.is_empty() {
1103 return Err(NodeServerError::NoWorkspaces);
1104 }
1105 let mut workspace_ids = BTreeMap::new();
1106 let mut workspace_roots = BTreeMap::new();
1107 for workspace in &workspaces {
1108 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1109 && workspace.managed_worktree_profiles.is_empty()
1110 {
1111 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1112 workspace.workspace_id.clone(),
1113 ));
1114 }
1115 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1116 && !workspace.managed_worktree_profiles.is_empty()
1117 {
1118 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1119 workspace.workspace_id.clone(),
1120 ));
1121 }
1122 if workspace_ids
1123 .insert(workspace.workspace_id.clone(), ())
1124 .is_some()
1125 {
1126 return Err(NodeServerError::DuplicateWorkspaceId(
1127 workspace.workspace_id.clone(),
1128 ));
1129 }
1130 let root_key = platform::root_identity(&workspace.canonical_root);
1131 if let Some(existing) = workspace_roots.insert(
1132 root_key,
1133 workspace.workspace_id.clone(),
1134 ) {
1135 return Err(NodeServerError::DuplicateWorkspaceRoot {
1136 first: existing,
1137 second: workspace.workspace_id.clone(),
1138 root: workspace.canonical_root.clone(),
1139 });
1140 }
1141 }
1142 Ok(Self {
1143 endpoint,
1144 api_listen: None,
1145 bridge_listen: None,
1146 bridge_token: None,
1147 bridge_underlay: None,
1148 call_home: None,
1149 call_home_identity: None,
1150 node_id,
1151 workspaces,
1152 access_token,
1153 runtime: NativeRuntimeConfig {
1156 worker_poll_interval_ms: 4,
1157 ..NativeRuntimeConfig::default()
1158 },
1159 session_record_retention: SessionRecordRetentionConfig::default(),
1160 state_path: None,
1161 spawn_profiles: SpawnProfileRegistry::default(),
1162 session_environment: None,
1163 history: None,
1164 harness_mcp_helper: None,
1165 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1166 #[cfg(feature = "fixture")]
1167 fixture_raw_pty_runtime: false,
1168 })
1169 }
1170
1171 pub fn with_api_listen(
1172 mut self,
1173 api_listen: std::net::SocketAddr,
1174 ) -> Result<Self, NodeServerError> {
1175 if !api_listen.ip().is_loopback() {
1176 return Err(NodeServerError::InvalidApiListen(api_listen));
1177 }
1178 self.api_listen = Some(api_listen);
1179 Ok(self)
1180 }
1181
1182 pub fn with_bridge_listen(
1186 mut self,
1187 bridge_listen: std::net::SocketAddr,
1188 ) -> Result<Self, NodeServerError> {
1189 if !bridge_listen.ip().is_loopback() {
1190 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1191 }
1192 self.bridge_listen = Some(bridge_listen);
1193 Ok(self)
1194 }
1195
1196 pub fn with_bridge_token(
1199 mut self,
1200 bridge_token: impl Into<String>,
1201 ) -> Result<Self, NodeServerError> {
1202 let bridge_token = bridge_token.into();
1203 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1204 return Err(NodeServerError::InvalidBridgeToken);
1205 }
1206 self.bridge_token = Some(bridge_token);
1207 Ok(self)
1208 }
1209
1210 pub fn with_bridge_underlay(
1215 mut self,
1216 bind: std::net::SocketAddr,
1217 transport_key: [u8; 32],
1218 underlay_token: impl Into<String>,
1219 ) -> Result<Self, NodeServerError> {
1220 if self.bridge_listen.is_none() {
1221 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1222 }
1223 if self.bridge_token.is_none() {
1224 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1225 }
1226 let underlay_token = underlay_token.into();
1227 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1228 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1229 }
1230 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1232 bind,
1233 transport_key,
1234 underlay_token,
1235 });
1236 Ok(self)
1237 }
1238
1239 pub fn with_call_home(
1260 mut self,
1261 relay: std::net::SocketAddr,
1262 ) -> Result<Self, NodeServerError> {
1263 if relay.port() == 0 || relay.ip().is_unspecified() {
1266 return Err(NodeServerError::InvalidCallHome(relay));
1267 }
1268 self.call_home = Some(relay);
1269 Ok(self)
1270 }
1271
1272 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1274 self.call_home_identity = Some(identity);
1275 self
1276 }
1277
1278 pub fn with_state_path(
1279 mut self,
1280 state_path: impl Into<PathBuf>,
1281 ) -> Result<Self, NodeServerError> {
1282 let state_path = state_path.into();
1283 if !state_path.is_absolute() || state_path.file_name().is_none() {
1284 return Err(NodeServerError::InvalidStatePath(
1285 state_path.to_string_lossy().into_owned(),
1286 ));
1287 }
1288 self.state_path = Some(state_path);
1289 Ok(self)
1290 }
1291
1292 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1293 self.spawn_profiles = spawn_profiles;
1294 self
1295 }
1296
1297 pub fn with_session_record_retention(
1303 mut self,
1304 retention: SessionRecordRetentionConfig,
1305 ) -> Self {
1306 self.session_record_retention = retention;
1307 self
1308 }
1309
1310 pub fn with_session_environment_materialization(
1311 mut self,
1312 root: impl Into<PathBuf>,
1313 resolver: Arc<dyn NodeSecretResolver>,
1314 ) -> Result<Self, NodeServerError> {
1315 let root = root.into();
1316 if !root.is_absolute() {
1317 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1318 }
1319 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1320 Ok(self)
1321 }
1322
1323 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1326 self.history = Some(history);
1327 self
1328 }
1329
1330 pub fn with_harness_mcp_helper(
1336 mut self,
1337 helper_program: impl Into<PathBuf>,
1338 ) -> Result<Self, NodeServerError> {
1339 self.harness_mcp_helper = Some(
1340 ReviewedHarnessMcpProgram::review(helper_program.into())
1341 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1342 );
1343 Ok(self)
1344 }
1345
1346 pub fn with_network_allowlist_catalog(
1351 mut self,
1352 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1353 ) -> Result<Self, NodeServerError> {
1354 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1355 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1356 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1357 });
1358 }
1359 self.network_allowlist_catalog = catalog;
1360 Ok(self)
1361 }
1362
1363 pub fn with_network_allowlist_catalog_file(
1367 self,
1368 path: impl AsRef<std::path::Path>,
1369 ) -> Result<Self, NodeServerError> {
1370 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1371 .map_err(NodeServerError::from)?;
1372 self.with_network_allowlist_catalog(catalog)
1373 }
1374}
1375
1376pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1377 platform::default_state_path(node_id.as_str())
1378 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1379}
1380
1381pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1382 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1383}
1384
1385fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1386 let parent = state_path.parent()?;
1387 let mut name = state_path.file_name()?.to_os_string();
1388 name.push(".delivery-store");
1389 Some(parent.join(name))
1390}
1391
1392fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1393 let parent = state_path.parent()?;
1394 let mut name = state_path.file_name()?.to_os_string();
1395 name.push(".context-pack-store");
1396 Some(parent.join(name))
1397}
1398
1399pub struct NodeServer {
1400 config: NodeServerConfig,
1401 runtime: NativeRuntime,
1402 shared: Arc<NodeShared>,
1403 events: EventSubscription,
1404 state_path_lock: Option<session_registry::StatePathLock>,
1405}
1406
1407#[cfg(feature = "fixture")]
1408#[derive(Clone, Default)]
1409pub struct SpawnManagedWorktreeV2FailureProbe {
1410 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1411}
1412
1413#[cfg(feature = "fixture")]
1414impl SpawnManagedWorktreeV2FailureProbe {
1415 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1416 *self
1417 .latest_code
1418 .lock()
1419 .unwrap_or_else(|poisoned| poisoned.into_inner())
1420 }
1421
1422 fn record(&self, code: Option<NodeFailureCode>) {
1423 *self
1424 .latest_code
1425 .lock()
1426 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1427 }
1428}
1429
1430#[cfg(all(feature = "fixture", windows))]
1431struct IsolatedCodexHomeFixtureEnvironment;
1432
1433#[cfg(all(feature = "fixture", windows))]
1434impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1435 for IsolatedCodexHomeFixtureEnvironment
1436{
1437 fn resolve_child_environment(
1438 &self,
1439 ) -> Result<
1440 Vec<gate4agent_catalog::EnvMutation>,
1441 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1442 > {
1443 Ok(vec![gate4agent_catalog::EnvMutation {
1444 key: OsString::from("USERPROFILE"),
1445 value: None,
1446 }])
1447 }
1448}
1449
1450impl NodeServer {
1451 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1454 self.shared.m4a_accounts.as_ref()?;
1455 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1456 }
1457
1458 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1459 let catalog = active_registry()?;
1460 Self::new_with_registry(config, catalog)
1461 }
1462
1463 #[cfg(feature = "fixture")]
1464 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1465 let mut spec = gate4agent_testkit::interactive_agent_spec();
1466 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1467 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1468 Self::new_fixture_with_spec(config, spec)
1469 }
1470
1471 #[cfg(feature = "fixture")]
1472 pub fn new_clean_exit_fixture(
1473 config: NodeServerConfig,
1474 fixture_root: PathBuf,
1475 started_marker: PathBuf,
1476 release_signal: PathBuf,
1477 ) -> Result<Self, NodeServerError> {
1478 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1479 &fixture_root,
1480 &started_marker,
1481 &release_signal,
1482 )
1483 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1484 spec.id = AgentId::new("claude")
1485 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1486 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1487 Self::new_fixture_with_spec(config, spec)
1488 }
1489
1490 #[cfg(all(feature = "fixture", windows))]
1500 pub fn new_durable_context_pack_clean_exit_fixture(
1501 config: NodeServerConfig,
1502 fixture_root: PathBuf,
1503 started_marker: PathBuf,
1504 release_signal: PathBuf,
1505 session_id: &str,
1506 ) -> Result<Self, NodeServerError> {
1507 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1508 &fixture_root,
1509 &started_marker,
1510 &release_signal,
1511 session_id,
1512 )
1513 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1514 let source_id = AgentId::new("claude")
1515 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1516 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1517 NodeServerError::Registry(
1518 "durable context pack clean-exit fixture source adapter is unavailable"
1519 .to_owned(),
1520 )
1521 })?;
1522 source_spec.capabilities.adapters.history =
1523 source_provider.capabilities.adapters.history.clone();
1524 source_spec.id = source_id;
1525 source_spec.display_name =
1526 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1527 source_spec.detection.command =
1533 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1534 source_spec.detection.aliases.clear();
1535
1536 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1537 target_spec.id = AgentId::new("codex")
1538 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1539 target_spec.display_name =
1540 "Controlled Codex durable context-pack target fixture".to_owned();
1541 target_spec.detection.command =
1542 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1543 target_spec.detection.aliases.clear();
1544
1545 let catalog = AgentRegistry::new([source_spec, target_spec])
1546 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1547 let mut server = Self::new_with_registry(config, catalog)?;
1548 Arc::get_mut(&mut server.shared)
1549 .expect("fixture Node shared state must remain uniquely owned before run")
1550 .fixture_semantic_hook_policy = true;
1551 Ok(server)
1552 }
1553
1554 #[cfg(feature = "fixture")]
1555 pub fn new_monitoring_hook_fixture(
1556 config: NodeServerConfig,
1557 ) -> Result<Self, NodeServerError> {
1558 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1559 spec.id = AgentId::new("claude")
1560 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1561 let mut server = Self::new_fixture_with_spec(config, spec)?;
1562 Arc::get_mut(&mut server.shared)
1563 .expect("fixture Node shared state must remain uniquely owned before run")
1564 .fixture_semantic_hook_policy = true;
1565 Ok(server)
1566 }
1567
1568 #[cfg(all(feature = "fixture", windows))]
1569 pub fn new_provider_bundle_argv_fixture(
1570 config: NodeServerConfig,
1571 agent_id: AgentId,
1572 proof_path: PathBuf,
1573 ) -> Result<Self, NodeServerError> {
1574 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1575 Self::new_fixture_with_spec(config, spec)
1576 }
1577
1578 #[cfg(all(feature = "fixture", windows))]
1579 pub fn new_provider_bundle_argv_hold_fixture(
1580 config: NodeServerConfig,
1581 agent_id: AgentId,
1582 proof_path: PathBuf,
1583 release_signal: PathBuf,
1584 ) -> Result<Self, NodeServerError> {
1585 let spec = Self::provider_bundle_argv_fixture_spec(
1586 agent_id,
1587 proof_path,
1588 Some(release_signal),
1589 )?;
1590 Self::new_fixture_with_spec(config, spec)
1591 }
1592
1593 #[cfg(all(feature = "fixture", windows))]
1594 fn provider_bundle_argv_fixture_spec(
1595 agent_id: AgentId,
1596 proof_path: PathBuf,
1597 release_signal: Option<PathBuf>,
1598 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1599 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1600 return Err(NodeServerError::Registry(
1601 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1602 ));
1603 }
1604 let mut spec = gate4agent_testkit::interactive_agent_spec();
1605 spec.id = agent_id.clone();
1606 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1607 let provider = builtin_registry()
1608 .get(&agent_id)
1609 .ok_or_else(|| {
1610 NodeServerError::Registry(
1611 "provider bundle argv fixture adapter is unavailable".to_owned(),
1612 )
1613 })?;
1614 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1615 if !proof_path.is_absolute()
1616 || !proof_path
1617 .parent()
1618 .is_some_and(|parent| parent.is_dir())
1619 {
1620 return Err(NodeServerError::Registry(
1621 "provider bundle argv proof path requires an existing absolute parent"
1622 .to_owned(),
1623 ));
1624 }
1625 if let Some(release_signal) = release_signal.as_ref() {
1626 if !release_signal.is_absolute()
1627 || !release_signal
1628 .parent()
1629 .is_some_and(|parent| parent.is_dir())
1630 {
1631 return Err(NodeServerError::Registry(
1632 "provider bundle argv release path requires an existing absolute parent"
1633 .to_owned(),
1634 ));
1635 }
1636 if release_signal.exists() {
1637 return Err(NodeServerError::Registry(
1638 "provider bundle argv release path must not exist".to_owned(),
1639 ));
1640 }
1641 let proof_parent = std::fs::canonicalize(
1642 proof_path
1643 .parent()
1644 .expect("validated provider bundle proof parent"),
1645 )
1646 .map_err(|_| {
1647 NodeServerError::Registry(
1648 "provider bundle argv proof parent is unavailable".to_owned(),
1649 )
1650 })?;
1651 let release_parent = std::fs::canonicalize(
1652 release_signal
1653 .parent()
1654 .expect("validated provider bundle release parent"),
1655 )
1656 .map_err(|_| {
1657 NodeServerError::Registry(
1658 "provider bundle argv release parent is unavailable".to_owned(),
1659 )
1660 })?;
1661 if proof_parent != release_parent {
1662 return Err(NodeServerError::Registry(
1663 "provider bundle argv release path must share the proof parent".to_owned(),
1664 ));
1665 }
1666 }
1667 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1668 NodeServerError::Registry(
1669 "provider bundle argv proof path is not valid Unicode".to_owned(),
1670 )
1671 })?;
1672 let release_signal = release_signal.map(|release_signal| {
1673 release_signal.into_os_string().into_string().map_err(|_| {
1674 NodeServerError::Registry(
1675 "provider bundle argv release path is not valid Unicode".to_owned(),
1676 )
1677 })
1678 }).transpose()?;
1679 let script = spec
1680 .launch
1681 .fixed_args
1682 .pop()
1683 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1684 let bundle_validation = if agent_id.as_str() == "codex" {
1685 "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') }"
1686 } else if agent_id.as_str() == "claude" {
1687 "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 }"
1688 } else {
1689 "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 }"
1690 };
1691 if let Some(release_signal) = release_signal {
1692 spec.launch.fixed_args.push(format!(
1693 "& {{ 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} }}",
1694 ));
1695 spec.launch.fixed_args.push(proof_path);
1696 spec.launch.fixed_args.push(release_signal);
1697 } else {
1698 spec.launch.fixed_args.push(format!(
1699 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1700 ));
1701 spec.launch.fixed_args.push(proof_path);
1702 }
1703 Ok(spec)
1704 }
1705
1706 #[cfg(all(feature = "fixture", windows))]
1707 pub fn new_context_pack_fixture(
1708 config: NodeServerConfig,
1709 proof_path: PathBuf,
1710 ) -> Result<Self, NodeServerError> {
1711 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1712 Self::new_with_registry(config, catalog)
1713 }
1714
1715 #[cfg(all(feature = "fixture", windows))]
1716 pub fn new_monitoring_context_pack_fixture(
1717 config: NodeServerConfig,
1718 proof_path: PathBuf,
1719 ) -> Result<Self, NodeServerError> {
1720 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1721 let mut server = Self::new_with_registry(config, catalog)?;
1722 Arc::get_mut(&mut server.shared)
1723 .expect("fixture Node shared state must remain uniquely owned before run")
1724 .fixture_semantic_hook_policy = true;
1725 Ok(server)
1726 }
1727
1728 #[cfg(all(feature = "fixture", windows))]
1729 pub fn install_isolated_codex_home_environment_fixture(
1730 &mut self,
1731 profile_id: SpawnEnvironmentProfileId,
1732 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1733 ) -> Result<(), NodeServerError> {
1734 let provider = AgentId::new("codex")
1735 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1736 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1737 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1738 "{}-pty",
1739 profile_id.as_str(),
1740 ))
1741 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1742 provider.clone(),
1743 TransportKind::Pty,
1744 vec![OsString::from("USERPROFILE")],
1745 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1746 )
1747 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1748 let materialization = NodeSessionMaterializationProfile::new(
1749 Vec::new(),
1750 vec![crate::session_environment::NodeSessionPathBinding::new(
1751 "CODEX_HOME",
1752 crate::session_environment::NodeSessionPathClass::ProviderHome,
1753 )
1754 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1755 Vec::new(),
1756 )
1757 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1758 self.install_environment_profile(
1759 NodeEnvironmentProfile::new_with_materialization(
1760 profile_id,
1761 profile_revision,
1762 provider,
1763 [native_profile],
1764 Some(materialization),
1765 )
1766 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1767 )
1768 }
1769
1770 #[cfg(all(feature = "fixture", windows))]
1771 pub fn new_context_only_proof_fixture(
1772 config: NodeServerConfig,
1773 target_provider: AgentId,
1774 proof_path: PathBuf,
1775 ) -> Result<Self, NodeServerError> {
1776 if target_provider.as_str() != "kimi" {
1777 return Err(NodeServerError::Registry(
1778 "context-only proof fixture requires Kimi".to_owned(),
1779 ));
1780 }
1781 let catalog = Self::context_pack_fixture_catalog(
1782 proof_path,
1783 Some(&target_provider),
1784 false,
1785 )?;
1786 Self::new_with_registry(config, catalog)
1787 }
1788
1789 #[cfg(all(feature = "fixture", windows))]
1790 fn context_pack_fixture_catalog(
1791 proof_path: PathBuf,
1792 context_only_target: Option<&AgentId>,
1793 monitoring_claude_source: bool,
1794 ) -> Result<AgentRegistry, NodeServerError> {
1795 if !proof_path.is_absolute()
1796 || !proof_path
1797 .parent()
1798 .is_some_and(|parent| parent.is_dir())
1799 {
1800 return Err(NodeServerError::Registry(
1801 "context pack proof path requires an existing absolute parent".to_owned(),
1802 ));
1803 }
1804 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1805 NodeServerError::Registry(
1806 "context pack proof path is not valid Unicode".to_owned(),
1807 )
1808 })?;
1809 let providers = ["claude", "codex", "grok", "kimi"];
1810 let builtins = builtin_registry();
1811 let mut specs = Vec::with_capacity(providers.len());
1812 for provider_id in providers {
1813 let agent_id = AgentId::new(provider_id)
1814 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1815 let provider = builtins.get(&agent_id).ok_or_else(|| {
1816 NodeServerError::Registry(
1817 "context pack fixture provider adapter is unavailable".to_owned(),
1818 )
1819 })?;
1820 let monitored_provider =
1821 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1822 let mut spec = if monitored_provider {
1823 gate4agent_testkit::monitoring_hook_agent_spec()
1824 } else {
1825 gate4agent_testkit::interactive_agent_spec()
1826 };
1827 spec.id = agent_id.clone();
1828 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1829 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1830 spec.detection.aliases.clear();
1831 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1832 if provider_id == "codex" {
1833 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1834 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1835 })?;
1836 let validation = r#"
1837$bundleArgs = @($bundleArgs)
1838if ($bundleArgs.Count -ne 0) { exit 91 }
1839$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1840$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1841$cwd = (Get-Location).ProviderPath
1842if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1843$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1844if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1845$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1846if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1847$sha = [Security.Cryptography.SHA256]::Create()
1848try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1849if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1850if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1851$contextPath = Join-Path $contextRoot 'context-pack.json'
1852if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1853$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1854if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1855$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1856$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1857$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1858$separator = [IO.Path]::DirectorySeparatorChar
1859if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1860if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1861$rawContext = [IO.File]::ReadAllText($contextPath)
1862if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1863try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1864$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1865$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1866foreach ($property in @($document.PSObject.Properties.Name)) {
1867 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1868}
1869foreach ($property in $requiredTopLevel) {
1870 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1871}
1872if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1873if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1874$messages = @($document.retained_messages)
1875if ($messages.Count -eq 0) { exit 107 }
1876foreach ($message in $messages) {
1877 $messageProperties = @($message.PSObject.Properties.Name)
1878 foreach ($property in $messageProperties) {
1879 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1880 }
1881 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1882}
1883$sha = [Security.Cryptography.SHA256]::Create()
1884try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1885[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))
1886$rawProof = [IO.File]::ReadAllText($proofPath)
1887if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1888[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1889"#;
1890 spec.launch.fixed_args.push(format!(
1891 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1892 ));
1893 spec.launch.fixed_args.push(proof_path.clone());
1894 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1895 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1896 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1897 })?;
1898 let validation = r#"
1899$bundleArgs = @($bundleArgs)
1900if ($bundleArgs.Count -ne 0) { exit 91 }
1901$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1902$cwd = (Get-Location).ProviderPath
1903if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1904$contextPath = Join-Path $contextRoot 'context-pack.json'
1905if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1906$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1907if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1908$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1909$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1910$separator = [IO.Path]::DirectorySeparatorChar
1911if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1912try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1913if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1914if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1915$messages = @($document.retained_messages)
1916if ($messages.Count -eq 0) { exit 99 }
1917$hasUser = $false
1918$hasAssistant = $false
1919foreach ($message in $messages) {
1920 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1921 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1922 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1923 else { exit 101 }
1924}
1925if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1926$sha = [Security.Cryptography.SHA256]::Create()
1927try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1928[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1929[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1930"#;
1931 spec.launch.fixed_args.push(format!(
1932 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1933 ));
1934 spec.launch.fixed_args.push(proof_path.clone());
1935 }
1936 specs.push(spec);
1937 }
1938 let catalog = AgentRegistry::new(specs)
1939 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1940 Ok(catalog)
1941 }
1942
1943 #[cfg(feature = "fixture")]
1944 pub fn new_exact_launcher_fixture(
1945 config: NodeServerConfig,
1946 agent_id: AgentId,
1947 launcher: String,
1948 ) -> Result<Self, NodeServerError> {
1949 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1950 return Err(NodeServerError::Registry(
1951 "exact launcher fixture requires a supported provider".to_owned(),
1952 ));
1953 }
1954 if launcher.contains('\0') {
1955 return Err(NodeServerError::Registry(
1956 "exact launcher fixture path contains NUL".to_owned(),
1957 ));
1958 }
1959 let launcher_path = Path::new(&launcher);
1960 if !launcher_path.is_absolute() {
1961 return Err(NodeServerError::Registry(
1962 "exact launcher fixture path must be absolute".to_owned(),
1963 ));
1964 }
1965 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1966 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1967 })?;
1968 if !metadata.is_file() {
1969 return Err(NodeServerError::Registry(
1970 "exact launcher fixture path must be a regular file".to_owned(),
1971 ));
1972 }
1973 let mut spec = builtin_registry()
1974 .get(&agent_id)
1975 .ok_or_else(|| {
1976 NodeServerError::Registry(
1977 "exact launcher fixture provider is unavailable".to_owned(),
1978 )
1979 })?
1980 .clone();
1981 spec.launch.program = launcher;
1982 Self::new_fixture_with_spec(config, spec)
1983 }
1984
1985 #[cfg(feature = "fixture")]
1994 fn h3b_provider_spec_with_program_and_args(
1995 agent_id: AgentId,
1996 provider_program: PathBuf,
1997 fixed_args: Vec<OsString>,
1998 ) -> Result<AgentSpec, NodeServerError> {
1999 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2000 return Err(NodeServerError::Registry(
2001 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2002 ));
2003 }
2004 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2005 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2006 })?;
2007 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2008 return Err(NodeServerError::Registry(
2009 "H3B fixture provider must be an exact regular file".to_owned(),
2010 ));
2011 }
2012 let program = provider_program.into_os_string().into_string().map_err(|_| {
2013 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2014 })?;
2015 let fixed_args = fixed_args.into_iter().map(|argument| {
2016 argument.into_string().map_err(|_| NodeServerError::Registry(
2017 "H3B fixture argument is not Unicode".to_owned(),
2018 ))
2019 }).collect::<Result<Vec<_>, _>>()?;
2020 let argument_bytes = fixed_args.iter()
2021 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2022 if program.contains('\0')
2023 || fixed_args.iter().any(|argument| argument.contains('\0'))
2024 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2025 {
2026 return Err(NodeServerError::Registry(
2027 "H3B fixture provider or arguments are invalid".to_owned(),
2028 ));
2029 }
2030 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2031 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2032 })?.clone();
2033 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2034 .ok_or_else(|| NodeServerError::Registry(
2035 "H3B fixture provider basename is unavailable".to_owned(),
2036 ))?.to_owned();
2037 spec.detection.command = process_name.clone();
2038 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2039 name: process_name,
2040 }];
2041 spec.launch.program = program;
2042 spec.launch.fixed_args = fixed_args;
2043 Ok(spec)
2044 }
2045
2046 #[cfg(feature = "fixture")]
2047 pub fn new_harness_mcp_proxy_fixture(
2048 config: NodeServerConfig,
2049 provider_program: PathBuf,
2050 fixed_args: Vec<OsString>,
2051 harness_mcp_program: PathBuf,
2052 ) -> Result<Self, NodeServerError> {
2053 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2054 let agent_id = AgentId::new("codex")
2055 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2056 let mut spec =
2057 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2058 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2059 config.fixture_raw_pty_runtime = true;
2060 Self::new_fixture_with_spec(config, spec)
2061 }
2062
2063 #[cfg(feature = "fixture")]
2087 pub fn new_harness_mcp_acp_launcher_fixture(
2088 config: NodeServerConfig,
2089 helper_program: PathBuf,
2090 provider: AgentId,
2091 program: PathBuf,
2092 args: Vec<OsString>,
2093 ) -> Result<Self, NodeServerError> {
2094 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2095 return Err(NodeServerError::Registry(
2096 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2097 ));
2098 }
2099 let config = config.with_harness_mcp_helper(helper_program)?;
2100 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2101 let launch = spec.launch.clone();
2102 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2103 NodeServerError::Registry(
2104 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2105 .to_owned(),
2106 )
2107 })?;
2108 acp.launch_override = Some(launch);
2116 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2117 Self::new_fixture_with_spec(config, spec)
2118 }
2119
2120 #[cfg(feature = "fixture")]
2121 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2122 let mut spec = gate4agent_testkit::interactive_agent_spec();
2123 let claude_id = AgentId::new("claude")
2124 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2125 let claude = builtin_registry()
2126 .get(&claude_id)
2127 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2128 spec.id = claude_id;
2129 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2130 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2131 name: "claude".to_owned(),
2132 }];
2133 spec.capabilities.transports.pty_adapter = claude
2134 .capabilities
2135 .transports
2136 .pty_adapter
2137 .clone();
2138 spec.capabilities.adapters.resume = claude
2139 .capabilities
2140 .adapters
2141 .resume
2142 .clone();
2143 let script = spec
2144 .launch
2145 .fixed_args
2146 .pop()
2147 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2148 spec.launch.fixed_args.push(format!(
2149 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2150 ));
2151 Self::new_fixture_with_spec(config, spec)
2152 }
2153
2154 #[cfg(feature = "fixture")]
2155 pub fn new_cmd_cwd_fixture(
2156 config: NodeServerConfig,
2157 command: String,
2158 ) -> Result<Self, NodeServerError> {
2159 let mut spec = gate4agent_testkit::interactive_agent_spec();
2160 spec.id = AgentId::new("claude")
2161 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2162 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2163 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2164 spec.launch.program = command;
2165 spec.launch.fixed_args.clear();
2166 Self::new_fixture_with_spec(config, spec)
2167 }
2168
2169 #[cfg(feature = "fixture")]
2170 fn new_fixture_with_spec(
2171 config: NodeServerConfig,
2172 spec: gate4agent_types::AgentSpec,
2173 ) -> Result<Self, NodeServerError> {
2174 let catalog = AgentRegistry::new([spec])
2175 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2176 Self::new_with_registry(config, catalog)
2177 }
2178
2179 fn new_with_registry(
2180 config: NodeServerConfig,
2181 catalog: AgentRegistry,
2182 ) -> Result<Self, NodeServerError> {
2183 for profile in config.spawn_profiles.iter() {
2184 if catalog.get(&profile.provider).is_none() {
2185 return Err(NodeServerError::Registry(
2186 "spawn profile references an unavailable provider".to_owned(),
2187 ));
2188 }
2189 }
2190 let input_settle_timeout_ms = catalog
2191 .iter()
2192 .map(|spec| spec.readiness.timeout_ms)
2193 .max()
2194 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2195 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2196 .max(MUTATION_SETTLE_TIMEOUT_MS);
2197 let (provider_contracts, provider_adapter_contracts) =
2198 provider_contract_manifest(&catalog)?;
2199 let compatibility_support = node_compatibility_support_for_manifest(
2200 &provider_contracts,
2201 &provider_adapter_contracts,
2202 )?;
2203 validate_node_negotiated_handshake_capacity(&compatibility_support)
2204 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2205 let enabled_providers = provider_contracts
2206 .iter()
2207 .map(|contract| contract.provider.clone())
2208 .collect();
2209 #[cfg(feature = "fixture")]
2210 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2211 #[cfg(not(feature = "fixture"))]
2212 let fixture_raw_pty_runtime = false;
2213 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2214 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2215 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2216 })).expect("fixture provider catalog remains bounded");
2217 (None, statuses)
2218 } else {
2219 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2220 let statuses = monitor.collect();
2221 (Some(monitor), statuses)
2222 };
2223 let state_path_lock =
2224 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2225 .map_err(|error| {
2226 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2227 })?;
2228 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2229 Some(state_path) => {
2230 let delivery_root = delivery_store_root_for_state_path(state_path)
2231 .ok_or_else(|| {
2232 NodeServerError::DeliveryStore(
2233 "delivery store root could not be derived from state path".to_owned(),
2234 )
2235 })?;
2236 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2237 let detail = match &error {
2238 DeliveryStoreError::Storage(io_error) => {
2239 format!("{error}: {io_error}")
2240 }
2241 _ => error.to_string(),
2242 };
2243 NodeServerError::DeliveryStore(detail)
2244 })?;
2245 (Some(store), bundles)
2246 }
2247 None => (None, Vec::new()),
2248 };
2249 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2250 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2251 })?;
2252 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2253 Some(state_path) => {
2254 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2255 .ok_or(NodeServerError::ContextPackStore)?;
2256 let (store, packs) = ContextPackStore::open(context_pack_root)
2257 .map_err(|_| NodeServerError::ContextPackStore)?;
2258 (Some(store), packs)
2259 }
2260 None => (None, Vec::new()),
2261 };
2262 let mut durable_context_catalog = ContextPackCatalog::default();
2263 for pack in durable_context_packs {
2264 durable_context_catalog
2265 .insert(pack)
2266 .map_err(|_| NodeServerError::ContextPackStore)?;
2267 }
2268 let session_environment_materializer = config
2269 .session_environment
2270 .as_ref()
2271 .map(|environment| {
2272 SessionEnvironmentMaterializer::new(
2273 environment.root.clone(),
2274 Arc::clone(&environment.resolver),
2275 )
2276 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2277 })
2278 .transpose()?;
2279 #[cfg(unix)]
2282 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2283 let native_session_catalog = config.history.clone().map(|history| {
2284 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2285 });
2286 let (handle, runtime) = match config.history.clone() {
2287 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2288 None => NativeRuntime::new(catalog, config.runtime),
2289 };
2290 let native_launch_profile_control = runtime.native_launch_profile_control();
2291 let harness_mcp_registry = config
2292 .harness_mcp_helper
2293 .clone()
2294 .map(HarnessMcpProxyRegistry::new);
2295 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2296 let incarnation_id = random_incarnation_id()
2297 .map_err(NodeServerError::IncarnationIdentity)?;
2298 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2299 .map_err(durable_state_load_error)?;
2300 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2301 let managed_worktree_tombstones =
2302 std::mem::take(&mut loaded.managed_worktree_tombstones);
2303 let materializations = std::mem::take(&mut loaded.materializations);
2304 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2305 let (workspaces, records, persistence_warning) =
2306 merge_durable_state(&config.workspaces, loaded)?;
2307 let mut shared = NodeShared::new_with_incarnation(
2308 handle,
2309 config.access_token.clone(),
2310 config.node_id.clone(),
2311 incarnation_id,
2312 workspaces,
2313 enabled_providers,
2314 provider_runtime_statuses,
2315 provider_runtime_monitor,
2316 provider_contracts,
2317 provider_adapter_contracts,
2318 config.spawn_profiles.clone(),
2319 Some(native_launch_profile_control),
2320 native_session_catalog,
2321 session_environment_materializer,
2322 config.state_path.clone(),
2323 records,
2324 managed_worktrees,
2325 managed_worktree_tombstones,
2326 materializations,
2327 persistence_warning,
2328 input_settle_timeout_ms,
2329 );
2330 shared.harness_mcp_registry = harness_mcp_registry.clone();
2331 shared.session_record_retention = config.session_record_retention;
2332 shared.network_allowlist_catalog =
2333 RwLock::new(config.network_allowlist_catalog.clone());
2334 shared.bundle_catalog = RwLock::new(delivered_catalog);
2335 shared.delivery_store = Mutex::new(delivery_store);
2336 shared.context_catalog = RwLock::new(durable_context_catalog);
2337 shared.context_pack_store = Mutex::new(context_pack_store);
2338 shared.managed_spawn_replays = Mutex::new(
2339 managed_spawn_replays
2340 .into_iter()
2341 .map(|record| (record.idempotency_key.clone(), record))
2342 .collect(),
2343 );
2344 let shared = Arc::new(shared);
2345 if let Some(registry) = harness_mcp_registry {
2346 let weak = Arc::downgrade(&shared);
2347 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2348 Some(shared) => shared.publish_transient(event),
2349 None => false,
2350 }));
2351 }
2352 if shared.persistence_error().is_none() {
2353 shared.persist_state().map_err(|error| {
2354 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2355 })?;
2356 }
2357 Ok(Self {
2358 config,
2359 runtime,
2360 shared,
2361 events,
2362 state_path_lock,
2363 })
2364 }
2365
2366 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2367 NodeShutdownHandle {
2368 shared: Arc::clone(&self.shared),
2369 }
2370 }
2371
2372 #[cfg(feature = "fixture")]
2373 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2374 &self,
2375 ) -> SpawnManagedWorktreeV2FailureProbe {
2376 self.shared
2377 .fixture_spawn_managed_worktree_v2_failure_probe
2378 .clone()
2379 }
2380
2381 pub fn install_environment_profile(
2386 &mut self,
2387 profile: NodeEnvironmentProfile,
2388 ) -> Result<(), NodeServerError> {
2389 if !self.shared.enabled_providers.contains(profile.provider()) {
2390 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2391 profile.provider().clone(),
2392 ));
2393 }
2394 let (binding, native_profiles, materialization) = profile.into_parts();
2395 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2396 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2397 }
2398 let mut profiles = self
2399 .shared
2400 .environment_profiles
2401 .write()
2402 .unwrap_or_else(|poisoned| poisoned.into_inner());
2403 if profiles.contains_key(&binding.id) {
2404 return Err(NodeServerError::DuplicateEnvironmentProfile(
2405 binding.id.clone(),
2406 ));
2407 }
2408 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2409 return Err(NodeServerError::EnvironmentProfileCapacity {
2410 max: MAX_NODE_ENVIRONMENT_PROFILES,
2411 });
2412 }
2413 for native_id in binding.native_profile_ids() {
2414 if profiles.values().any(|current| {
2415 current.native_profile_ids().any(|current_id| current_id == native_id)
2416 }) {
2417 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2418 native_id.to_string(),
2419 ));
2420 }
2421 }
2422
2423 let mut installed = Vec::with_capacity(native_profiles.len());
2424 for native_profile in native_profiles {
2425 let native_id = native_profile.id().clone();
2426 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2427 for installed_id in installed.into_iter().rev() {
2428 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2429 }
2430 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2431 }
2432 installed.push(native_id);
2433 }
2434 if let Some(materialization) = materialization {
2435 self.shared
2436 .environment_materialization_profiles
2437 .write()
2438 .unwrap_or_else(|poisoned| poisoned.into_inner())
2439 .insert(binding.id.clone(), materialization);
2440 }
2441 profiles.insert(binding.id.clone(), binding);
2442 Ok(())
2443 }
2444
2445 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2447 if self.shared.session_environment_materializer.is_none() {
2448 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2449 }
2450 let mut catalog = self
2451 .shared
2452 .bundle_catalog
2453 .write()
2454 .unwrap_or_else(|poisoned| poisoned.into_inner());
2455 catalog
2456 .insert_idempotent(bundle)
2457 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2458 Ok(())
2459 }
2460
2461 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2462 let shutdown = self.shutdown_handle();
2463 let run = self.run();
2464 tokio::pin!(run);
2465 tokio::select! {
2466 result = &mut run => result,
2467 signal = wait_for_ctrl_signal() => {
2468 signal?;
2469 let shutdown_result = shutdown.request_shutdown().await;
2470 let run_result = run.await;
2471 shutdown_result.and(run_result)
2472 }
2473 }
2474 }
2475
2476 pub async fn run(self) -> Result<(), NodeServerError> {
2477 let NodeServer {
2478 config,
2479 mut runtime,
2480 shared,
2481 events,
2482 state_path_lock: _state_path_lock,
2483 } = self;
2484 shared.reconcile_materializations();
2485 shared.reconcile_managed_worktrees().await;
2486 shared.retire_unavailable_session_records().await;
2487 shared.reconcile_context_pack_exports().await;
2488 let endpoint = config.endpoint.clone();
2492 let call_home = config.call_home;
2493 let call_home_identity = config.call_home_identity.clone();
2494 let api_listen = config.api_listen;
2495 let bridge_listen = config.bridge_listen;
2496 let bridge_underlay = config.bridge_underlay;
2497 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2498 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2499 let accept_shared = Arc::clone(&shared);
2500 let api_shared = Arc::clone(&shared);
2501 let bridge_shared = Arc::clone(&shared);
2502 let shutdown_shared = Arc::clone(&shared);
2503 let shutdown_timeout = Duration::from_millis(
2504 config.runtime.provider_shutdown_timeout_ms.max(1),
2505 );
2506 let result = {
2507 let runtime_loop = drive_runtime_until_shutdown(
2508 &mut runtime,
2509 events,
2510 Arc::clone(&shared),
2511 shutdown_timeout,
2512 );
2513 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2514 let api_loop = http_api::run(api_listen, api_shared);
2515 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2516 tokio::pin!(runtime_loop);
2517 tokio::pin!(accept_loop);
2518 tokio::pin!(api_loop);
2519 tokio::pin!(bridge_loop);
2520 tokio::select! {
2521 runtime_result = &mut runtime_loop => {
2522 let shutdown_result = shutdown_shared
2523 .begin_shutdown()
2524 .await
2525 .map_err(NodeServerError::ShutdownDispatch);
2526 let (accept_result, api_result, bridge_result) =
2527 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2528 match runtime_result {
2529 Err(error) => Err(error),
2530 Ok(()) => shutdown_result
2531 .and(accept_result)
2532 .and(api_result.map_err(NodeServerError::HttpApi))
2533 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2534 }
2535 }
2536 accept_result = &mut accept_loop => {
2537 let shutdown_result = shutdown_shared
2538 .begin_shutdown()
2539 .await
2540 .map_err(NodeServerError::ShutdownDispatch);
2541 let (runtime_result, api_result, bridge_result) =
2542 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2543 match accept_result {
2544 Err(error) => Err(error),
2545 Ok(()) => shutdown_result
2546 .and(runtime_result)
2547 .and(api_result.map_err(NodeServerError::HttpApi))
2548 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2549 }
2550 }
2551 api_result = &mut api_loop => {
2552 let shutdown_result = shutdown_shared
2553 .begin_shutdown()
2554 .await
2555 .map_err(NodeServerError::ShutdownDispatch);
2556 let (runtime_result, accept_result, bridge_result) =
2557 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2558 match api_result {
2559 Err(error) => Err(NodeServerError::HttpApi(error)),
2560 Ok(()) => shutdown_result
2561 .and(runtime_result)
2562 .and(accept_result)
2563 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2564 }
2565 }
2566 bridge_result = &mut bridge_loop => {
2567 let shutdown_result = shutdown_shared
2568 .begin_shutdown()
2569 .await
2570 .map_err(NodeServerError::ShutdownDispatch);
2571 let (runtime_result, accept_result, api_result) =
2572 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2573 match bridge_result {
2574 Err(error) => Err(NodeServerError::BridgeApi(error)),
2575 Ok(()) => shutdown_result
2576 .and(runtime_result)
2577 .and(accept_result)
2578 .and(api_result.map_err(NodeServerError::HttpApi)),
2579 }
2580 }
2581 }
2582 };
2583 if let Ok(mut guard) = shared.delivery_store.lock() {
2590 *guard = None;
2591 }
2592 if let Ok(mut guard) = shared.context_pack_store.lock() {
2593 *guard = None;
2594 }
2595 result
2596 }
2597}
2598
2599async fn drive_runtime_until_shutdown(
2600 runtime: &mut NativeRuntime,
2601 events: EventSubscription,
2602 shared: Arc<NodeShared>,
2603 shutdown_timeout: Duration,
2604) -> Result<(), NodeServerError> {
2605 let _ = shared
2609 .shell_efficiency_profile
2610 .set(runtime.shell_efficiency_profile());
2611 let mut shutdown_started = None;
2612 let mut last_retention_sweep = Instant::now();
2613 loop {
2614 let runtime_tick_start = Instant::now();
2621 let tick_result = runtime.tick().await;
2622 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2623
2624 let event_drain_start = Instant::now();
2625 for outcome in &tick_result.command_outcomes {
2633 if let Err(error) = &outcome.result {
2634 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2635 tracing::warn!(
2636 command_id = outcome.command_id.0,
2637 cause = %error,
2638 "control command rejected"
2639 );
2640 shared.record_rejected_command(
2641 outcome.command_id,
2642 error.to_string(),
2643 error.is_unsupported_transport(),
2644 );
2645 }
2646 }
2647 let mut events_drained: u64 = 0;
2648 while let Ok(event) = events.try_recv() {
2649 events_drained += 1;
2650 let clean_exit = matches!(
2651 event.event,
2652 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2653 );
2654 let instance_id = event.instance_id;
2655 let generation = event.generation;
2656 if clean_exit {
2668 if let Some((record_id, session)) =
2669 shared.managed_record_export_target_for_instance(instance_id, generation)
2670 {
2671 let export_shared = Arc::clone(&shared);
2672 tokio::spawn(async move {
2673 export_shared
2674 .reconcile_context_pack_export_for_record(&record_id, session)
2675 .await;
2676 });
2677 }
2678 }
2679 shared.publish_control(event);
2680 }
2681 let event_drain_elapsed = event_drain_start.elapsed();
2682
2683 let publish_terminal_frames_start = Instant::now();
2684 shared.publish_terminal_frames();
2685 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2686
2687 shared
2693 .native_session_gauge
2694 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2695 *shared
2696 .runtime_tick_profile
2697 .lock()
2698 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2699
2700 let remainder_start = Instant::now();
2701 if shared.shutdown.load(Ordering::Acquire) {
2702 let started = *shutdown_started.get_or_insert_with(Instant::now);
2703 let snapshot = shared.handle.snapshot();
2704 let sessions_settled = snapshot.sessions.iter().all(|session| {
2705 !matches!(
2706 session.status,
2707 gate4agent_types::SessionStatus::Starting
2708 | gate4agent_types::SessionStatus::Running
2709 | gate4agent_types::SessionStatus::Stopping
2710 )
2711 });
2712 if runtime.active_native_sessions() == 0 && sessions_settled {
2713 return Ok(());
2714 }
2715 if started.elapsed() >= shutdown_timeout {
2716 return Err(NodeServerError::ShutdownTimedOut {
2717 active_native_sessions: runtime.active_native_sessions(),
2718 });
2719 }
2720 }
2721 let remainder_elapsed = remainder_start.elapsed();
2722
2723 {
2724 let mut profile = shared
2725 .drive_loop_profile
2726 .lock()
2727 .unwrap_or_else(|poisoned| poisoned.into_inner());
2728 profile.record_runtime_tick(runtime_tick_elapsed);
2729 profile.record_event_drain(event_drain_elapsed);
2730 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2731 profile.record_remainder(remainder_elapsed);
2732 profile.note_iteration(Instant::now());
2733 }
2734
2735 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2747 if tick_result.observations_applied == 0 && events_drained == 0 {
2748 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2749 }
2750
2751 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2756 shared.retire_unavailable_session_records().await;
2757 last_retention_sweep = Instant::now();
2758 }
2759
2760 let hot = shared
2763 .pty_input_marks
2764 .lock()
2765 .unwrap_or_else(|poisoned| poisoned.into_inner())
2766 .values()
2767 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2768 if hot {
2769 sleep(Duration::from_millis(1)).await;
2770 } else {
2771 tokio::select! {
2772 _ = sleep(Duration::from_millis(10)) => {}
2773 _ = shared.pty_input_wake.notified() => {}
2774 }
2775 }
2776 }
2777}
2778
2779#[derive(Clone)]
2780pub struct NodeShutdownHandle {
2781 shared: Arc<NodeShared>,
2782}
2783
2784impl NodeShutdownHandle {
2785 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2786 self.shared
2787 .begin_shutdown()
2788 .await
2789 .map_err(NodeServerError::ShutdownDispatch)
2790 }
2791}
2792
2793async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2794 #[cfg(windows)]
2795 {
2796 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2797 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2798 return tokio::select! {
2799 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2800 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2801 };
2802 }
2803 #[cfg(unix)]
2804 {
2805 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2806 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2807 tokio::select! {
2808 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2809 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2810 }
2811 }
2812}
2813
2814fn shell_host_spec() -> AgentSpec {
2828 AgentSpec {
2829 id: AgentId::new("shell").expect("shell agent id"),
2830 revision: "hatchery:shell-pty-1".to_owned(),
2831 display_name: "Host shell".to_owned(),
2832 detection: DetectionSpec {
2833 command: "bash".to_owned(),
2834 aliases: vec!["sh".to_owned(), "shell".to_owned()],
2835 required_commands: Vec::new(),
2836 unsupported_platforms: Vec::new(),
2837 },
2838 launch: LaunchSpec {
2839 program: "/bin/bash".to_owned(),
2841 fixed_args: vec!["-i".to_owned()],
2842 },
2843 expected_processes: vec![ProcessMatcher::Exact {
2844 name: "bash".to_owned(),
2845 }],
2846 prompt: PromptSpec {
2847 initial: InitialPromptMode::None,
2848 native_draft: None,
2849 },
2850 readiness: AgentReadinessSpec::default(),
2851 capabilities: AgentCapabilities {
2852 agent_commands: None,
2853 transports: AgentTransportCapabilities {
2854 pty: true,
2855 pty_adapter: None,
2856 pipe: None,
2857 acp: None,
2858 },
2859 adapters: AgentAdapterCapabilities::default(),
2860 },
2861 verification: SpecVerification::Gate4AgentVerified,
2862 }
2863}
2864
2865fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2866 let mut specs = vec![shell_host_spec()];
2867 specs.extend(
2868 builtin_registry()
2869 .iter()
2870 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2871 .cloned(),
2872 );
2873 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2874}
2875
2876fn provider_contract_manifest(
2877 catalog: &AgentRegistry,
2878) -> Result<
2879 (
2880 Vec<ProviderContractSupport>,
2881 Vec<ProviderAdapterContractSupport>,
2882 ),
2883 NodeServerError,
2884> {
2885 let mut provider_contracts = Vec::new();
2886 let mut provider_adapter_contracts = Vec::new();
2887 for spec in catalog.iter() {
2888 let provider = spec.id.clone();
2889 let revision = ProviderContractRevision::new(spec.revision.clone())
2890 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2891 provider_contracts.push(ProviderContractSupport {
2892 provider: provider.clone(),
2893 revision,
2894 });
2895 for (family, binding) in declared_adapter_bindings(spec)? {
2896 let revision = AdapterContractRevision::new(binding.revision.clone())
2897 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2898 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2899 provider: provider.clone(),
2900 family,
2901 adapter_id: binding.id.clone(),
2902 revision,
2903 });
2904 }
2905 }
2906 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2907 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2908 Ok((provider_contracts, provider_adapter_contracts))
2909}
2910
2911fn declared_adapter_bindings(
2912 spec: &AgentSpec,
2913) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2914 let mut bindings = Vec::new();
2915 push_declared_binding(
2916 &mut bindings,
2917 AdapterFamily::PtySemantic,
2918 spec.capabilities.transports.pty_adapter.as_ref(),
2919 )?;
2920 push_declared_binding(
2921 &mut bindings,
2922 AdapterFamily::Pipe,
2923 spec.capabilities.adapters.pty_sidecar.as_ref(),
2924 )?;
2925 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2926 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2927 gate4agent_types::PipeProtocol::SemanticNdjson
2928 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2929 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2930 });
2931 push_declared_binding(
2932 &mut bindings,
2933 AdapterFamily::Pipe,
2934 pipe_transport
2935 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2936 .map(|transport| &transport.adapter),
2937 )?;
2938 push_declared_binding(
2939 &mut bindings,
2940 AdapterFamily::Acp,
2941 spec.capabilities
2942 .transports
2943 .acp
2944 .as_ref()
2945 .map(|transport| &transport.adapter),
2946 )?;
2947 push_declared_binding(
2948 &mut bindings,
2949 AdapterFamily::Hook,
2950 spec.capabilities.adapters.hook.as_ref(),
2951 )?;
2952 push_declared_binding(
2953 &mut bindings,
2954 AdapterFamily::ManagedHook,
2955 spec.capabilities.adapters.managed_hook.as_ref(),
2956 )?;
2957 push_declared_binding(
2958 &mut bindings,
2959 AdapterFamily::OneShot,
2960 pipe_transport
2961 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2962 .map(|transport| &transport.adapter),
2963 )?;
2964 push_declared_binding(
2965 &mut bindings,
2966 AdapterFamily::OneShot,
2967 spec.capabilities.adapters.one_shot.as_ref(),
2968 )?;
2969 push_declared_binding(
2970 &mut bindings,
2971 AdapterFamily::History,
2972 spec.capabilities.adapters.history.as_ref(),
2973 )?;
2974 push_declared_binding(
2975 &mut bindings,
2976 AdapterFamily::Resume,
2977 spec.capabilities.adapters.resume.as_ref(),
2978 )?;
2979 push_declared_binding(
2980 &mut bindings,
2981 AdapterFamily::SessionOptions,
2982 spec.capabilities.adapters.session_options.as_ref(),
2983 )?;
2984 push_declared_binding(
2985 &mut bindings,
2986 AdapterFamily::CapabilityProbe,
2987 spec.capabilities.adapters.capability_probe.as_ref(),
2988 )?;
2989 Ok(bindings)
2990}
2991
2992fn push_declared_binding<'a>(
2993 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2994 family: AdapterFamily,
2995 binding: Option<&'a AdapterBinding>,
2996) -> Result<(), NodeServerError> {
2997 let Some(binding) = binding else {
2998 return Ok(());
2999 };
3000 if let Some((_, existing)) = bindings
3001 .iter()
3002 .find(|(existing_family, _)| *existing_family == family)
3003 {
3004 if *existing == binding {
3005 return Ok(());
3006 }
3007 return Err(NodeServerError::ProviderContractManifest(format!(
3008 "provider declares conflicting {family:?} adapter bindings"
3009 )));
3010 }
3011 bindings.push((family, binding));
3012 Ok(())
3013}
3014
3015fn merge_durable_state(
3016 configured: &[WorkspaceConfig],
3017 mut loaded: LoadedNodeState,
3018) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3019 let mut workspaces = configured
3020 .iter()
3021 .cloned()
3022 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3023 .collect::<BTreeMap<_, _>>();
3024 let mut roots = workspaces
3025 .values()
3026 .map(|workspace| {
3027 (
3028 platform::root_identity(&workspace.canonical_root),
3029 workspace.workspace_id.clone(),
3030 )
3031 })
3032 .collect::<BTreeMap<_, _>>();
3033 let mut warning = loaded
3034 .warning
3035 .take()
3036 .map(|message| session_registry::sanitized_persistence_summary(&message));
3037
3038 for (workspace_id, canonical_root) in loaded.workspaces {
3039 if let Some(existing) = workspaces.get(&workspace_id) {
3040 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3041 return Err(NodeServerError::DurableState(io::Error::new(
3042 io::ErrorKind::InvalidData,
3043 DURABLE_STATE_CONFLICT_ERROR,
3044 )));
3045 }
3046 continue;
3047 }
3048 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3049 return Err(NodeServerError::DurableState(io::Error::new(
3050 io::ErrorKind::InvalidData,
3051 DURABLE_STATE_CONFLICT_ERROR,
3052 )));
3053 }
3054 if !Path::new(&canonical_root).is_dir() {
3055 warning.get_or_insert_with(|| {
3056 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3057 });
3058 continue;
3059 }
3060 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3061 NodeServerError::DurableState(io::Error::new(
3062 io::ErrorKind::InvalidData,
3063 DURABLE_STATE_CONFLICT_ERROR,
3064 ))
3065 })?;
3066 roots.insert(
3067 platform::root_identity(&workspace.canonical_root),
3068 workspace.workspace_id.clone(),
3069 );
3070 workspaces.insert(workspace_id, workspace);
3071 }
3072
3073 for record in &mut loaded.records {
3074 match workspaces.get(&record.workspace_id) {
3075 Some(workspace)
3076 if platform::roots_equal(
3077 &workspace.canonical_root,
3078 windows_path_text(&record.canonical_root),
3079 ) => {}
3080 Some(_) => {
3081 return Err(NodeServerError::DurableState(io::Error::new(
3082 io::ErrorKind::InvalidData,
3083 DURABLE_STATE_CONFLICT_ERROR,
3084 )));
3085 }
3086 None => {
3087 record.state = ManagedSessionState::Unavailable;
3088 record.active_session = None;
3089 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3090 }
3091 }
3092 }
3093 Ok((workspaces.into_values().collect(), loaded.records, warning))
3094}
3095
3096struct RejectedCommandReason {
3099 message: String,
3100 unsupported_transport: bool,
3107 expires_at: Instant,
3108}
3109
3110struct NodeShared {
3111 handle: Gate4AgentHandle,
3112 access_token: String,
3113 node_id: NodeId,
3114 incarnation_id: NodeIncarnationId,
3115 started_at_unix_ms: u64,
3116 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3117 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3118 managed_worktree_profiles:
3119 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3120 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3121 enabled_providers: Vec<AgentId>,
3122 provider_runtime_statuses: ProviderRuntimeStatuses,
3123 provider_runtime_status_updates:
3124 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3125 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3126 provider_contracts: Vec<ProviderContractSupport>,
3127 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3128 spawn_profiles: SpawnProfileRegistry,
3129 environment_profiles:
3130 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3131 environment_materialization_profiles:
3132 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3133 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3141 #[cfg(feature = "dig2-station-probe")]
3145 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3146 bundle_catalog: RwLock<BundleCatalog>,
3147 delivery_store: Mutex<Option<DeliveryStore>>,
3148 context_pack_store: Mutex<Option<ContextPackStore>>,
3149 context_catalog: RwLock<ContextPackCatalog>,
3150 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3151 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3152 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3153 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3154 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3156 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3158 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3159 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3160 managed_spawn_replays:
3161 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3162 controller: Mutex<Option<ControllerLease>>,
3163 history: Mutex<NodeEventHistory>,
3164 event_tx: broadcast::Sender<NodeEventEnvelope>,
3165 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3166 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3174 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3183 terminal_frame_watermarks:
3184 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3185 pty_input_wake: tokio::sync::Notify,
3187 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3190 next_connection_id: AtomicU64,
3191 next_instance_id: AtomicU64,
3192 next_command_id: AtomicU64,
3193 shutdown: AtomicBool,
3194 shutdown_notify: Notify,
3195 preauth_slots: Arc<Semaphore>,
3196 authenticated_slots: Arc<Semaphore>,
3197 inspection_slots: Arc<Semaphore>,
3198 mutation_gate: AsyncMutex<()>,
3199 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3200 session_records: Mutex<SessionRecords>,
3201 state_path: Option<PathBuf>,
3202 persistence_error: RwLock<Option<String>>,
3203 state_transaction: Mutex<()>,
3204 input_settle_timeout_ms: u64,
3205 drive_loop_profile: Mutex<DriveLoopProfiler>,
3209 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3215 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3227 native_session_gauge: AtomicUsize,
3234 connection_loop_iterations: AtomicU64,
3245 connection_events_sent: AtomicU64,
3246 rejected_commands_total: AtomicU64,
3255 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3275 drive_loop_iterations_total: AtomicU64,
3276 drive_loop_iterations_idle: AtomicU64,
3277 session_record_retention: SessionRecordRetentionConfig,
3281 retired_records_total: AtomicU64,
3287 #[cfg(feature = "fixture")]
3288 fixture_semantic_hook_policy: bool,
3289 #[cfg(feature = "fixture")]
3290 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3291}
3292
3293impl Drop for NodeShared {
3294 fn drop(&mut self) {
3295 SessionLifecycleHook::on_shutdown(self);
3296 }
3297}
3298
3299#[derive(Clone, Debug, Eq, PartialEq)]
3300struct SessionBinding {
3301 workspace_id: WorkspaceId,
3302 generation: SessionGeneration,
3303 runtime_policy: ProviderRuntimePolicy,
3304 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3305 record_id: Option<SessionRecordId>,
3306 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3307 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3308 bundle: Option<ResolvedBundleReceipt>,
3309 context: Option<ResolvedContextPackReceipt>,
3310 materialization_id: Option<MaterializationId>,
3311}
3312
3313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3314enum SpawnRecordPolicy {
3315 ProviderIdentityOnly,
3316 Always,
3317}
3318
3319struct NativeEnvironmentSelectionGuard {
3320 control: Option<NativeLaunchProfileControl>,
3321 instance_id: AgentInstanceId,
3322}
3323
3324struct NativeInstanceOverlayGuard {
3325 control: Option<NativeLaunchProfileControl>,
3326 instance_id: AgentInstanceId,
3327}
3328
3329struct NativeHarnessMcpOverlayGuard {
3330 control: NativeLaunchProfileControl,
3331 instance_id: AgentInstanceId,
3332}
3333
3334impl NativeHarnessMcpOverlayGuard {
3335 fn retain(self) { std::mem::forget(self); }
3336}
3337
3338impl Drop for NativeHarnessMcpOverlayGuard {
3339 fn drop(&mut self) {
3340 self.control
3341 .clear_native_mcp_server_launch_overlay(self.instance_id);
3342 }
3343}
3344
3345impl NativeInstanceOverlayGuard {
3346 fn retain(mut self) {
3347 self.control = None;
3348 }
3349}
3350
3351impl Drop for NativeInstanceOverlayGuard {
3352 fn drop(&mut self) {
3353 if let Some(control) = self.control.as_ref() {
3354 control.clear_native_instance_launch_overlay(self.instance_id);
3355 }
3356 }
3357}
3358
3359enum PreparedNativeLaunchOverlay {
3360 Environment(NativeLaunchEnvironmentOverlay),
3361 Instance(NativeInstanceLaunchOverlay),
3362}
3363
3364impl NativeEnvironmentSelectionGuard {
3365 fn retain(mut self) {
3366 self.control = None;
3367 }
3368}
3369
3370impl Drop for NativeEnvironmentSelectionGuard {
3371 fn drop(&mut self) {
3372 if let Some(control) = self.control.as_ref() {
3373 control.clear_native_launch_profile_selection(self.instance_id);
3374 }
3375 }
3376}
3377
3378struct SessionMaterializationGuard<'a> {
3379 shared: &'a NodeShared,
3380 id: Option<MaterializationId>,
3381 provider_home: Option<PathBuf>,
3384}
3385
3386impl SessionMaterializationGuard<'_> {
3387 fn id(&self) -> Option<&MaterializationId> {
3388 self.id.as_ref()
3389 }
3390
3391 fn provider_home(&self) -> Option<&Path> {
3392 self.provider_home.as_deref()
3393 }
3394
3395 fn retain(mut self) {
3396 self.id = None;
3397 }
3398}
3399
3400impl Drop for SessionMaterializationGuard<'_> {
3401 fn drop(&mut self) {
3402 if let Some(id) = self.id.take() {
3403 let _ = self.shared.cleanup_materialization(&id);
3404 }
3405 }
3406}
3407
3408
3409trait SessionLifecycleHook {
3412 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3414 fn on_session_ended(&self, address: &SessionAddress);
3416 fn on_shutdown(&self);
3418 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3420 fn cleanup_session_materialization(
3422 &self,
3423 address: &SessionAddress,
3424 binding: &SessionBinding,
3425 ) -> Result<(), NodeFailure>;
3426}
3427
3428impl SessionLifecycleHook for NodeShared {
3429 fn on_session_ended(&self, address: &SessionAddress) {
3430 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3431 registry.revoke_session(address);
3432 }
3433 if let Some(control) = self.native_launch_profile_control.as_ref() {
3434 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3435 }
3436 }
3437
3438 fn on_shutdown(&self) {
3439 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3440 let instances = registry.shutdown();
3441 if let Some(control) = self.native_launch_profile_control.as_ref() {
3442 for instance_id in instances {
3443 control.clear_native_mcp_server_launch_overlay(instance_id);
3444 }
3445 }
3446 }
3447 }
3448
3449 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3450 if let Some(control) = self.native_launch_profile_control.as_ref() {
3451 control.clear_native_mcp_server_launch_overlay(instance_id);
3452 }
3453 }
3454
3455 fn cleanup_session_materialization(
3456 &self,
3457 address: &SessionAddress,
3458 binding: &SessionBinding,
3459 ) -> Result<(), NodeFailure> {
3460 self.cleanup_session_owned_materialization(address, binding)
3461 }
3462
3463 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3464 #[cfg(feature = "dig2-station-probe")]
3467 {
3468 crate::dig2_station_lease::release_lease_for_holder(
3469 &self.browser_station_leases,
3470 address.session.instance_id,
3471 );
3472 }
3473 if let Some(control) = self.native_launch_profile_control.as_ref() {
3474 control.clear_native_mcp_server_launch_overlay(
3475 address.session.instance_id,
3476 );
3477 }
3478 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3479 registry.revoke_session(address);
3480 }
3481 if removed
3482 .is_some_and(|binding| {
3483 binding.environment_profile.is_some()
3484 || binding.bundle.is_some()
3485 || binding.context.is_some()
3486 })
3487 {
3488 if let Some(control) = self.native_launch_profile_control.as_ref() {
3489 control.clear_native_launch_profile_selection(
3490 address.session.instance_id,
3491 );
3492 control.clear_native_instance_launch_overlay(
3493 address.session.instance_id,
3494 );
3495 }
3496 }
3497 }
3498}
3499
3500struct SpawnPrepareRequest<'r> {
3501 instance_id: AgentInstanceId,
3502 address: &'r SessionAddress,
3503 provider: &'r AgentId,
3504 mode: SessionMode,
3505 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3506 bundle: Option<&'r ResolvedBundleReceipt>,
3507 context: Option<&'r ResolvedContextPackReceipt>,
3508 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3509 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3510 approval_level: ApprovalLevel,
3511 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3512 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3513}
3514
3515struct PreparedSpawn<'a> {
3519 instance_overlay: Option<NativeInstanceOverlayGuard>,
3520 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3521 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3522 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3523 #[cfg(feature = "dig2-station-probe")]
3524 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3525 materialization_id: Option<MaterializationId>,
3526}
3527
3528impl PreparedSpawn<'_> {
3529 fn retain_session_guards(&mut self) {
3531 if let Some(selection) = self.environment_selection.take() {
3532 selection.retain();
3533 }
3534 if let Some(overlay) = self.instance_overlay.take() {
3535 overlay.retain();
3536 }
3537 if let Some(guard) = self.materialization_guard.take() {
3538 guard.retain();
3539 }
3540 }
3541
3542 fn retain_launch_guards(&mut self) {
3544 if let Some(overlay) = self.harness_mcp_overlay.take() {
3545 overlay.retain();
3546 }
3547 #[cfg(feature = "dig2-station-probe")]
3548 if let Some(guard) = self.browser_station_lease_guard.take() {
3549 guard.retain();
3550 }
3551 }
3552}
3553
3554trait SpawnPreparer {
3558 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3559 fn select_environment(
3561 &self,
3562 instance_id: AgentInstanceId,
3563 provider: &AgentId,
3564 mode: SessionMode,
3565 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3566 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3567 fn install_launch_overlay(
3569 &self,
3570 instance_id: AgentInstanceId,
3571 overlay: PreparedNativeLaunchOverlay,
3572 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3573}
3574
3575impl SpawnPreparer for NodeShared {
3576 fn select_environment(
3577 &self,
3578 instance_id: AgentInstanceId,
3579 provider: &AgentId,
3580 mode: SessionMode,
3581 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3582 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3583 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3584 }
3585
3586 fn install_launch_overlay(
3587 &self,
3588 instance_id: AgentInstanceId,
3589 overlay: PreparedNativeLaunchOverlay,
3590 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3591 self.install_prepared_launch_overlay(instance_id, overlay)
3592 }
3593
3594 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3595 let SpawnPrepareRequest {
3596 instance_id,
3597 address,
3598 provider,
3599 mode,
3600 environment_profile,
3601 bundle,
3602 context,
3603 managed_authority,
3604 harness_mcp,
3605 approval_level,
3606 network_allowlist,
3607 browser_profile_id,
3608 } = request;
3609 #[cfg(feature = "dig2-station-probe")]
3614 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3615 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3616 match acquire_lease(
3617 &self.browser_station_leases,
3618 profile_id,
3619 instance_id,
3620 unix_time_ms(),
3621 ) {
3622 Ok(guard) => Some(guard),
3623 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3624 return Err(failure(
3625 NodeFailureCode::BrowserStationProfileBusy,
3626 "dig2browser station profile is already leased by another session",
3627 ));
3628 }
3629 Err(BrowserStationLeaseError::CapacityExceeded) => {
3630 return Err(failure(
3631 NodeFailureCode::BrowserStationProfileBusy,
3632 "dig2browser station profile lease capacity exhausted",
3633 ));
3634 }
3635 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3636 return Err(failure(
3637 NodeFailureCode::BrowserStationProfileBusy,
3638 "session already holds a dig2browser station profile lease",
3639 ));
3640 }
3641 }
3642 } else {
3643 None
3644 };
3645 #[cfg(not(feature = "dig2-station-probe"))]
3646 let _ = browser_profile_id;
3647 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3648 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3657 {
3658 return Err(failure(
3659 NodeFailureCode::BindingMismatch,
3660 "harness MCP reservation does not match the exact PTY/ACP provider",
3661 ));
3662 }
3663 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3664 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3665 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3666 })?;
3667 let launch = prepared.launch();
3672 let mut env_entries = vec![
3673 (
3674 OsString::from(launch.endpoint_env.as_str()),
3675 prepared.endpoint().as_os_str().to_os_string(),
3676 ),
3677 (
3678 OsString::from(launch.token_env.as_str()),
3679 prepared.token().expose().into(),
3680 ),
3681 (
3682 OsString::from(launch.program_env.as_str()),
3683 prepared.helper_program().as_os_str().to_os_string(),
3684 ),
3685 ];
3686 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3687 env_entries.push(trace_entry);
3688 }
3689 if let Some(door) = crate::door::door_listen_addr() {
3690 env_entries.push((
3691 OsString::from("HATCHERY_NODE_DOOR"),
3692 OsString::from(door.to_string()),
3693 ));
3694 let session_key = format!(
3696 "pty-{}-{}",
3697 address.workspace_id.as_str(),
3698 address.session.instance_id.0
3699 );
3700 env_entries.push((
3701 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3702 OsString::from(session_key),
3703 ));
3704 }
3705 let overlay = NativeMcpServerLaunchOverlay::new(
3706 provider.clone(),
3707 launch.server_name.as_str(),
3708 prepared.helper_program().as_os_str().to_os_string(),
3709 launch.args.iter().map(OsString::from).collect(),
3710 env_entries,
3711 launch.scrub_env.iter().map(OsString::from).collect(),
3712 ).map_err(|_| failure(
3713 NodeFailureCode::HarnessMcpUnavailable,
3714 "harness MCP launch overlay is unavailable",
3715 ))?;
3716 control
3717 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3718 .map_err(|_| failure(
3719 NodeFailureCode::HarnessMcpUnavailable,
3720 "harness MCP launch overlay could not be installed",
3721 ))?;
3722 tracing::info!(
3728 node_id = %self.node_id,
3729 instance_id = ?instance_id,
3730 provider = %provider,
3731 mode = ?mode,
3732 "harness MCP overlay installed for spawn",
3733 );
3734 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3735 } else {
3736 None
3737 };
3738 let prepared_materialization = self.prepare_session_materialization(
3739 &address,
3740 &provider,
3741 mode,
3742 environment_profile,
3743 bundle,
3744 context,
3745 managed_authority.cloned(),
3746 )?;
3747 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3748 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3749 None => (None, None),
3750 };
3751 let materialization_id = materialization_guard
3752 .as_ref()
3753 .and_then(SessionMaterializationGuard::id)
3754 .cloned();
3755 let mut environment_selection = SpawnPreparer::select_environment(self,
3756 instance_id,
3757 &provider,
3758 mode,
3759 environment_profile,
3760 )?;
3761 let provider_home = materialization_guard
3764 .as_ref()
3765 .and_then(SessionMaterializationGuard::provider_home);
3766 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3767 &provider,
3768 mode,
3769 approval_level,
3770 network_allowlist,
3771 provider_home,
3772 )?;
3773 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3774 (prepared, args) if args.is_empty() => prepared,
3775 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3776 overlay.append_extra_args(args).map_err(|_| {
3777 failure(
3778 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3779 "network allowlist overlay could not merge with instance launch args",
3780 )
3781 })?;
3782 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3783 }
3784 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3785 Some(PreparedNativeLaunchOverlay::Instance(
3786 overlay
3787 .into_instance_with_extra_args(args)
3788 .map_err(|_| {
3789 failure(
3790 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3791 "network allowlist overlay could not merge with environment overlay",
3792 )
3793 })?,
3794 ))
3795 }
3796 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3797 NativeInstanceLaunchOverlay::new(
3798 provider.clone(),
3799 match mode {
3800 SessionMode::Pty => TransportKind::Pty,
3801 SessionMode::Acp => TransportKind::Acp,
3802 SessionMode::Inline => TransportKind::Pipe,
3803 },
3804 Vec::new(),
3805 args,
3806 )
3807 .map_err(|_| {
3808 failure(
3809 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3810 "network allowlist overlay is invalid for this transport",
3811 )
3812 })?,
3813 )),
3814 };
3815 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3818 (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3819 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3820 match accounts.provision(m4a_provider, &session_key) {
3821 Ok(account) => {
3822 let identity = account.env_mutations();
3823 let merged = match environment_overlay {
3824 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3825 .map(PreparedNativeLaunchOverlay::Instance),
3826 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3827 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3828 }
3829 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3830 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3831 }
3832 }
3833 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3834 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3835 self.m4a_live
3836 .lock()
3837 .unwrap_or_else(|p| p.into_inner())
3838 .insert(account.nick.clone(), (instance_id, provider.clone()));
3839 Some(merged)
3840 }
3841 Err(error) => {
3842 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3843 environment_overlay
3844 }
3845 }
3846 }
3847 (SessionMode::Pty, Some(_), None) => {
3848 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3849 environment_overlay
3850 }
3851 _ => environment_overlay,
3852 };
3853 let mut instance_overlay = environment_overlay
3854 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3855 .transpose()?
3856 .flatten();
3857 Ok(PreparedSpawn {
3858 instance_overlay,
3859 environment_selection,
3860 materialization_guard,
3861 harness_mcp_overlay,
3862 #[cfg(feature = "dig2-station-probe")]
3863 browser_station_lease_guard,
3864 materialization_id,
3865 })
3866 }
3867}
3868
3869struct SessionRecords {
3870 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3871}
3872
3873struct SpawnIdempotencyCache {
3874 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3875}
3876
3877struct SpawnIdempotencyEntry {
3878 value: SpawnIdempotencyValue,
3879 expires_at: Instant,
3880}
3881
3882enum SpawnIdempotencyValue {
3883 Standard {
3884 spec: SpawnSpec,
3885 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3886 },
3887 Managed {
3888 request: ManagedWorktreeSpawnRequest,
3889 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3890 },
3891 HarnessMcp,
3892}
3893
3894enum DurableManagedSpawnReplayDecision {
3895 Reserved,
3896 PendingLinked,
3897 Committed(ManagedWorktreeSpawnReceipt),
3898}
3899
3900impl SpawnIdempotencyValue {
3901 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3902 match self {
3903 Self::Standard { result, .. } => result
3904 .as_ref()
3905 .ok()
3906 .and_then(|receipt| receipt.context.as_ref()),
3907 Self::Managed { result, .. } => result
3908 .as_ref()
3909 .ok()
3910 .and_then(|receipt| receipt.spawn.context.as_ref()),
3911 Self::HarnessMcp => None,
3912 }
3913 .is_some_and(|receipt| &receipt.id == context_id)
3914 }
3915}
3916
3917#[derive(Clone, Copy)]
3918struct ControllerLease {
3919 connection_id: u64,
3920 expires_at: Instant,
3921}
3922
3923struct NodeEventHistory {
3924 last_sequence: u64,
3925 replay_floor_sequence: u64,
3926 events: VecDeque<NodeEventEnvelope>,
3927 record_providers: BTreeMap<SessionRecordId, AgentId>,
3928 removed_record_providers: BTreeMap<u64, AgentId>,
3929}
3930
3931impl NodeEventHistory {
3932 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3933 Self {
3934 last_sequence: 0,
3935 replay_floor_sequence: 1,
3936 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3937 record_providers,
3938 removed_record_providers: BTreeMap::new(),
3939 }
3940 }
3941}
3942
3943impl NodeShared {
3944 fn new_with_incarnation(
3945 handle: Gate4AgentHandle,
3946 access_token: String,
3947 node_id: NodeId,
3948 incarnation_id: NodeIncarnationId,
3949 workspaces: Vec<WorkspaceConfig>,
3950 enabled_providers: Vec<AgentId>,
3951 provider_runtime_statuses: ProviderRuntimeStatuses,
3952 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3953 provider_contracts: Vec<ProviderContractSupport>,
3954 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3955 spawn_profiles: SpawnProfileRegistry,
3956 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3957 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3958 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3959 state_path: Option<PathBuf>,
3960 records: Vec<ManagedSessionRecord>,
3961 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3962 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3963 materializations: Vec<MaterializationOwnershipRecord>,
3964 persistence_warning: Option<String>,
3965 input_settle_timeout_ms: u64,
3966 ) -> Self {
3967 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3968 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3969 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3970 let (harness_mcp_proxy_event_tx, _) =
3971 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3972 let record_providers = records
3973 .iter()
3974 .map(|record| (record.record_id.clone(), record.provider.clone()))
3975 .collect();
3976 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3977 .iter()
3978 .map(|workspace| {
3979 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3980 })
3981 .collect();
3982 for lease in &managed_worktrees {
3983 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3984 }
3985 let managed_worktree_profiles = workspaces.iter()
3986 .map(|workspace| (
3987 workspace.workspace_id.clone(),
3988 workspace.managed_worktree_profiles.clone(),
3989 ))
3990 .collect();
3991 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3992 managed_worktrees,
3993 managed_worktree_tombstones,
3994 ).expect("durable managed worktree registry was validated while loading");
3995 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3996 managed_worktrees.reattach_record_holders(
3997 &managed_worktree_record_holders(&records),
3998 unix_time_ms(),
3999 );
4000 Self {
4001 handle,
4002 access_token,
4003 node_id,
4004 incarnation_id,
4005 started_at_unix_ms: std::time::SystemTime::now()
4006 .duration_since(std::time::UNIX_EPOCH)
4007 .unwrap_or_default()
4008 .as_millis()
4009 .min(u64::MAX as u128) as u64,
4010 workspaces: RwLock::new(
4011 workspaces
4012 .into_iter()
4013 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4014 .collect(),
4015 ),
4016 worktree_service_modes,
4017 managed_worktree_profiles,
4018 managed_worktrees: Mutex::new(managed_worktrees),
4019 enabled_providers,
4020 provider_runtime_statuses,
4021 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4022 provider_runtime_monitor,
4023 provider_contracts,
4024 provider_adapter_contracts,
4025 spawn_profiles,
4026 environment_profiles: RwLock::new(BTreeMap::new()),
4027 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4028 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4029 #[cfg(feature = "dig2-station-probe")]
4030 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4031 bundle_catalog: RwLock::new(BundleCatalog::default()),
4032 delivery_store: Mutex::new(None),
4033 context_pack_store: Mutex::new(None),
4034 context_catalog: RwLock::new(ContextPackCatalog::default()),
4035 native_launch_profile_control,
4036 harness_mcp_registry: None,
4037 native_session_catalog,
4038 session_environment_materializer,
4039 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4040 m4a_live: Mutex::new(BTreeMap::new()),
4041 materializations: Mutex::new(
4042 materializations
4043 .into_iter()
4044 .map(|record| (record.id().clone(), record))
4045 .collect(),
4046 ),
4047 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4048 entries: BTreeMap::new(),
4049 }),
4050 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4051 controller: Mutex::new(None),
4052 history: Mutex::new(NodeEventHistory::new(record_providers)),
4053 event_tx,
4054 terminal_event_tx,
4055 agent_stream_event_tx,
4056 harness_mcp_proxy_event_tx,
4057 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4058 pty_input_wake: tokio::sync::Notify::new(),
4059 pty_input_marks: Mutex::new(BTreeMap::new()),
4060 next_connection_id: AtomicU64::new(1),
4061 next_instance_id: AtomicU64::new(1),
4062 next_command_id: AtomicU64::new(1),
4063 shutdown: AtomicBool::new(false),
4064 shutdown_notify: Notify::new(),
4065 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4066 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4067 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4068 mutation_gate: AsyncMutex::new(()),
4069 session_bindings: Mutex::new(BTreeMap::new()),
4070 session_records: Mutex::new(SessionRecords {
4071 records: records
4072 .into_iter()
4073 .map(|record| (record.record_id.clone(), record))
4074 .collect(),
4075 }),
4076 state_path,
4077 persistence_error: RwLock::new(
4078 persistence_warning
4079 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4080 ),
4081 state_transaction: Mutex::new(()),
4082 input_settle_timeout_ms,
4083 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4084 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4085 shell_efficiency_profile: OnceLock::new(),
4086 native_session_gauge: AtomicUsize::new(0),
4087 connection_loop_iterations: AtomicU64::new(0),
4088 connection_events_sent: AtomicU64::new(0),
4089 rejected_commands_total: AtomicU64::new(0),
4090 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4091 drive_loop_iterations_total: AtomicU64::new(0),
4092 drive_loop_iterations_idle: AtomicU64::new(0),
4093 session_record_retention: SessionRecordRetentionConfig::default(),
4094 retired_records_total: AtomicU64::new(0),
4095 #[cfg(feature = "fixture")]
4096 fixture_semantic_hook_policy: false,
4097 #[cfg(feature = "fixture")]
4098 fixture_spawn_managed_worktree_v2_failure_probe:
4099 SpawnManagedWorktreeV2FailureProbe::default(),
4100 }
4101 }
4102
4103 #[cfg(test)]
4104 fn new(
4105 handle: Gate4AgentHandle,
4106 access_token: String,
4107 node_id: NodeId,
4108 workspaces: Vec<WorkspaceConfig>,
4109 enabled_providers: Vec<AgentId>,
4110 ) -> Self {
4111 Self::new_with_incarnation(
4112 handle,
4113 access_token,
4114 node_id,
4115 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4116 workspaces,
4117 enabled_providers,
4118 ProviderRuntimeStatuses::default(),
4119 None,
4120 Vec::new(),
4121 Vec::new(),
4122 SpawnProfileRegistry::default(),
4123 None,
4124 None,
4125 None,
4126 None,
4127 Vec::new(),
4128 Vec::new(),
4129 Vec::new(),
4130 Vec::new(),
4131 None,
4132 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4133 )
4134 }
4135
4136 fn persistence_error(&self) -> Option<String> {
4137 self.persistence_error
4138 .read()
4139 .unwrap_or_else(|poisoned| poisoned.into_inner())
4140 .clone()
4141 }
4142
4143 async fn admit_provider_runtime(
4144 &self,
4145 provider: &AgentId,
4146 requirement: ProviderRuntimeRequirement,
4147 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4148 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4149 let pipe_transport = monitor.supports_pipe_transport(provider);
4150 let acp_transport = monitor.supports_acp_transport(provider);
4151 let provider = provider.clone();
4152 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4153 let (status, admission) = timeout(
4154 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4155 refresh,
4156 )
4157 .await
4158 .map_err(|_| failure(
4159 NodeFailureCode::BackendBusy,
4160 "provider runtime probe exceeded its bounded deadline",
4161 ))?
4162 .map_err(|_| failure(
4163 NodeFailureCode::BackendOperationFailed,
4164 "provider runtime probe task failed",
4165 ))?;
4166 if let Some(status) = status {
4167 self
4168 .provider_runtime_status_updates
4169 .write()
4170 .unwrap_or_else(|poisoned| poisoned.into_inner())
4171 .insert(status.provider().clone(), status);
4172 }
4173 admission.and_then(|policy| {
4174 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4175 })
4176 } else {
4177 crate::provider_runtime::admit_status(
4183 &self.provider_runtime_statuses,
4184 provider,
4185 false,
4186 false,
4187 requirement,
4188 )
4189 };
4190 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4191 admission.map_err(|error| match error {
4192 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4193 NodeFailureCode::BackendOperationFailed,
4194 "provider launcher is unavailable",
4195 ),
4196 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4197 NodeFailureCode::UnsupportedCapability,
4198 "provider semantic capability is not verified",
4199 ),
4200 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4201 NodeFailureCode::BackendBusy,
4202 "provider runtime probe is already in progress",
4203 ),
4204 })
4205 }
4206
4207 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4213 match self.provider_runtime_monitor.as_deref() {
4214 Some(monitor) => monitor.supports_pipe_transport(provider),
4215 None => false,
4216 }
4217 }
4218
4219 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4225 match self.provider_runtime_monitor.as_deref() {
4226 Some(monitor) => monitor.supports_acp_transport(provider),
4227 None => false,
4228 }
4229 }
4230
4231 fn admit_declared_hook_semantics(
4247 &self,
4248 provider: &AgentId,
4249 policy: ProviderRuntimePolicy,
4250 ) -> ProviderRuntimePolicy {
4251 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4252 return policy;
4253 }
4254 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4255 contract.provider == *provider && contract.family == AdapterFamily::Hook
4256 });
4257 if !declares_hook {
4258 return policy;
4259 }
4260 ProviderRuntimePolicy::new(
4261 policy.raw_pty_lifecycle,
4262 policy.semantic_readiness,
4263 policy.structured_prompt,
4264 policy.provider_session_identity,
4265 policy.semantic_resume,
4266 true,
4267 )
4268 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4269 }
4270
4271 fn resolve_network_allowlist_overlay_args(
4286 &self,
4287 provider: &AgentId,
4288 mode: SessionMode,
4289 approval_level: ApprovalLevel,
4290 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4291 provider_home: Option<&Path>,
4292 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4293 let Some(allowlist_id) = network_allowlist else {
4294 return Ok(Vec::new());
4295 };
4296 let catalog = self
4297 .network_allowlist_catalog
4298 .read()
4299 .unwrap_or_else(|poisoned| poisoned.into_inner());
4300 let Some(entry) = catalog.get(allowlist_id) else {
4301 return Err(failure(
4302 NodeFailureCode::UnknownNetworkAllowlist,
4303 "spawn network allowlist is unavailable on this node",
4304 ));
4305 };
4306
4307 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4309 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4310 |message| {
4311 failure(
4312 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4313 &message,
4314 )
4315 },
4316 )?;
4317 if !matches!(mode, SessionMode::Pty) {
4318 return Err(failure(
4319 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4320 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4321 ));
4322 }
4323 let Some(home) = provider_home else {
4324 return Err(failure(
4325 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4326 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4327 ));
4328 };
4329 let domains =
4330 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4331 if domains.is_empty() {
4332 return Err(failure(
4333 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4334 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4335 ));
4336 }
4337 let settings_path = home.join(
4338 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4339 );
4340 let json =
4341 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4342 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4343 |_| {
4344 failure(
4345 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4346 "Claude station network settings sidecar could not be written under ProviderHome",
4347 )
4348 },
4349 )?;
4350 let overlay =
4351 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4352 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4353 }
4354
4355 let Some(native) = entry.provider_native.as_ref() else {
4356 return Ok(Vec::new());
4357 };
4358 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4359 provider.as_str(),
4360 approval_level,
4361 native,
4362 )
4363 .map_err(|message| {
4364 failure(
4365 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4366 &message,
4367 )
4368 })?;
4369 if overlay.is_empty() {
4370 return Ok(Vec::new());
4371 }
4372 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4373 return Err(failure(
4374 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4375 "Codex network_access -c overlay requires PTY or ACP transport",
4376 ));
4377 }
4378 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4379 }
4380
4381 fn resolve_codex_network_access_overlay_args(
4383 &self,
4384 provider: &AgentId,
4385 mode: SessionMode,
4386 approval_level: ApprovalLevel,
4387 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4388 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4389 self.resolve_network_allowlist_overlay_args(
4390 provider,
4391 mode,
4392 approval_level,
4393 network_allowlist,
4394 None,
4395 )
4396 }
4397
4398 fn resolve_environment_profile(
4399 &self,
4400 resolved: &ResolvedSpawnSpec,
4401 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4402 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4408 let catalog = self
4409 .network_allowlist_catalog
4410 .read()
4411 .unwrap_or_else(|poisoned| poisoned.into_inner());
4412 let Some(entry) = catalog.get(allowlist_id) else {
4413 return Err(failure(
4414 NodeFailureCode::UnknownNetworkAllowlist,
4415 "spawn network allowlist is unavailable on this node",
4416 ));
4417 };
4418 if let Some(native) = entry.provider_native.as_ref() {
4421 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4422 resolved.provider.as_str(),
4423 resolved.approval_level,
4424 native,
4425 ) {
4426 return Err(failure(
4427 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4428 &message,
4429 ));
4430 }
4431 }
4432 }
4433 #[cfg(feature = "dig2-station-probe")]
4442 if resolved.browser_profile_id.is_some() {
4443 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4444 match probe_station_reachable() {
4445 Ok(()) => {}
4446 Err(StationProbeError::UnsupportedPlatform)
4447 | Err(StationProbeError::InvalidPipeSuffix) => {
4448 return Err(failure(
4449 NodeFailureCode::BrowserStationProbeUnavailable,
4450 "dig2browser station probe unavailable on this platform",
4451 ));
4452 }
4453 Err(StationProbeError::Unreachable) => {
4454 return Err(failure(
4455 NodeFailureCode::BrowserStationUnreachable,
4456 "dig2browser station named-pipe is unreachable",
4457 ));
4458 }
4459 }
4460 }
4461 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4462 return Ok(None);
4463 };
4464 let profiles = self
4465 .environment_profiles
4466 .read()
4467 .unwrap_or_else(|poisoned| poisoned.into_inner());
4468 let binding = profiles.get(profile_id).ok_or_else(|| {
4469 failure(
4470 NodeFailureCode::UnknownEnvironmentProfile,
4471 "spawn environment profile is unavailable on this node",
4472 )
4473 })?;
4474 if binding.provider != resolved.provider
4475 || binding.native_profile_id(resolved.mode).is_none()
4476 {
4477 return Err(failure(
4478 NodeFailureCode::EnvironmentProfileBindingMismatch,
4479 "spawn environment profile does not match the provider and mode",
4480 ));
4481 }
4482 Ok(Some(ResolvedEnvironmentProfileReceipt {
4486 profile_id: binding.id.clone(),
4487 profile_revision: binding.revision.clone(),
4488 network_allowlist: resolved.network_allowlist.clone(),
4489 browser_profile_id: resolved.browser_profile_id.clone(),
4490 }))
4491 }
4492
4493 fn resolve_bundle(
4494 &self,
4495 resolved: &ResolvedSpawnSpec,
4496 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4497 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4498 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4499 return Ok(None);
4500 };
4501 let catalog = self.bundle_catalog
4502 .read()
4503 .unwrap_or_else(|poisoned| poisoned.into_inner());
4504 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4505 failure(
4506 NodeFailureCode::UnknownBundle,
4507 "spawn bundle is unavailable on this node",
4508 )
4509 })?;
4510 self.bundle_layout(
4511 &resolved.provider,
4512 resolved.mode,
4513 environment_profile,
4514 bundle,
4515 )?;
4516 Ok(Some(bundle.receipt()))
4517 }
4518
4519 fn begin_delivery_stage(
4520 &self,
4521 manifest: DeliveryBundleManifestV2,
4522 ) -> Result<NodeResponse, NodeFailure> {
4523 let mut store = self
4524 .delivery_store
4525 .lock()
4526 .unwrap_or_else(|poisoned| poisoned.into_inner());
4527 let store = store.as_mut().ok_or_else(|| {
4528 failure(
4529 NodeFailureCode::DeliveryStageStorageFailed,
4530 "node delivery store is unavailable",
4531 )
4532 })?;
4533 let begun = store.begin(manifest).map_err(delivery_failure)?;
4534 Ok(NodeResponse::DeliveryStageBegun {
4535 stage_id: begun.stage_id,
4536 manifest_digest: begun.manifest_digest,
4537 missing_blobs: begun.missing_blobs,
4538 })
4539 }
4540
4541 fn put_delivery_blob_chunk(
4542 &self,
4543 stage_id: DeliveryStageId,
4544 blob_digest: DeliveryBlobDigestV1,
4545 offset: u64,
4546 chunk_hex: DeliveryBlobChunkHexV1,
4547 ) -> Result<NodeResponse, NodeFailure> {
4548 let mut store = self
4549 .delivery_store
4550 .lock()
4551 .unwrap_or_else(|poisoned| poisoned.into_inner());
4552 let store = store.as_mut().ok_or_else(|| {
4553 failure(
4554 NodeFailureCode::DeliveryStageStorageFailed,
4555 "node delivery store is unavailable",
4556 )
4557 })?;
4558 let accepted = store
4559 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4560 .map_err(delivery_failure)?;
4561 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4562 stage_id: accepted.stage_id,
4563 blob_digest: accepted.blob_digest,
4564 next_offset: accepted.next_offset,
4565 })
4566 }
4567
4568 fn commit_delivery_stage(
4569 &self,
4570 stage_id: DeliveryStageId,
4571 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4572 let mut store = self
4573 .delivery_store
4574 .lock()
4575 .unwrap_or_else(|poisoned| poisoned.into_inner());
4576 let store = store.as_mut().ok_or_else(|| {
4577 failure(
4578 NodeFailureCode::DeliveryStageStorageFailed,
4579 "node delivery store is unavailable",
4580 )
4581 })?;
4582 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4583 let mut catalog = self
4584 .bundle_catalog
4585 .write()
4586 .unwrap_or_else(|poisoned| poisoned.into_inner());
4587 let mut next_catalog = catalog.clone();
4588 next_catalog
4589 .insert_idempotent(prepared.bundle().clone())
4590 .map_err(|_| {
4591 failure(
4592 NodeFailureCode::DeliveryStageConflict,
4593 "delivery bundle identity conflicts with installed content",
4594 )
4595 })?;
4596 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4597 *catalog = next_catalog;
4598 Ok(receipt)
4599 }
4600
4601 fn abort_delivery_stage(
4602 &self,
4603 stage_id: &DeliveryStageId,
4604 ) -> Result<(), NodeFailure> {
4605 let mut store = self
4606 .delivery_store
4607 .lock()
4608 .unwrap_or_else(|poisoned| poisoned.into_inner());
4609 let store = store.as_mut().ok_or_else(|| {
4610 failure(
4611 NodeFailureCode::DeliveryStageStorageFailed,
4612 "node delivery store is unavailable",
4613 )
4614 })?;
4615 store.abort(stage_id).map_err(delivery_failure)
4616 }
4617
4618 fn resolve_context(
4619 &self,
4620 resolved: &ResolvedSpawnSpec,
4621 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4622 let Some(context_id) = resolved.context_id.as_ref() else {
4623 return Ok(None);
4624 };
4625 self.context_catalog
4626 .read()
4627 .unwrap_or_else(|poisoned| poisoned.into_inner())
4628 .get(context_id)
4629 .map(|context| context.receipt().clone())
4630 .ok_or_else(|| {
4631 failure(
4632 NodeFailureCode::UnknownContextPack,
4633 "spawn context pack is unavailable on this node",
4634 )
4635 })
4636 .map(Some)
4637 }
4638
4639 fn bundle_layout(
4640 &self,
4641 provider: &AgentId,
4642 mode: SessionMode,
4643 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4644 bundle: &NodeBundle,
4645 ) -> Result<BundleProviderLayout, NodeFailure> {
4646 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4647 failure(
4648 NodeFailureCode::BundleBindingMismatch,
4649 "spawn bundle does not match the provider and mode",
4650 )
4651 })?;
4652 if layout != BundleProviderLayout::Codex {
4653 return Ok(layout);
4654 }
4655 let profile = environment_profile.and_then(|receipt| {
4656 let profiles = self
4657 .environment_profiles
4658 .read()
4659 .unwrap_or_else(|poisoned| poisoned.into_inner());
4660 let binding = profiles.get(&receipt.profile_id)?;
4661 if binding.revision != receipt.profile_revision
4662 || &binding.provider != provider
4663 || binding.native_profile_id(mode).is_none()
4664 {
4665 return None;
4666 }
4667 self.environment_materialization_profiles
4668 .read()
4669 .unwrap_or_else(|poisoned| poisoned.into_inner())
4670 .get(&receipt.profile_id)
4671 .cloned()
4672 });
4673 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4674 return Err(failure(
4675 NodeFailureCode::BundleBindingMismatch,
4676 "Codex bundle requires an exact isolated CODEX_HOME profile",
4677 ));
4678 }
4679 Ok(layout)
4680 }
4681
4682 fn select_environment_profile(
4683 &self,
4684 instance_id: AgentInstanceId,
4685 provider: &AgentId,
4686 mode: SessionMode,
4687 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4688 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4689 let Some(environment_profile) = environment_profile else {
4690 return Ok(None);
4691 };
4692 let native_profile_id = {
4693 let profiles = self
4694 .environment_profiles
4695 .read()
4696 .unwrap_or_else(|poisoned| poisoned.into_inner());
4697 let binding = profiles
4698 .get(&environment_profile.profile_id)
4699 .ok_or_else(|| {
4700 failure(
4701 NodeFailureCode::UnknownEnvironmentProfile,
4702 "session environment profile is unavailable on this node",
4703 )
4704 })?;
4705 if &binding.revision != &environment_profile.profile_revision
4706 || &binding.provider != provider
4707 {
4708 return Err(failure(
4709 NodeFailureCode::EnvironmentProfileBindingMismatch,
4710 "session environment profile revision or provider changed",
4711 ));
4712 }
4713 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4714 failure(
4715 NodeFailureCode::EnvironmentProfileBindingMismatch,
4716 "session environment profile does not support this mode",
4717 )
4718 })?
4719 };
4720 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4721 failure(
4722 NodeFailureCode::BackendOperationFailed,
4723 "native environment profile controller is unavailable",
4724 )
4725 })?;
4726 control
4727 .select_native_launch_profile(instance_id, native_profile_id)
4728 .map_err(|_| {
4729 failure(
4730 NodeFailureCode::BackendOperationFailed,
4731 "native environment profile selection failed",
4732 )
4733 })?;
4734 Ok(Some(NativeEnvironmentSelectionGuard {
4735 control: Some(control),
4736 instance_id,
4737 }))
4738 }
4739
4740 fn install_prepared_launch_overlay(
4741 &self,
4742 instance_id: AgentInstanceId,
4743 overlay: PreparedNativeLaunchOverlay,
4744 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4745 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4746 failure(
4747 NodeFailureCode::BackendOperationFailed,
4748 "native launch profile controller is unavailable",
4749 )
4750 })?;
4751 match overlay {
4752 PreparedNativeLaunchOverlay::Environment(overlay) => {
4753 control
4754 .install_native_launch_environment_overlay(instance_id, overlay)
4755 .map_err(|_| failure(
4756 NodeFailureCode::BackendOperationFailed,
4757 "session environment overlay installation failed",
4758 ))?;
4759 Ok(None)
4760 }
4761 PreparedNativeLaunchOverlay::Instance(overlay) => {
4762 control
4763 .install_native_instance_launch_overlay(instance_id, overlay)
4764 .map_err(|_| failure(
4765 NodeFailureCode::BundleBindingMismatch,
4766 "session bundle launch overlay installation failed",
4767 ))?;
4768 Ok(Some(NativeInstanceOverlayGuard {
4769 control: Some(control),
4770 instance_id,
4771 }))
4772 }
4773 }
4774 }
4775
4776 fn environment_materialization_profile(
4777 &self,
4778 environment_profile: &ResolvedEnvironmentProfileReceipt,
4779 ) -> Option<NodeSessionMaterializationProfile> {
4780 self.environment_materialization_profiles
4781 .read()
4782 .unwrap_or_else(|poisoned| poisoned.into_inner())
4783 .get(&environment_profile.profile_id)
4784 .cloned()
4785 }
4786
4787 fn materialization_profile(
4788 &self,
4789 provider: &AgentId,
4790 mode: SessionMode,
4791 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4792 bundle: Option<&ResolvedBundleReceipt>,
4793 context: Option<&ResolvedContextPackReceipt>,
4794 ) -> Result<Option<(
4795 NodeSessionMaterializationProfile,
4796 bool,
4797 Option<BundleProviderLayout>,
4798 )>, NodeFailure> {
4799 let environment = environment_profile
4800 .and_then(|receipt| self.environment_materialization_profile(receipt));
4801 let (bundle, bundle_layout) = match bundle {
4802 Some(receipt) => {
4803 let catalog = self.bundle_catalog.read()
4804 .unwrap_or_else(|poisoned| poisoned.into_inner());
4805 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4806 NodeFailureCode::UnknownBundle,
4807 "session bundle is unavailable on this node",
4808 ))?;
4809 if installed.receipt() != *receipt {
4810 return Err(failure(
4811 NodeFailureCode::BundleBindingMismatch,
4812 "session bundle revision or digest changed",
4813 ));
4814 }
4815 let layout = self.bundle_layout(
4816 provider,
4817 mode,
4818 environment_profile,
4819 installed,
4820 )?;
4821 (Some(installed.clone()), Some(layout))
4822 }
4823 None => (None, None),
4824 };
4825 let has_environment_overlay = environment.is_some() || context.is_some();
4826 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4827 (None, None, None) => None,
4828 (Some(profile), None, None) => Some(Ok(profile)),
4829 (None, Some(bundle), Some(layout)) => {
4830 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4831 }
4832 (Some(profile), Some(bundle), Some(layout)) => {
4833 Some(profile.with_bundle(bundle, layout))
4834 }
4835 _ => {
4836 return Err(failure(
4837 NodeFailureCode::BundleBindingMismatch,
4838 "session bundle provider layout is unavailable",
4839 ));
4840 }
4841 };
4842 let profile = profile
4843 .transpose()
4844 .map_err(|_| failure(
4845 NodeFailureCode::BundleMaterializationFailed,
4846 "session bundle materialization profile is invalid",
4847 ))?;
4848 let profile = match (profile, context) {
4849 (Some(profile), Some(receipt)) => {
4850 let catalog = self.context_catalog
4851 .read()
4852 .unwrap_or_else(|poisoned| poisoned.into_inner());
4853 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4854 NodeFailureCode::UnknownContextPack,
4855 "session context pack is unavailable on this node",
4856 ))?;
4857 if pack.receipt() != receipt {
4858 return Err(failure(
4859 NodeFailureCode::ContextPackMaterializationFailed,
4860 "session context pack identity changed",
4861 ));
4862 }
4863 profile.with_context(pack)
4864 }
4865 (None, Some(receipt)) => {
4866 let catalog = self.context_catalog
4867 .read()
4868 .unwrap_or_else(|poisoned| poisoned.into_inner());
4869 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4870 NodeFailureCode::UnknownContextPack,
4871 "session context pack is unavailable on this node",
4872 ))?;
4873 if pack.receipt() != receipt {
4874 return Err(failure(
4875 NodeFailureCode::ContextPackMaterializationFailed,
4876 "session context pack identity changed",
4877 ));
4878 }
4879 NodeSessionMaterializationProfile::from_context(pack)
4880 }
4881 (Some(profile), None) => Ok(profile),
4882 (None, None) => return Ok(None),
4883 }
4884 .map_err(|_| failure(
4885 NodeFailureCode::ContextPackMaterializationFailed,
4886 "session context pack materialization profile is invalid",
4887 ))?;
4888 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4889 }
4890
4891 fn prepare_session_materialization<'a>(
4892 &'a self,
4893 address: &SessionAddress,
4894 provider: &AgentId,
4895 mode: SessionMode,
4896 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4897 bundle: Option<&ResolvedBundleReceipt>,
4898 context: Option<&ResolvedContextPackReceipt>,
4899 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4900 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4901 let Some((profile, has_environment_overlay, bundle_layout)) =
4902 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4903 else {
4904 return Ok(None);
4905 };
4906 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4907 failure(
4908 NodeFailureCode::BackendOperationFailed,
4909 "session environment materializer is unavailable",
4910 )
4911 })?;
4912 let materialization_failure_code = if context.is_some() {
4913 NodeFailureCode::ContextPackMaterializationFailed
4914 } else if bundle.is_some() {
4915 NodeFailureCode::BundleMaterializationFailed
4916 } else {
4917 NodeFailureCode::BackendOperationFailed
4918 };
4919 let id = MaterializationId::new(format!(
4920 "mat-{}-{}-{}",
4921 self.incarnation_id,
4922 address.session.instance_id.0,
4923 address.session.generation.0,
4924 ))
4925 .map_err(|_| {
4926 failure(
4927 NodeFailureCode::BackendOperationFailed,
4928 "session environment materialization identity failed",
4929 )
4930 })?;
4931 let owner = MaterializationOwner::Session {
4932 incarnation_id: self.incarnation_id,
4933 instance_id: address.session.instance_id,
4934 generation: address.session.generation,
4935 };
4936 let mut ownership = materializer
4937 .begin(
4938 id.clone(),
4939 environment_profile.cloned(),
4940 bundle.cloned(),
4941 context.cloned(),
4942 owner,
4943 managed_lease_id,
4944 &profile,
4945 unix_time_ms(),
4946 )
4947 .map_err(|_| {
4948 failure(
4949 materialization_failure_code,
4950 "session materialization preparation failed",
4951 )
4952 })?;
4953 {
4954 let _transaction = self
4955 .state_transaction
4956 .lock()
4957 .unwrap_or_else(|poisoned| poisoned.into_inner());
4958 let duplicate = self
4959 .materializations
4960 .lock()
4961 .unwrap_or_else(|poisoned| poisoned.into_inner())
4962 .insert(id.clone(), ownership.clone());
4963 if duplicate.is_some() {
4964 return Err(failure(
4965 NodeFailureCode::BackendOperationFailed,
4966 "session environment materialization identity conflict",
4967 ));
4968 }
4969 if let Err(error) = self.persist_state_locked() {
4970 self.materializations
4971 .lock()
4972 .unwrap_or_else(|poisoned| poisoned.into_inner())
4973 .remove(&id);
4974 return Err(persistence_failure(error));
4975 }
4976 }
4977 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4978 Ok(environment) => environment,
4979 Err(_) => {
4980 let transaction = self
4981 .state_transaction
4982 .lock()
4983 .unwrap_or_else(|poisoned| poisoned.into_inner());
4984 self.materializations
4985 .lock()
4986 .unwrap_or_else(|poisoned| poisoned.into_inner())
4987 .insert(id.clone(), ownership.clone());
4988 if self.persist_state_locked().is_err() {
4989 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4990 }
4991 drop(transaction);
4992 if ownership.state() == MaterializationState::CleanupRequired {
4993 let _ = self.cleanup_materialization(&id);
4994 }
4995 return Err(failure(
4996 materialization_failure_code,
4997 "session materialization failed",
4998 ));
4999 }
5000 };
5001 {
5002 let _transaction = self
5003 .state_transaction
5004 .lock()
5005 .unwrap_or_else(|poisoned| poisoned.into_inner());
5006 self.materializations
5007 .lock()
5008 .unwrap_or_else(|poisoned| poisoned.into_inner())
5009 .insert(id.clone(), ownership.clone());
5010 if let Err(error) = self.persist_state_locked() {
5011 drop(_transaction);
5012 let _ = self.cleanup_materialization(&id);
5013 return Err(persistence_failure(error));
5014 }
5015 }
5016 let guard = SessionMaterializationGuard {
5017 shared: self,
5018 id: Some(id),
5019 provider_home: Some(ownership.provider_home().to_path_buf()),
5020 };
5021 let bundle_arguments = match bundle {
5022 Some(receipt) => {
5023 let catalog = self.bundle_catalog.read()
5024 .unwrap_or_else(|poisoned| poisoned.into_inner());
5025 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5026 NodeFailureCode::UnknownBundle,
5027 "session bundle is unavailable on this node",
5028 ))?;
5029 if installed.receipt() != *receipt {
5030 return Err(failure(
5031 NodeFailureCode::BundleBindingMismatch,
5032 "session bundle revision or digest changed",
5033 ));
5034 }
5035 let layout = bundle_layout.ok_or_else(|| failure(
5036 NodeFailureCode::BundleBindingMismatch,
5037 "materialized bundle provider layout is unavailable",
5038 ))?;
5039 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5040 failure(
5041 NodeFailureCode::BundleMaterializationFailed,
5042 "session bundle byte revalidation failed",
5043 )
5044 })?;
5045 Some(bundle_launch_arguments(
5046 layout,
5047 ownership.bundle_root(),
5048 ownership.provider_home(),
5049 ).map_err(|_| failure(
5050 NodeFailureCode::BundleBindingMismatch,
5051 "session bundle launch binding is invalid",
5052 ))?)
5053 }
5054 None => None,
5055 };
5056 if let Some(receipt) = context {
5057 materializer
5058 .revalidate_context(&ownership, receipt)
5059 .map_err(|_| failure(
5060 NodeFailureCode::ContextPackMaterializationFailed,
5061 "session context pack byte revalidation failed",
5062 ))?;
5063 }
5064 let transport = match mode {
5065 SessionMode::Pty => TransportKind::Pty,
5066 SessionMode::Inline => TransportKind::Pipe,
5067 SessionMode::Acp => TransportKind::Acp,
5068 };
5069 let overlay = if let Some(extra_args) = bundle_arguments {
5070 Some(PreparedNativeLaunchOverlay::Instance(
5071 NativeInstanceLaunchOverlay::new(
5072 provider.clone(),
5073 transport,
5074 if has_environment_overlay {
5075 environment
5076 } else {
5077 Vec::new()
5078 },
5079 extra_args,
5080 )
5081 .map_err(|_| {
5082 failure(
5083 NodeFailureCode::BundleBindingMismatch,
5084 "session bundle launch overlay is invalid",
5085 )
5086 })?,
5087 ))
5088 } else if has_environment_overlay {
5089 let context_only = environment_profile.is_none()
5090 && bundle.is_none()
5091 && context.is_some();
5092 let overlay = if context_only {
5093 NativeLaunchEnvironmentOverlay::new_context_root(
5094 provider.clone(),
5095 transport,
5096 environment,
5097 )
5098 .map_err(|_| {
5099 failure(
5100 NodeFailureCode::ContextPackMaterializationFailed,
5101 "context-only launch overlay is invalid",
5102 )
5103 })?
5104 } else {
5105 NativeLaunchEnvironmentOverlay::new(
5106 provider.clone(),
5107 transport,
5108 environment,
5109 )
5110 .map_err(|_| {
5111 failure(
5112 NodeFailureCode::BackendOperationFailed,
5113 "session environment overlay is invalid",
5114 )
5115 })?
5116 };
5117 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5118 } else {
5119 None
5120 };
5121 Ok(Some((overlay, guard)))
5122 }
5123
5124 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5125 let Some(mut ownership) = self
5126 .materializations
5127 .lock()
5128 .unwrap_or_else(|poisoned| poisoned.into_inner())
5129 .get(id)
5130 .cloned()
5131 else {
5132 return Ok(());
5133 };
5134 if ownership.state() != MaterializationState::CleanupRequired {
5135 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5136 failure(
5137 NodeFailureCode::BackendOperationFailed,
5138 "session environment cleanup state is invalid",
5139 )
5140 })?;
5141 }
5142 {
5143 let _transaction = self
5144 .state_transaction
5145 .lock()
5146 .unwrap_or_else(|poisoned| poisoned.into_inner());
5147 let previous = self
5148 .materializations
5149 .lock()
5150 .unwrap_or_else(|poisoned| poisoned.into_inner())
5151 .insert(id.clone(), ownership.clone());
5152 if let Err(error) = self.persist_state_locked() {
5153 let mut materializations = self
5154 .materializations
5155 .lock()
5156 .unwrap_or_else(|poisoned| poisoned.into_inner());
5157 if let Some(previous) = previous {
5158 materializations.insert(id.clone(), previous);
5159 }
5160 return Err(persistence_failure(error));
5161 }
5162 }
5163 let cleanup = self
5164 .session_environment_materializer
5165 .as_ref()
5166 .ok_or(())
5167 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5168 if cleanup.is_err() {
5169 let _ = ownership.mark_recovery_required(unix_time_ms());
5170 self.materializations
5171 .lock()
5172 .unwrap_or_else(|poisoned| poisoned.into_inner())
5173 .insert(id.clone(), ownership);
5174 if self.persist_state().is_err() {
5175 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5176 }
5177 return Err(failure(
5178 NodeFailureCode::BackendOperationFailed,
5179 "session environment cleanup requires recovery",
5180 ));
5181 }
5182 let _transaction = self
5183 .state_transaction
5184 .lock()
5185 .unwrap_or_else(|poisoned| poisoned.into_inner());
5186 self.materializations
5187 .lock()
5188 .unwrap_or_else(|poisoned| poisoned.into_inner())
5189 .remove(id);
5190 if let Err(error) = self.persist_state_locked() {
5191 self.materializations
5192 .lock()
5193 .unwrap_or_else(|poisoned| poisoned.into_inner())
5194 .insert(id.clone(), ownership);
5195 return Err(persistence_failure(error));
5196 }
5197 Ok(())
5198 }
5199
5200 fn resolve_record_materialization(
5201 &self,
5202 record_id: &SessionRecordId,
5203 provider: &AgentId,
5204 mode: SessionMode,
5205 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5206 bundle: Option<&ResolvedBundleReceipt>,
5207 context: Option<&ResolvedContextPackReceipt>,
5208 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5209 let ownership = {
5210 let materializations = self
5211 .materializations
5212 .lock()
5213 .unwrap_or_else(|poisoned| poisoned.into_inner());
5214 let mut matches = materializations.values().filter(|record| {
5215 record.owner()
5216 == &MaterializationOwner::Record {
5217 record_id: record_id.clone(),
5218 }
5219 });
5220 let ownership = matches.next().cloned();
5221 if matches.next().is_some() {
5222 return Err(failure(
5223 NodeFailureCode::BackendOperationFailed,
5224 "managed session has conflicting environment ownership",
5225 ));
5226 }
5227 ownership
5228 };
5229 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5230 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5231 failure(
5232 NodeFailureCode::ContextPackMaterializationFailed,
5233 "session context materializer is unavailable",
5234 )
5235 })?;
5236 let pack = materializer
5237 .revalidate_context(ownership, receipt)
5238 .map_err(|_| {
5239 self.mark_materialization_recovery_required(ownership.id());
5240 failure(
5241 NodeFailureCode::ContextPackMaterializationFailed,
5242 "managed session context pack revalidation failed",
5243 )
5244 })?;
5245 self.context_catalog
5246 .write()
5247 .unwrap_or_else(|poisoned| poisoned.into_inner())
5248 .insert(pack)
5249 .map_err(|_| failure(
5250 NodeFailureCode::ContextPackMaterializationFailed,
5251 "managed session context pack catalog restore failed",
5252 ))?;
5253 }
5254 let materialization = self.materialization_profile(
5255 provider,
5256 mode,
5257 environment_profile,
5258 bundle,
5259 context,
5260 )?;
5261 match (ownership, materialization) {
5262 (None, None) => Ok(None),
5263 (None, Some(_)) | (Some(_), None) => Err(failure(
5264 NodeFailureCode::BackendOperationFailed,
5265 "managed session materialization is unavailable",
5266 )),
5267 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5268 if ownership.environment_profile() != environment_profile
5269 || ownership.bundle() != bundle
5270 || ownership.context() != context
5271 || ownership.state() != MaterializationState::Ready
5272 {
5273 self.mark_materialization_recovery_required(ownership.id());
5274 return Err(failure(
5275 NodeFailureCode::BackendOperationFailed,
5276 "managed session environment ownership requires recovery",
5277 ));
5278 }
5279 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5280 failure(
5281 NodeFailureCode::BackendOperationFailed,
5282 "session environment materializer is unavailable",
5283 )
5284 })?;
5285 if materializer.revalidate(&ownership).is_err() {
5286 self.mark_materialization_recovery_required(ownership.id());
5287 return Err(failure(
5288 NodeFailureCode::BackendOperationFailed,
5289 "managed session environment revalidation failed",
5290 ));
5291 }
5292 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5293 let catalog = self.bundle_catalog.read()
5294 .unwrap_or_else(|poisoned| poisoned.into_inner());
5295 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5296 return Err(failure(
5297 NodeFailureCode::UnknownBundle,
5298 "managed session bundle is unavailable",
5299 ));
5300 };
5301 let layout = bundle_layout.ok_or_else(|| failure(
5302 NodeFailureCode::BundleBindingMismatch,
5303 "record bundle provider layout is unavailable",
5304 ))?;
5305 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5306 drop(catalog);
5307 self.mark_materialization_recovery_required(ownership.id());
5308 return Err(failure(
5309 NodeFailureCode::BundleMaterializationFailed,
5310 "managed session bundle revalidation failed",
5311 ));
5312 }
5313 Some(bundle_launch_arguments(
5314 layout,
5315 ownership.bundle_root(),
5316 ownership.provider_home(),
5317 ).map_err(|_| failure(
5318 NodeFailureCode::BundleBindingMismatch,
5319 "managed session bundle launch binding is invalid",
5320 ))?)
5321 } else {
5322 None
5323 };
5324 if !has_environment_materialization {
5325 let overlay = bundle_arguments.map(|extra_args| {
5326 NativeInstanceLaunchOverlay::new(
5327 provider.clone(),
5328 TransportKind::Pty,
5329 Vec::new(),
5330 extra_args,
5331 )
5332 .map(PreparedNativeLaunchOverlay::Instance)
5333 .map_err(|_| failure(
5334 NodeFailureCode::BundleBindingMismatch,
5335 "managed session bundle launch overlay is invalid",
5336 ))
5337 }).transpose()?;
5338 return Ok(Some((ownership.id().clone(), overlay)));
5339 }
5340 let environment = match materializer.resolve_environment(&ownership, &profile) {
5341 Ok(environment) => environment,
5342 Err(
5343 SessionEnvironmentMaterializeError::SecretUnavailable
5344 | SessionEnvironmentMaterializeError::SecretDenied
5345 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5346 ) => {
5347 return Err(failure(
5348 NodeFailureCode::BackendOperationFailed,
5349 "managed session environment resolution failed",
5350 ));
5351 }
5352 Err(
5353 SessionEnvironmentMaterializeError::InvalidRoot
5354 | SessionEnvironmentMaterializeError::OwnershipMismatch
5355 | SessionEnvironmentMaterializeError::Filesystem(_)
5356 | SessionEnvironmentMaterializeError::Record(_),
5357 ) => {
5358 self.mark_materialization_recovery_required(ownership.id());
5359 return Err(failure(
5360 NodeFailureCode::BackendOperationFailed,
5361 "managed session environment resolution failed",
5362 ));
5363 }
5364 };
5365 let transport = match mode {
5366 SessionMode::Pty => TransportKind::Pty,
5367 SessionMode::Inline => TransportKind::Pipe,
5368 SessionMode::Acp => TransportKind::Acp,
5369 };
5370 let overlay = match bundle_arguments {
5371 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5372 NativeInstanceLaunchOverlay::new(
5373 provider.clone(),
5374 transport,
5375 environment,
5376 extra_args,
5377 )
5378 .map_err(|_| failure(
5379 NodeFailureCode::BundleBindingMismatch,
5380 "managed session bundle launch overlay is invalid",
5381 ))?,
5382 ),
5383 None => PreparedNativeLaunchOverlay::Environment(
5384 NativeLaunchEnvironmentOverlay::new(
5385 provider.clone(),
5386 transport,
5387 environment,
5388 )
5389 .map_err(|_| {
5390 failure(
5391 NodeFailureCode::BackendOperationFailed,
5392 "managed session environment overlay is invalid",
5393 )
5394 })?,
5395 ),
5396 };
5397 Ok(Some((ownership.id().clone(), Some(overlay))))
5398 }
5399 }
5400 }
5401
5402 fn destroy_record_materialization(
5403 &self,
5404 record_id: &SessionRecordId,
5405 ) -> Result<bool, NodeFailure> {
5406 let ids = self
5407 .materializations
5408 .lock()
5409 .unwrap_or_else(|poisoned| poisoned.into_inner())
5410 .values()
5411 .filter_map(|record| {
5412 (record.owner()
5413 == &MaterializationOwner::Record {
5414 record_id: record_id.clone(),
5415 })
5416 .then(|| record.id().clone())
5417 })
5418 .collect::<Vec<_>>();
5419 if ids.len() > 1 {
5420 return Err(failure(
5421 NodeFailureCode::BackendOperationFailed,
5422 "managed session has conflicting environment ownership",
5423 ));
5424 }
5425 let Some(id) = ids.first().cloned() else {
5426 return Ok(false);
5427 };
5428 let (terminal_record, mut cleanup_ownership) = {
5429 let _transaction = self
5430 .state_transaction
5431 .lock()
5432 .unwrap_or_else(|poisoned| poisoned.into_inner());
5433 let original_record = self.record(record_id)?;
5434 let mut terminal_record = original_record.clone();
5435 terminal_record.provider_session = None;
5436 terminal_record.active_session = None;
5437 terminal_record.state = ManagedSessionState::Unavailable;
5438 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5439 terminal_record.updated_at_unix_ms = unix_time_ms();
5440 let original_ownership = self
5441 .materializations
5442 .lock()
5443 .unwrap_or_else(|poisoned| poisoned.into_inner())
5444 .get(&id)
5445 .cloned()
5446 .ok_or_else(|| {
5447 failure(
5448 NodeFailureCode::BackendOperationFailed,
5449 "managed session environment ownership disappeared during cleanup",
5450 )
5451 })?;
5452 let mut cleanup_ownership = original_ownership.clone();
5453 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5454 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5455 failure(
5456 NodeFailureCode::BackendOperationFailed,
5457 "managed session environment cleanup state is invalid",
5458 )
5459 })?;
5460 }
5461 self.session_records
5462 .lock()
5463 .unwrap_or_else(|poisoned| poisoned.into_inner())
5464 .records
5465 .insert(record_id.clone(), terminal_record.clone());
5466 self.materializations
5467 .lock()
5468 .unwrap_or_else(|poisoned| poisoned.into_inner())
5469 .insert(id.clone(), cleanup_ownership.clone());
5470 if let Err(error) = self.persist_state_locked() {
5471 self.session_records
5472 .lock()
5473 .unwrap_or_else(|poisoned| poisoned.into_inner())
5474 .records
5475 .insert(record_id.clone(), original_record);
5476 self.materializations
5477 .lock()
5478 .unwrap_or_else(|poisoned| poisoned.into_inner())
5479 .insert(id.clone(), original_ownership);
5480 return Err(persistence_failure(error));
5481 }
5482 (terminal_record, cleanup_ownership)
5483 };
5484 let cleanup = self
5485 .session_environment_materializer
5486 .as_ref()
5487 .ok_or(())
5488 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5489 if cleanup.is_err() {
5490 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5491 self.materializations
5492 .lock()
5493 .unwrap_or_else(|poisoned| poisoned.into_inner())
5494 .insert(id.clone(), cleanup_ownership);
5495 if self.persist_state().is_err() {
5496 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5497 }
5498 return Err(failure(
5499 NodeFailureCode::BackendOperationFailed,
5500 "managed session environment cleanup requires recovery",
5501 ));
5502 }
5503 let _transaction = self
5504 .state_transaction
5505 .lock()
5506 .unwrap_or_else(|poisoned| poisoned.into_inner());
5507 self.remove_record_memory(record_id);
5508 self.materializations
5509 .lock()
5510 .unwrap_or_else(|poisoned| poisoned.into_inner())
5511 .remove(&id);
5512 if let Err(error) = self.persist_state_locked() {
5513 self.session_records
5514 .lock()
5515 .unwrap_or_else(|poisoned| poisoned.into_inner())
5516 .records
5517 .insert(record_id.clone(), terminal_record);
5518 self.materializations
5519 .lock()
5520 .unwrap_or_else(|poisoned| poisoned.into_inner())
5521 .insert(id, cleanup_ownership);
5522 return Err(persistence_failure(error));
5523 }
5524 Ok(true)
5525 }
5526
5527 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5528 let changed = {
5529 let mut materializations = self
5530 .materializations
5531 .lock()
5532 .unwrap_or_else(|poisoned| poisoned.into_inner());
5533 let Some(ownership) = materializations.get_mut(id) else {
5534 return;
5535 };
5536 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5537 };
5538 if changed && self.persist_state().is_err() {
5539 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5540 }
5541 }
5542
5543 fn reconcile_materializations(&self) {
5544 let records = self
5545 .materializations
5546 .lock()
5547 .unwrap_or_else(|poisoned| poisoned.into_inner())
5548 .values()
5549 .cloned()
5550 .collect::<Vec<_>>();
5551 for mut ownership in records {
5552 let result = match (ownership.state(), ownership.owner()) {
5553 (MaterializationState::Preparing, _)
5554 | (MaterializationState::CleanupRequired, _) => {
5555 self.cleanup_materialization(ownership.id())
5556 }
5557 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5558 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5559 self.materializations
5560 .lock()
5561 .unwrap_or_else(|poisoned| poisoned.into_inner())
5562 .insert(ownership.id().clone(), ownership.clone());
5563 self.persist_state().map_err(persistence_failure)
5564 } else {
5565 Err(failure(
5566 NodeFailureCode::BackendOperationFailed,
5567 "session environment reconciliation failed",
5568 ))
5569 }
5570 }
5571 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5572 .session_environment_materializer
5573 .as_ref()
5574 .ok_or_else(|| {
5575 failure(
5576 NodeFailureCode::BackendOperationFailed,
5577 "session environment materializer is unavailable",
5578 )
5579 })
5580 .and_then(|materializer| {
5581 materializer.revalidate(&ownership).map_err(|_| failure(
5582 NodeFailureCode::BackendOperationFailed,
5583 "session materialization revalidation failed",
5584 ))?;
5585 let record = self.record(record_id)?;
5586 if record.environment_profile.as_ref() != ownership.environment_profile()
5587 || record.bundle.as_ref() != ownership.bundle()
5588 || record.context.as_ref() != ownership.context()
5589 {
5590 return Err(failure(
5591 NodeFailureCode::BackendOperationFailed,
5592 "session materialization ownership changed after restart",
5593 ));
5594 }
5595 if let Some(receipt) = ownership.bundle() {
5596 let catalog = self.bundle_catalog.read()
5597 .unwrap_or_else(|poisoned| poisoned.into_inner());
5598 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5599 NodeFailureCode::UnknownBundle,
5600 "session bundle is unavailable after restart",
5601 ))?;
5602 if bundle.receipt() != *receipt {
5603 return Err(failure(
5604 NodeFailureCode::BundleBindingMismatch,
5605 "session bundle changed after restart",
5606 ));
5607 }
5608 let layout = self.bundle_layout(
5609 &record.provider,
5610 record.mode,
5611 record.environment_profile.as_ref(),
5612 bundle,
5613 )?;
5614 materializer
5615 .revalidate_bundle(&ownership, bundle, layout)
5616 .map_err(|_| failure(
5617 NodeFailureCode::BundleMaterializationFailed,
5618 "session bundle revalidation failed",
5619 ))?;
5620 }
5621 if let Some(receipt) = ownership.context() {
5622 let pack = materializer
5623 .revalidate_context(&ownership, receipt)
5624 .map_err(|_| {
5625 failure(
5626 NodeFailureCode::ContextPackMaterializationFailed,
5627 "session context revalidation failed",
5628 )
5629 })?;
5630 self.context_catalog
5631 .write()
5632 .unwrap_or_else(|poisoned| poisoned.into_inner())
5633 .insert(pack)
5634 .map_err(|_| {
5635 failure(
5636 NodeFailureCode::ContextPackMaterializationFailed,
5637 "session context catalog restore failed",
5638 )
5639 })?;
5640 }
5641 Ok(())
5642 }),
5643 (MaterializationState::RecoveryRequired, _) => Ok(()),
5644 };
5645 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5646 let _ = ownership.mark_recovery_required(unix_time_ms());
5647 self.materializations
5648 .lock()
5649 .unwrap_or_else(|poisoned| poisoned.into_inner())
5650 .insert(ownership.id().clone(), ownership);
5651 if self.persist_state().is_err() {
5652 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5653 }
5654 }
5655 }
5656 let materialized_profiles = self
5657 .environment_materialization_profiles
5658 .read()
5659 .unwrap_or_else(|poisoned| poisoned.into_inner())
5660 .keys()
5661 .cloned()
5662 .collect::<Vec<_>>();
5663 let ownership = self
5664 .materializations
5665 .lock()
5666 .unwrap_or_else(|poisoned| poisoned.into_inner())
5667 .values()
5668 .cloned()
5669 .collect::<Vec<_>>();
5670 let changed = {
5671 let mut session_records = self
5672 .session_records
5673 .lock()
5674 .unwrap_or_else(|poisoned| poisoned.into_inner());
5675 let mut changed = false;
5676 for record in session_records.records.values_mut() {
5677 let environment_profile = record.environment_profile.as_ref();
5678 if environment_profile.is_none()
5679 && record.bundle.is_none()
5680 && record.context.is_none()
5681 {
5682 continue;
5683 }
5684 if record.bundle.is_none()
5685 && environment_profile.is_some_and(|profile| {
5686 !materialized_profiles.contains(&profile.profile_id)
5687 })
5688 {
5689 continue;
5690 }
5691 let has_exact_ownership = ownership.iter().any(|candidate| {
5692 candidate.state() == MaterializationState::Ready
5693 && candidate.environment_profile() == environment_profile
5694 && candidate.bundle() == record.bundle.as_ref()
5695 && candidate.context() == record.context.as_ref()
5696 && candidate.owner()
5697 == &MaterializationOwner::Record {
5698 record_id: record.record_id.clone(),
5699 }
5700 });
5701 if !has_exact_ownership {
5702 record.provider_session = None;
5703 record.active_session = None;
5704 record.state = ManagedSessionState::Unavailable;
5705 record.last_error = Some(if record.bundle.is_some() {
5706 "bundle-unavailable".to_owned()
5707 } else if record.context.is_some() {
5708 "context-unavailable".to_owned()
5709 } else {
5710 "environment-profile-unavailable".to_owned()
5711 });
5712 record.updated_at_unix_ms = unix_time_ms();
5713 changed = true;
5714 }
5715 }
5716 changed
5717 };
5718 if changed && self.persist_state().is_err() {
5719 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5720 }
5721 }
5722
5723 fn replay_spawn_spec(
5724 &self,
5725 spec: &SpawnSpec,
5726 now: Instant,
5727 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5728 let mut cache = self
5729 .spawn_idempotency
5730 .lock()
5731 .unwrap_or_else(|poisoned| poisoned.into_inner());
5732 cache.entries.retain(|_, entry| entry.expires_at > now);
5733 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5734 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5735 return Err(failure(
5736 NodeFailureCode::SpawnIdempotencyConflict,
5737 "spawn idempotency key was reused across spawn kinds",
5738 ));
5739 };
5740 if existing != spec {
5741 return Err(failure(
5742 NodeFailureCode::SpawnIdempotencyConflict,
5743 "spawn idempotency key was reused with a different specification",
5744 ));
5745 }
5746 return Ok(Some(result.clone()));
5747 }
5748 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5749 return Err(failure(
5750 NodeFailureCode::SpawnIdempotencyCapacity,
5751 "spawn idempotency cache reached its bounded capacity",
5752 ));
5753 }
5754 Ok(None)
5755 }
5756
5757 fn remember_spawn_spec(
5758 &self,
5759 spec: SpawnSpec,
5760 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5761 accepted_at: Instant,
5762 ) {
5763 self
5764 .spawn_idempotency
5765 .lock()
5766 .unwrap_or_else(|poisoned| poisoned.into_inner())
5767 .entries
5768 .insert(
5769 spec.idempotency_key.clone(),
5770 SpawnIdempotencyEntry {
5771 value: SpawnIdempotencyValue::Standard { spec, result },
5772 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5773 },
5774 );
5775 }
5776
5777 fn claim_harness_mcp_spawn(
5778 &self,
5779 _reservation_id: &HarnessMcpReservationId,
5780 _activation_digest: &HarnessMcpActivationDigest,
5781 spec: &SpawnSpec,
5782 now: Instant,
5783 ) -> Result<(), NodeFailure> {
5784 let mut cache = self.spawn_idempotency.lock()
5785 .unwrap_or_else(|poisoned| poisoned.into_inner());
5786 cache.entries.retain(|_, entry| entry.expires_at > now);
5787 if cache.entries.contains_key(&spec.idempotency_key) {
5788 return Err(failure(
5789 NodeFailureCode::SpawnIdempotencyConflict,
5790 "spawn idempotency key was reused across spawn kinds",
5791 ));
5792 }
5793 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5794 return Err(failure(
5795 NodeFailureCode::SpawnIdempotencyCapacity,
5796 "spawn idempotency cache reached its bounded capacity",
5797 ));
5798 }
5799 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5800 value: SpawnIdempotencyValue::HarnessMcp,
5801 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5802 });
5803 Ok(())
5804 }
5805
5806 fn replay_managed_spawn(
5807 &self,
5808 request: &ManagedWorktreeSpawnRequest,
5809 now: Instant,
5810 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5811 let mut cache = self.spawn_idempotency.lock()
5812 .unwrap_or_else(|poisoned| poisoned.into_inner());
5813 cache.entries.retain(|_, entry| entry.expires_at > now);
5814 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5815 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5816 return Err(failure(
5817 NodeFailureCode::SpawnIdempotencyConflict,
5818 "spawn idempotency key was reused across spawn kinds",
5819 ));
5820 };
5821 if existing != request {
5822 return Err(failure(
5823 NodeFailureCode::SpawnIdempotencyConflict,
5824 "spawn idempotency key was reused with a different managed specification",
5825 ));
5826 }
5827 return Ok(Some(result.clone()));
5828 }
5829 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5830 return Err(failure(
5831 NodeFailureCode::SpawnIdempotencyCapacity,
5832 "spawn idempotency cache reached its bounded capacity",
5833 ));
5834 }
5835 Ok(None)
5836 }
5837
5838 fn remember_managed_spawn(
5839 &self,
5840 request: ManagedWorktreeSpawnRequest,
5841 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5842 accepted_at: Instant,
5843 ) {
5844 self.spawn_idempotency.lock()
5845 .unwrap_or_else(|poisoned| poisoned.into_inner())
5846 .entries
5847 .insert(
5848 request.spawn_spec.idempotency_key.clone(),
5849 SpawnIdempotencyEntry {
5850 value: SpawnIdempotencyValue::Managed { request, result },
5851 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5852 },
5853 );
5854 }
5855
5856 fn remember_managed_spawn_attempt(
5857 &self,
5858 durable_v2_key: Option<&SpawnIdempotencyKey>,
5859 request: ManagedWorktreeSpawnRequest,
5860 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5861 accepted_at: Instant,
5862 ) {
5863 if durable_v2_key.is_none() {
5864 self.remember_managed_spawn(request, result, accepted_at);
5865 }
5866 }
5867
5868 fn acquire_durable_managed_spawn_v2(
5869 &self,
5870 request: &ManagedWorktreeSpawnRequestV2,
5871 request_digest: String,
5872 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5873 if self.state_path.is_none() {
5874 return Err(failure(
5875 NodeFailureCode::BackendOperationFailed,
5876 "managed worktree V2 requires durable node state",
5877 ));
5878 }
5879 let _transaction = self.state_transaction
5880 .lock()
5881 .unwrap_or_else(|poisoned| poisoned.into_inner());
5882 let key = request.spawn_spec.idempotency_key.clone();
5883 let mut previous = None;
5884 {
5885 let mut replays = self.managed_spawn_replays
5886 .lock()
5887 .unwrap_or_else(|poisoned| poisoned.into_inner());
5888 if let Some(record) = replays.get_mut(&key) {
5889 if record.request_digest != request_digest {
5890 return Err(failure(
5891 NodeFailureCode::SpawnIdempotencyConflict,
5892 "managed worktree V2 idempotency key was reused with a different request",
5893 ));
5894 }
5895 match &record.state {
5896 ManagedWorktreeSpawnReplayStateV10::Pending {
5897 owner_incarnation_id,
5898 lease_id: None,
5899 } if owner_incarnation_id != &self.incarnation_id => {
5900 previous = Some(record.clone());
5901 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5902 owner_incarnation_id: self.incarnation_id,
5903 lease_id: None,
5904 };
5905 record.updated_at_unix_ms = unix_time_ms();
5906 }
5907 ManagedWorktreeSpawnReplayStateV10::Pending {
5908 lease_id: None,
5909 ..
5910 } => {
5911 return Err(failure(
5912 NodeFailureCode::BackendBusy,
5913 "managed worktree V2 reservation is already active",
5914 ));
5915 }
5916 ManagedWorktreeSpawnReplayStateV10::Pending {
5917 lease_id: Some(_),
5918 ..
5919 } => {
5920 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5921 }
5922 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5923 return Ok(DurableManagedSpawnReplayDecision::Committed(
5924 receipt.clone(),
5925 ));
5926 }
5927 }
5928 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5929 return Err(failure(
5930 NodeFailureCode::SpawnIdempotencyCapacity,
5931 "durable managed worktree replay ledger reached its bounded capacity",
5932 ));
5933 } else {
5934 let now = unix_time_ms();
5935 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5936 idempotency_key: key.clone(),
5937 request_digest,
5938 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5939 profile_id: request.worktree_profile_id.clone(),
5940 expected_profile_revision: request.expected_profile_revision.clone(),
5941 state: ManagedWorktreeSpawnReplayStateV10::Pending {
5942 owner_incarnation_id: self.incarnation_id,
5943 lease_id: None,
5944 },
5945 created_at_unix_ms: now,
5946 updated_at_unix_ms: now,
5947 });
5948 }
5949 }
5950 if let Err(error) = self.persist_state_locked() {
5951 let mut replays = self.managed_spawn_replays
5952 .lock()
5953 .unwrap_or_else(|poisoned| poisoned.into_inner());
5954 match previous {
5955 Some(previous) => {
5956 replays.insert(key, previous);
5957 }
5958 None => {
5959 replays.remove(&key);
5960 }
5961 }
5962 return Err(persistence_failure(error));
5963 }
5964 Ok(DurableManagedSpawnReplayDecision::Reserved)
5965 }
5966
5967 fn link_durable_managed_spawn_v2_lease_locked(
5968 &self,
5969 key: &SpawnIdempotencyKey,
5970 lease_id: &ManagedWorktreeLeaseId,
5971 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5972 let mut replays = self.managed_spawn_replays
5973 .lock()
5974 .unwrap_or_else(|poisoned| poisoned.into_inner());
5975 let record = replays.get_mut(key).ok_or_else(|| {
5976 failure(
5977 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5978 "managed worktree V2 reservation disappeared before allocation",
5979 )
5980 })?;
5981 let previous = record.clone();
5982 match &mut record.state {
5983 ManagedWorktreeSpawnReplayStateV10::Pending {
5984 lease_id: current,
5985 ..
5986 }
5987 if current.is_none() =>
5988 {
5989 *current = Some(lease_id.clone());
5990 record.updated_at_unix_ms = unix_time_ms();
5991 Ok(previous)
5992 }
5993 _ => Err(failure(
5994 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5995 "managed worktree V2 reservation is not allocation-ready",
5996 )),
5997 }
5998 }
5999
6000 fn restore_durable_managed_spawn_v2_locked(
6001 &self,
6002 key: &SpawnIdempotencyKey,
6003 previous: ManagedWorktreeSpawnReplayRecordV10,
6004 ) {
6005 self.managed_spawn_replays
6006 .lock()
6007 .unwrap_or_else(|poisoned| poisoned.into_inner())
6008 .insert(key.clone(), previous);
6009 }
6010
6011 fn commit_durable_managed_spawn_v2(
6012 &self,
6013 request: &ManagedWorktreeSpawnRequestV2,
6014 receipt: ManagedWorktreeSpawnReceipt,
6015 ) -> Result<(), NodeFailure> {
6016 let _transaction = self.state_transaction
6017 .lock()
6018 .unwrap_or_else(|poisoned| poisoned.into_inner());
6019 let key = &request.spawn_spec.idempotency_key;
6020 let previous = {
6021 let mut replays = self.managed_spawn_replays
6022 .lock()
6023 .unwrap_or_else(|poisoned| poisoned.into_inner());
6024 let record = replays.get_mut(key).ok_or_else(|| {
6025 failure(
6026 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6027 "managed worktree V2 reservation disappeared before commit",
6028 )
6029 })?;
6030 let previous = record.clone();
6031 match &record.state {
6032 ManagedWorktreeSpawnReplayStateV10::Pending {
6033 lease_id: Some(lease_id),
6034 ..
6035 } if lease_id == &receipt.lease.lease_id => {}
6036 _ => {
6037 return Err(failure(
6038 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6039 "managed worktree V2 reservation is not linked to the spawned lease",
6040 ));
6041 }
6042 }
6043 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6044 record.updated_at_unix_ms = unix_time_ms();
6045 previous
6046 };
6047 if let Err(error) = self.persist_state_locked() {
6048 self.restore_durable_managed_spawn_v2_locked(key, previous);
6049 return Err(persistence_failure(error));
6050 }
6051 Ok(())
6052 }
6053
6054 fn remove_unallocated_durable_managed_spawn_v2(
6055 &self,
6056 request: &ManagedWorktreeSpawnRequestV2,
6057 ) -> Result<(), NodeFailure> {
6058 let _transaction = self.state_transaction
6059 .lock()
6060 .unwrap_or_else(|poisoned| poisoned.into_inner());
6061 let key = &request.spawn_spec.idempotency_key;
6062 let removed = {
6063 let mut replays = self.managed_spawn_replays
6064 .lock()
6065 .unwrap_or_else(|poisoned| poisoned.into_inner());
6066 match replays.get(key).map(|record| &record.state) {
6067 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6068 lease_id: None,
6069 ..
6070 }) => {
6071 replays.remove(key)
6072 }
6073 _ => None,
6074 }
6075 };
6076 let Some(removed) = removed else {
6077 return Ok(());
6078 };
6079 if let Err(error) = self.persist_state_locked() {
6080 self.managed_spawn_replays
6081 .lock()
6082 .unwrap_or_else(|poisoned| poisoned.into_inner())
6083 .insert(key.clone(), removed);
6084 return Err(persistence_failure(error));
6085 }
6086 Ok(())
6087 }
6088
6089 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6090 if spec.target.node_id != self.node_id {
6091 return Err(failure(
6092 NodeFailureCode::SpawnTargetMismatch,
6093 "spawn target does not match this node",
6094 ));
6095 }
6096 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6097 failure(
6098 NodeFailureCode::UnknownSpawnProfile,
6099 "spawn profile is unavailable on this node",
6100 )
6101 })?;
6102 let resolved = spec.resolve(defaults).map_err(|error| match error {
6103 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6104 NodeFailureCode::SpawnProfileRevisionMismatch,
6105 "spawn profile revision does not match the loaded profile",
6106 ),
6107 _ => failure(
6108 NodeFailureCode::InvalidRequest,
6109 "spawn specification could not be resolved",
6110 ),
6111 })?;
6112 if resolved.target.worktree_id.is_some() {
6113 self.require_worktree_service(&resolved.target.workspace_id)?;
6114 }
6115 let environment_profile = self.resolve_environment_profile(&resolved)?;
6116 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6117 self.resolve_context(&resolved)?;
6118 Ok(resolved)
6119 }
6120
6121 async fn resolve_spawn_workspace(
6122 &self,
6123 resolved: &ResolvedSpawnSpec,
6124 deadline: Instant,
6125 ) -> Result<WorkspaceId, NodeFailure> {
6126 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6127 return Ok(resolved.target.workspace_id.clone());
6128 };
6129 if self.managed_worktrees.lock()
6130 .unwrap_or_else(|poisoned| poisoned.into_inner())
6131 .lease_for_workspace(worktree_id)
6132 .is_some()
6133 {
6134 return Err(failure(
6135 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6136 "managed worktree targets may only be spawned through their lease request",
6137 ));
6138 }
6139 self.require_worktree_service(&resolved.target.workspace_id)?;
6140 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6141 let selected_root = self.workspace_root(worktree_id)?;
6142 let remaining = spawn_deadline_remaining(deadline)?;
6143 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6144 .await
6145 .map_err(|_| {
6146 failure(
6147 NodeFailureCode::SpawnDeadlineExceeded,
6148 "Git worktree selection exceeded the spawn deadline",
6149 )
6150 })?
6151 .map_err(git_worktree_failure)?;
6152 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6153 Ok(worktree_id.clone())
6154 }
6155
6156 async fn spawn_from_spec(
6157 &self,
6158 spec: SpawnSpec,
6159 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6160 self.spawn_from_spec_with_proxy(spec, None).await
6161 }
6162
6163 async fn spawn_from_spec_with_proxy(
6164 &self,
6165 spec: SpawnSpec,
6166 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6167 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6168 let accepted_at = Instant::now();
6169 if harness_mcp.is_none() {
6170 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6171 return replayed;
6172 }
6173 }
6174 let resolved = self.resolve_spawn_spec(&spec)?;
6175 let environment_profile = self.resolve_environment_profile(&resolved)?;
6176 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6177 let context = self.resolve_context(&resolved)?;
6178 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6179 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6180 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6181 let spawn_workspace_id = match spawn_workspace_id {
6182 Ok(workspace_id) => workspace_id,
6183 Err(error) => {
6184 let result = Err(error);
6185 if harness_mcp.is_none() {
6186 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6187 }
6188 return result;
6189 }
6190 };
6191 if self.managed_worktrees.lock()
6192 .unwrap_or_else(|poisoned| poisoned.into_inner())
6193 .lease_for_workspace(&spawn_workspace_id)
6194 .is_some()
6195 {
6196 let result = Err(failure(
6197 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6198 "managed worktree targets may only be spawned through their lease request",
6199 ));
6200 if harness_mcp.is_none() {
6201 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6202 }
6203 return result;
6204 }
6205 let result = self
6206 .spawn_session_with_deadline(
6207 spawn_workspace_id,
6208 resolved.provider.clone(),
6209 resolved.mode,
6210 resolved.terminal_size,
6211 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6212 environment_profile.clone(),
6213 bundle.clone(),
6214 context.clone(),
6215 None,
6216 None,
6217 SpawnRecordPolicy::Always,
6218 Some(deadline),
6219 &required_capabilities,
6220 harness_mcp,
6221 resolved.approval_level,
6222 resolved.network_allowlist.clone(),
6223 resolved.browser_profile_id.clone(),
6224 )
6225 .await
6226 .map(|(session, _runtime_policy)| {
6227 resolved.receipt_with_materialization(
6228 self.incarnation_id,
6229 session,
6230 environment_profile,
6231 bundle,
6232 context,
6233 )
6234 });
6235 if harness_mcp.is_none() {
6236 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6237 }
6238 result
6239 }
6240
6241 async fn spawn_from_spec_with_harness_mcp(
6242 &self,
6243 reservation_id: HarnessMcpReservationId,
6244 activation_digest: HarnessMcpActivationDigest,
6245 spec: SpawnSpec,
6246 deadline_unix_ms: u64,
6247 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6248 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6249 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6250 })?;
6251 if let Some(receipt) = registry
6252 .replay_spawn(&reservation_id, &activation_digest, &spec)
6253 .map_err(harness_mcp_failure)?
6254 {
6255 return Ok(receipt);
6256 }
6257 let resolved = self.resolve_spawn_spec(&spec)?;
6258 let prepared = registry
6259 .prepare_spawn(
6260 &reservation_id,
6261 &activation_digest,
6262 &spec,
6263 deadline_unix_ms,
6264 resolved.provider.clone(),
6265 )
6266 .map_err(harness_mcp_failure)?;
6267 self.claim_harness_mcp_spawn(
6268 &reservation_id,
6269 &activation_digest,
6270 &spec,
6271 Instant::now(),
6272 )?;
6273 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6274 let mut receipt = match spawn {
6275 Ok(receipt) => receipt,
6276 Err(error) => {
6277 let _ = registry.abort(&reservation_id, &activation_digest);
6278 return Err(error);
6279 }
6280 };
6281 let record_id = self
6282 .session_bindings
6283 .lock()
6284 .unwrap_or_else(|poisoned| poisoned.into_inner())
6285 .get(&receipt.session.session.instance_id)
6286 .and_then(|binding| binding.record_id.clone());
6287 let record_id = match record_id {
6288 Some(record_id) => record_id,
6289 None => {
6290 let rollback = self.remove_session(&receipt.session).await;
6291 let _ = registry.abort(&reservation_id, &activation_digest);
6292 if let Err(rollback_error) = rollback {
6293 return Err(rollback_error);
6294 }
6295 return Err(failure(
6296 NodeFailureCode::BindingMismatch,
6297 "harness MCP spawn did not retain its exact managed record",
6298 ));
6299 }
6300 };
6301 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6302 reservation_id: reservation_id.clone(),
6303 activation_digest: activation_digest.clone(),
6304 });
6305 if let Err(error) = registry.mark_spawned(
6306 &prepared,
6307 receipt.session.clone(),
6308 record_id,
6309 receipt.clone(),
6310 ) {
6311 let rollback = self.remove_session(&receipt.session).await;
6312 let _ = registry.abort(&reservation_id, &activation_digest);
6313 if let Err(rollback_error) = rollback {
6314 return Err(rollback_error);
6315 }
6316 return Err(harness_mcp_failure(error));
6317 }
6318 Ok(receipt)
6319 }
6320
6321 async fn spawn_managed_worktree(
6322 &self,
6323 request: ManagedWorktreeSpawnRequest,
6324 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6325 self.spawn_managed_worktree_inner(request, None).await
6326 }
6327
6328 async fn spawn_managed_worktree_inner(
6329 &self,
6330 request: ManagedWorktreeSpawnRequest,
6331 durable_v2_key: Option<&SpawnIdempotencyKey>,
6332 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6333 let accepted_at = Instant::now();
6334 if durable_v2_key.is_none() {
6335 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6336 return replayed;
6337 }
6338 }
6339 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6340 if request.spawn_spec.target.worktree_id.is_some() {
6341 let result = Err(failure(
6342 NodeFailureCode::InvalidRequest,
6343 "managed worktree spawn must not select a caller-provided worktree",
6344 ));
6345 self.remember_managed_spawn_attempt(
6346 durable_v2_key,
6347 request,
6348 result.clone(),
6349 accepted_at,
6350 );
6351 return result;
6352 }
6353 let environment_profile = self.resolve_environment_profile(&resolved)?;
6354 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6355 let context = self.resolve_context(&resolved)?;
6356 let source_workspace_id = resolved.target.workspace_id.clone();
6357 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6358 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6359 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6360 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6361 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6362 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6363 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6364 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6365 };
6366 let runtime_policy = timeout(
6367 spawn_deadline_remaining(deadline)?,
6368 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6369 ).await.map_err(|_| failure(
6370 NodeFailureCode::SpawnDeadlineExceeded,
6371 "provider admission exceeded the managed spawn deadline",
6372 ))??;
6373 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6374 return Err(failure(
6375 NodeFailureCode::UnsupportedSpawnCapability,
6376 "provider runtime does not admit a required spawn capability",
6377 ));
6378 }
6379 self.ensure_binding_capacity()?;
6380 let source_root = self.workspace_root(&source_workspace_id)?;
6381 profile.validate_for_workspace(&source_root)
6382 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6383 let base_timeout_ms = u64::try_from(
6384 spawn_deadline_remaining(deadline)?.as_millis(),
6385 ).unwrap_or(u64::MAX).max(1);
6386 let base_commit = resolve_base_commit_with_timeout(
6387 &source_root,
6388 profile.base(),
6389 base_timeout_ms,
6390 ).await.map_err(|error| {
6391 if Instant::now() >= deadline || error.message.contains("timed out") {
6392 failure(
6393 NodeFailureCode::SpawnDeadlineExceeded,
6394 "managed worktree base resolution exceeded the spawn deadline",
6395 )
6396 } else {
6397 managed_git_worktree_failure(error)
6398 }
6399 })?;
6400
6401 let lease = {
6402 let _transaction = self.state_transaction.lock()
6403 .unwrap_or_else(|poisoned| poisoned.into_inner());
6404 let previous = self.managed_worktrees.lock()
6405 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6406 let lease = self.managed_worktrees.lock()
6407 .unwrap_or_else(|poisoned| poisoned.into_inner())
6408 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6409 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6410 let previous_replay = match durable_v2_key
6411 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6412 .transpose()
6413 {
6414 Ok(previous) => previous,
6415 Err(error) => {
6416 *self.managed_worktrees.lock()
6417 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6418 return Err(error);
6419 }
6420 };
6421 if let Err(error) = self.persist_state_locked() {
6422 *self.managed_worktrees.lock()
6423 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6424 if let (Some(key), Some(previous_replay)) =
6425 (durable_v2_key, previous_replay)
6426 {
6427 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6428 }
6429 return Err(persistence_failure(error));
6430 }
6431 lease
6432 };
6433 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6434 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6435 self.managed_worktrees.lock()
6436 .unwrap_or_else(|poisoned| poisoned.into_inner())
6437 .remove_unmutated(&lease.lease_id);
6438 if self.persist_state().is_err() {
6439 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6440 }
6441 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6442 self.remember_managed_spawn_attempt(
6443 durable_v2_key,
6444 request,
6445 result.clone(),
6446 accepted_at,
6447 );
6448 return result;
6449 }
6450 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6451 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6452 Err(error) => {
6453 self.reconcile_failed_allocation(&lease, &source_root).await;
6454 let result = Err(error);
6455 self.remember_managed_spawn_attempt(
6456 durable_v2_key,
6457 request,
6458 result.clone(),
6459 accepted_at,
6460 );
6461 return result;
6462 }
6463 };
6464 let created = match create_git_worktree_with_timeout(
6465 &source_root,
6466 &lease.target_root,
6467 &lease.branch,
6468 Some(&base_commit),
6469 mutation_timeout_ms,
6470 ).await {
6471 Ok(created) => created,
6472 Err(error) => {
6473 self.reconcile_failed_allocation(&lease, &source_root).await;
6474 let result = Err(if Instant::now() >= deadline
6475 || error.message.contains("timed out")
6476 || error.message.contains("deadline")
6477 {
6478 failure(
6479 NodeFailureCode::SpawnDeadlineExceeded,
6480 "managed worktree creation exceeded the spawn deadline",
6481 )
6482 } else {
6483 managed_git_worktree_failure(error)
6484 });
6485 self.remember_managed_spawn_attempt(
6486 durable_v2_key,
6487 request,
6488 result.clone(),
6489 accepted_at,
6490 );
6491 return result;
6492 }
6493 };
6494 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6495 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6496 let result = Err(failure(
6497 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6498 "created worktree identity did not match its durable lease",
6499 ));
6500 self.remember_managed_spawn_attempt(
6501 durable_v2_key,
6502 request,
6503 result.clone(),
6504 accepted_at,
6505 );
6506 return result;
6507 }
6508 {
6509 let mut registry = self.managed_worktrees.lock()
6510 .unwrap_or_else(|poisoned| poisoned.into_inner());
6511 let current = registry.get_mut(&lease.lease_id)
6512 .expect("allocated managed worktree lease remains present");
6513 current.expected_head = Some(created.head.clone());
6514 current.state = ManagedWorktreeLeaseState::Ready;
6515 current.updated_at_unix_ms = unix_time_ms();
6516 }
6517 if let Err(error) = self.persist_state() {
6518 self.mark_managed_recovery_required(
6519 &lease.lease_id,
6520 ManagedWorktreeCleanupFailure::Backend,
6521 );
6522 let result = Err(persistence_failure(error));
6523 self.remember_managed_spawn_attempt(
6524 durable_v2_key,
6525 request,
6526 result.clone(),
6527 accepted_at,
6528 );
6529 return result;
6530 }
6531 let ready = self.managed_worktrees.lock()
6532 .unwrap_or_else(|poisoned| poisoned.into_inner())
6533 .get(&lease.lease_id)
6534 .expect("ready managed worktree lease remains present")
6535 .snapshot();
6536 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6537 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6538 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6539 let result = Err(error);
6540 self.remember_managed_spawn_attempt(
6541 durable_v2_key,
6542 request,
6543 result.clone(),
6544 accepted_at,
6545 );
6546 return result;
6547 }
6548 let spawn = self.spawn_session_with_deadline(
6549 lease.workspace_id.clone(),
6550 resolved.provider.clone(),
6551 resolved.mode,
6552 resolved.terminal_size,
6553 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6554 environment_profile.clone(),
6555 bundle.clone(),
6556 context.clone(),
6557 Some(lease.lease_id.clone()),
6558 Some(runtime_policy),
6559 SpawnRecordPolicy::Always,
6560 Some(deadline),
6561 &required_capabilities,
6562 None,
6563 resolved.approval_level,
6564 resolved.network_allowlist.clone(),
6565 resolved.browser_profile_id.clone(),
6566 ).await;
6567 let (session, effective_runtime_policy) = match spawn {
6568 Ok(spawned) => spawned,
6569 Err(error) => {
6570 if self
6571 .cleanup_managed_worktree(&lease.lease_id, true)
6572 .await
6573 .is_err()
6574 {
6575 self.mark_managed_recovery_required(
6576 &lease.lease_id,
6577 ManagedWorktreeCleanupFailure::Backend,
6578 );
6579 }
6580 let result = Err(error);
6581 self.remember_managed_spawn_attempt(
6582 durable_v2_key,
6583 request,
6584 result.clone(),
6585 accepted_at,
6586 );
6587 return result;
6588 }
6589 };
6590 let (record_id, raw_inventory_record_id) = {
6591 let mut bindings = self.session_bindings.lock()
6592 .unwrap_or_else(|poisoned| poisoned.into_inner());
6593 let binding = bindings.get_mut(&session.session.instance_id)
6594 .expect("successful spawn retains its session binding");
6595 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6596 managed_spawn_record_ownership(binding, effective_runtime_policy)
6597 };
6598 let bound = self.managed_worktrees.lock()
6599 .unwrap_or_else(|poisoned| poisoned.into_inner())
6600 .bind_session(
6601 &lease.lease_id,
6602 ManagedWorktreeSessionHolder {
6603 incarnation_id: self.incarnation_id,
6604 instance_id: session.session.instance_id,
6605 generation: session.session.generation,
6606 },
6607 record_id,
6608 unix_time_ms(),
6609 )
6610 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6611 let snapshot = match bound {
6612 Ok(snapshot) => snapshot,
6613 Err(error) => {
6614 if raw_inventory_record_id
6615 .as_ref()
6616 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6617 {
6618 self.mark_managed_recovery_required(
6619 &lease.lease_id,
6620 ManagedWorktreeCleanupFailure::Backend,
6621 );
6622 }
6623 let _ = self.remove_session(&session).await;
6624 self.mark_managed_recovery_required(
6625 &lease.lease_id,
6626 ManagedWorktreeCleanupFailure::Backend,
6627 );
6628 let result = Err(error);
6629 self.remember_managed_spawn_attempt(
6630 durable_v2_key,
6631 request,
6632 result.clone(),
6633 accepted_at,
6634 );
6635 return result;
6636 }
6637 };
6638 if let Err(error) = self.persist_state() {
6639 if raw_inventory_record_id
6640 .as_ref()
6641 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6642 {
6643 self.mark_managed_recovery_required(
6644 &lease.lease_id,
6645 ManagedWorktreeCleanupFailure::Backend,
6646 );
6647 }
6648 let _ = self.remove_session(&session).await;
6649 if self
6650 .cleanup_managed_worktree(&lease.lease_id, true)
6651 .await
6652 .is_err()
6653 {
6654 self.mark_managed_recovery_required(
6655 &lease.lease_id,
6656 ManagedWorktreeCleanupFailure::Backend,
6657 );
6658 }
6659 let result = Err(persistence_failure(error));
6660 self.remember_managed_spawn_attempt(
6661 durable_v2_key,
6662 request,
6663 result.clone(),
6664 accepted_at,
6665 );
6666 return result;
6667 }
6668 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6669 let receipt = managed_spawn_receipt(
6670 &resolved,
6671 self.incarnation_id,
6672 session,
6673 snapshot,
6674 environment_profile,
6675 bundle,
6676 context,
6677 );
6678 let result = Ok(receipt);
6679 self.remember_managed_spawn_attempt(
6680 durable_v2_key,
6681 request,
6682 result.clone(),
6683 accepted_at,
6684 );
6685 result
6686 }
6687
6688 async fn spawn_managed_worktree_v2(
6689 &self,
6690 request: ManagedWorktreeSpawnRequestV2,
6691 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6692 if request.spawn_spec.target.node_id != self.node_id {
6693 return Err(failure(
6694 NodeFailureCode::SpawnTargetMismatch,
6695 "spawn target does not match this node",
6696 ));
6697 }
6698 if request.spawn_spec.target.worktree_id.is_some() {
6699 return Err(failure(
6700 NodeFailureCode::InvalidRequest,
6701 "managed worktree spawn must not select a caller-provided worktree",
6702 ));
6703 }
6704 let request_digest = managed_spawn_request_digest_v2(&request)?;
6705 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6706 match replay {
6707 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6708 DurableManagedSpawnReplayDecision::PendingLinked => {
6709 return Err(failure(
6710 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6711 "managed worktree V2 reservation has an incomplete durable allocation",
6712 ));
6713 }
6714 DurableManagedSpawnReplayDecision::Reserved => {}
6715 }
6716 let validated = (|| {
6717 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6718 let profile = self.managed_profile(
6719 &resolved.target.workspace_id,
6720 &request.worktree_profile_id,
6721 )?;
6722 if profile.revision() != &request.expected_profile_revision {
6723 return Err(failure(
6724 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6725 "managed worktree profile revision does not match the loaded profile",
6726 ));
6727 }
6728 Ok(())
6729 })();
6730 match validated {
6731 Ok(()) => {}
6732 Err(error) => {
6733 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6734 return Err(error);
6735 }
6736 }
6737 let legacy = ManagedWorktreeSpawnRequest {
6738 spawn_spec: request.spawn_spec.clone(),
6739 worktree_profile_id: request.worktree_profile_id.clone(),
6740 };
6741 match self
6742 .spawn_managed_worktree_inner(
6743 legacy,
6744 Some(&request.spawn_spec.idempotency_key),
6745 )
6746 .await
6747 {
6748 Ok(receipt) => {
6749 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6750 Ok(receipt)
6751 }
6752 Err(error) => {
6753 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6754 Err(error)
6755 }
6756 }
6757 }
6758
6759 fn managed_profile(
6760 &self,
6761 workspace_id: &WorkspaceId,
6762 profile_id: &WorktreeProfileId,
6763 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6764 if self.worktree_service_modes.get(workspace_id).copied()
6765 != Some(WorktreeServiceMode::Managed)
6766 {
6767 return Err(failure(
6768 NodeFailureCode::UnsupportedSpawnCapability,
6769 "workspace does not enable managed worktrees",
6770 ));
6771 }
6772 self.managed_worktree_profiles.get(workspace_id)
6773 .and_then(|profiles| profiles.get(profile_id))
6774 .cloned()
6775 .ok_or_else(|| failure(
6776 NodeFailureCode::InvalidRequest,
6777 "managed worktree profile is unavailable for this workspace",
6778 ))
6779 }
6780
6781 async fn reconcile_failed_allocation(
6782 &self,
6783 lease: &ManagedWorktreeLeaseRecord,
6784 source_root: &str,
6785 ) {
6786 match list_git_worktrees(source_root).await {
6787 Ok(worktrees) => {
6788 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6789 None => {
6790 match std::fs::symlink_metadata(&lease.target_root) {
6791 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6792 self.managed_worktrees.lock()
6793 .unwrap_or_else(|poisoned| poisoned.into_inner())
6794 .remove_unmutated(&lease.lease_id);
6795 let _ = self.persist_state();
6796 }
6797 _ => self.mark_managed_recovery_required(
6798 &lease.lease_id,
6799 ManagedWorktreeCleanupFailure::OwnershipConflict,
6800 ),
6801 }
6802 }
6803 Some(item) if exact_owned_worktree(
6804 lease,
6805 &item.path,
6806 item.branch.as_deref(),
6807 &item.head,
6808 ) => {
6809 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6810 }
6811 Some(_) => self.mark_managed_recovery_required(
6812 &lease.lease_id,
6813 ManagedWorktreeCleanupFailure::OwnershipConflict,
6814 ),
6815 }
6816 }
6817 Err(_) => self.mark_managed_recovery_required(
6818 &lease.lease_id,
6819 ManagedWorktreeCleanupFailure::Backend,
6820 ),
6821 }
6822 }
6823
6824 async fn reconcile_managed_worktrees(&self) {
6825 let leases = self.managed_worktrees.lock()
6826 .unwrap_or_else(|poisoned| poisoned.into_inner())
6827 .active_records()
6828 .cloned()
6829 .collect::<Vec<_>>();
6830 for lease in leases {
6831 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6832 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6833 _ => {
6834 self.mark_managed_recovery_required(
6835 &lease.lease_id,
6836 ManagedWorktreeCleanupFailure::OwnershipConflict,
6837 );
6838 continue;
6839 }
6840 };
6841 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6842 self.mark_managed_recovery_required(
6843 &lease.lease_id,
6844 ManagedWorktreeCleanupFailure::OwnershipConflict,
6845 );
6846 continue;
6847 }
6848 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6849 Ok(root) => root,
6850 Err(_) => {
6851 self.mark_managed_recovery_required(
6852 &lease.lease_id,
6853 ManagedWorktreeCleanupFailure::OwnershipConflict,
6854 );
6855 continue;
6856 }
6857 };
6858 let worktrees = match list_git_worktrees(&source_root).await {
6859 Ok(worktrees) => worktrees,
6860 Err(_) => {
6861 self.mark_managed_recovery_required(
6862 &lease.lease_id,
6863 ManagedWorktreeCleanupFailure::Backend,
6864 );
6865 continue;
6866 }
6867 };
6868 let listed = worktrees.iter()
6869 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6870 let Some(listed) = listed else {
6871 match std::fs::symlink_metadata(&lease.target_root) {
6872 Err(error) if error.kind() == io::ErrorKind::NotFound
6873 && lease.state == ManagedWorktreeLeaseState::Allocating
6874 && !lease.has_holders() => {
6875 let removed = self.managed_worktrees.lock()
6876 .unwrap_or_else(|poisoned| poisoned.into_inner())
6877 .tombstone(&lease.lease_id, unix_time_ms());
6878 if self.persist_state().is_ok() && removed.is_some() {
6879 self.publish(NodeEvent::ManagedWorktreeRemoved {
6880 lease_id: lease.lease_id.clone(),
6881 });
6882 }
6883 }
6884 _ => self.mark_managed_recovery_required(
6885 &lease.lease_id,
6886 ManagedWorktreeCleanupFailure::OwnershipConflict,
6887 ),
6888 }
6889 continue;
6890 };
6891 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6892 &lease,
6893 &listed.path,
6894 listed.branch.as_deref(),
6895 &listed.head,
6896 ) {
6897 self.mark_managed_recovery_required(
6898 &lease.lease_id,
6899 ManagedWorktreeCleanupFailure::OwnershipConflict,
6900 );
6901 continue;
6902 }
6903 if !self.workspaces.read()
6904 .unwrap_or_else(|poisoned| poisoned.into_inner())
6905 .contains_key(&lease.workspace_id)
6906 {
6907 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6908 self.mark_managed_recovery_required(
6909 &lease.lease_id,
6910 ManagedWorktreeCleanupFailure::Backend,
6911 );
6912 continue;
6913 }
6914 }
6915 let snapshot = {
6916 let mut registry = self.managed_worktrees.lock()
6917 .unwrap_or_else(|poisoned| poisoned.into_inner());
6918 let current = registry.get_mut(&lease.lease_id)
6919 .expect("reconciled managed lease remains present");
6920 current.expected_head = Some(listed.head.clone());
6921 current.cleanup_failure = None;
6922 current.state = if current.has_holders() {
6923 ManagedWorktreeLeaseState::InUse
6924 } else if current.retention == ManagedWorktreeRetention::Retain {
6925 ManagedWorktreeLeaseState::Retained
6926 } else {
6927 ManagedWorktreeLeaseState::Ready
6928 };
6929 current.updated_at_unix_ms = unix_time_ms();
6930 current.snapshot()
6931 };
6932 if self.persist_state().is_err() {
6933 self.mark_managed_recovery_required(
6934 &lease.lease_id,
6935 ManagedWorktreeCleanupFailure::Backend,
6936 );
6937 continue;
6938 }
6939 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6940 if snapshot.state == ManagedWorktreeLeaseState::Ready {
6941 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6942 }
6943 }
6944 }
6945
6946 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6966 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6967 .session_records
6968 .lock()
6969 .unwrap_or_else(|poisoned| poisoned.into_inner())
6970 .records
6971 .values()
6972 .filter(|record| {
6973 record.state == ManagedSessionState::Live
6974 && record.exported_context.is_none()
6975 && record.provider_session.is_some()
6976 })
6977 .filter_map(|record| {
6978 record
6979 .active_session
6980 .clone()
6981 .map(|session| (record.record_id.clone(), session))
6982 })
6983 .collect();
6984 for (record_id, session) in candidates {
6985 let shared = Arc::clone(self);
6986 tokio::spawn(async move {
6987 shared
6988 .reconcile_context_pack_export_for_record(&record_id, session)
6989 .await;
6990 });
6991 }
6992 }
6993
6994 async fn reconcile_context_pack_export_for_record(
7013 &self,
7014 record_id: &SessionRecordId,
7015 session: SessionAddress,
7016 ) {
7017 let Ok(record) = self.record(record_id) else {
7018 return;
7019 };
7020 if !matches!(
7021 record.state,
7022 ManagedSessionState::Live | ManagedSessionState::Dormant
7023 ) || record.exported_context.is_some()
7024 || record.provider_session.is_none()
7025 {
7026 return;
7027 }
7028 let Ok(receipt) = self
7035 .export_context_pack_for_session_record(&record.record_id, &session, true)
7036 .await
7037 else {
7038 return;
7039 };
7040 self.set_exported_context(&record.record_id, receipt);
7041 }
7042
7043 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7048 let transaction = self
7049 .state_transaction
7050 .lock()
7051 .unwrap_or_else(|poisoned| poisoned.into_inner());
7052 let (previous, updated) = {
7053 let mut records = self
7054 .session_records
7055 .lock()
7056 .unwrap_or_else(|poisoned| poisoned.into_inner());
7057 let Some(record) = records.records.get_mut(record_id) else {
7058 return;
7059 };
7060 if record.exported_context.is_some() {
7061 return;
7062 }
7063 let previous = record.clone();
7064 record.exported_context = Some(receipt);
7065 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7066 (previous, record.clone())
7067 };
7068 if self.persist_state_locked().is_err() {
7069 self.session_records
7070 .lock()
7071 .unwrap_or_else(|poisoned| poisoned.into_inner())
7072 .records
7073 .insert(record_id.clone(), previous);
7074 return;
7075 }
7076 drop(transaction);
7077 self.publish_record(updated);
7078 }
7079
7080 fn managed_record_export_target_for_instance(
7090 &self,
7091 instance_id: AgentInstanceId,
7092 generation: SessionGeneration,
7093 ) -> Option<(SessionRecordId, SessionAddress)> {
7094 let bindings = self
7095 .session_bindings
7096 .lock()
7097 .unwrap_or_else(|poisoned| poisoned.into_inner());
7098 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7099 let record_id = binding.record_id.clone()?;
7100 Some((
7101 record_id,
7102 SessionAddress {
7103 workspace_id: binding.workspace_id.clone(),
7104 session: SessionKey { instance_id, generation },
7105 },
7106 ))
7107 }
7108
7109 fn mark_managed_recovery_required(
7110 &self,
7111 lease_id: &ManagedWorktreeLeaseId,
7112 failure_kind: ManagedWorktreeCleanupFailure,
7113 ) {
7114 let snapshot = {
7115 let mut registry = self.managed_worktrees.lock()
7116 .unwrap_or_else(|poisoned| poisoned.into_inner());
7117 let Some(lease) = registry.get_mut(lease_id) else { return };
7118 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7119 lease.cleanup_failure = Some(failure_kind);
7120 lease.updated_at_unix_ms = unix_time_ms();
7121 lease.snapshot()
7122 };
7123 let _ = self.persist_state();
7124 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7125 }
7126
7127 fn set_persistence_error(&self, error: Option<String>) {
7128 *self
7129 .persistence_error
7130 .write()
7131 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7132 .map(|message| session_registry::sanitized_persistence_summary(&message));
7133 }
7134
7135 fn persist_state(&self) -> io::Result<()> {
7136 let _transaction = self
7137 .state_transaction
7138 .lock()
7139 .unwrap_or_else(|poisoned| poisoned.into_inner());
7140 self.persist_state_locked()
7141 }
7142
7143 fn persist_state_locked(&self) -> io::Result<()> {
7144 let workspaces = self
7145 .workspaces
7146 .read()
7147 .unwrap_or_else(|poisoned| poisoned.into_inner())
7148 .clone();
7149 let records = self
7150 .session_records
7151 .lock()
7152 .unwrap_or_else(|poisoned| poisoned.into_inner())
7153 .records
7154 .values()
7155 .cloned()
7156 .collect::<Vec<_>>();
7157 let mut managed = self.managed_worktrees
7158 .lock()
7159 .unwrap_or_else(|poisoned| poisoned.into_inner());
7160 managed.reattach_record_holders(
7161 &managed_worktree_record_holders(&records),
7162 unix_time_ms(),
7163 );
7164 let materializations = self
7165 .materializations
7166 .lock()
7167 .unwrap_or_else(|poisoned| poisoned.into_inner())
7168 .values()
7169 .cloned()
7170 .collect::<Vec<_>>();
7171 let managed_spawn_replays = self.managed_spawn_replays
7172 .lock()
7173 .unwrap_or_else(|poisoned| poisoned.into_inner())
7174 .values()
7175 .cloned()
7176 .collect::<Vec<_>>();
7177 let result = session_registry::save_v10(
7178 self.state_path.as_deref(),
7179 &self.node_id,
7180 &workspaces,
7181 &records,
7182 &managed.records(),
7183 &managed.tombstones(),
7184 &materializations,
7185 &managed_spawn_replays,
7186 );
7187 match &result {
7188 Ok(warning) => self.set_persistence_error(warning.clone()),
7189 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7190 }
7191 result.map(|_| ())
7192 }
7193
7194 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7195 let _mutation_guard = self.mutation_gate.lock().await;
7196 self.begin_shutdown_locked().await
7197 }
7198
7199 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7200 if self.shutdown.swap(true, Ordering::AcqRel) {
7201 return Ok(());
7202 }
7203 SessionLifecycleHook::on_shutdown(self);
7204 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7205 let mut first_error = None;
7206 for session in &self.handle.snapshot().sessions {
7207 let remaining = deadline.saturating_duration_since(Instant::now());
7208 if remaining.is_zero() {
7209 first_error.get_or_insert_with(|| {
7210 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7211 });
7212 break;
7213 }
7214 if let Err(error) = self
7215 .dispatch_bounded(
7216 ControlCommand::Stop {
7217 instance_id: session.instance_id,
7218 force: true,
7219 },
7220 remaining,
7221 )
7222 .await
7223 {
7224 first_error.get_or_insert(error);
7225 }
7226 }
7227 self.shutdown_notify.notify_waiters();
7228 match first_error {
7229 Some(error) => Err(error),
7230 None => Ok(()),
7231 }
7232 }
7233
7234 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7235 let bindings = self
7236 .session_bindings
7237 .lock()
7238 .unwrap_or_else(|poisoned| poisoned.into_inner());
7239 if bindings.len() >= CONTROL_SESSIONS_MAX {
7240 Err(failure(
7241 NodeFailureCode::BackendBusy,
7242 "node session-binding capacity is full; remove an existing session first",
7243 ))
7244 } else {
7245 Ok(())
7246 }
7247 }
7248
7249 fn ensure_workspace_environment_profile_compatible(
7256 &self,
7257 workspace_id: &WorkspaceId,
7258 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7259 ) -> Result<(), NodeFailure> {
7260 let bindings = self
7261 .session_bindings
7262 .lock()
7263 .unwrap_or_else(|poisoned| poisoned.into_inner());
7264 for binding in bindings.values() {
7265 if &binding.workspace_id != workspace_id {
7266 continue;
7267 }
7268 if binding.environment_profile.as_ref() != environment_profile {
7269 return Err(failure(
7270 NodeFailureCode::WorkspaceBusy,
7271 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7272 ));
7273 }
7274 }
7275 Ok(())
7276 }
7277
7278 #[cfg(test)]
7279 fn bind_session(
7280 &self,
7281 instance_id: AgentInstanceId,
7282 workspace_id: WorkspaceId,
7283 generation: SessionGeneration,
7284 ) {
7285 self.bind_session_with_policy(
7286 &SessionAddress {
7287 workspace_id,
7288 session: SessionKey { instance_id, generation },
7289 },
7290 ProviderRuntimePolicy::raw_pty(),
7291 None,
7292 );
7293 }
7294
7295 #[cfg(test)]
7296 fn bind_session_with_policy(
7297 &self,
7298 address: &SessionAddress,
7299 runtime_policy: ProviderRuntimePolicy,
7300 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7301 ) {
7302 self.bind_session_with_materialization(
7303 address,
7304 runtime_policy,
7305 environment_profile,
7306 None,
7307 None,
7308 None,
7309 );
7310 }
7311
7312 fn bind_session_with_materialization(
7313 &self,
7314 address: &SessionAddress,
7315 runtime_policy: ProviderRuntimePolicy,
7316 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7317 bundle: Option<ResolvedBundleReceipt>,
7318 context: Option<ResolvedContextPackReceipt>,
7319 materialization_id: Option<MaterializationId>,
7320 ) {
7321 let mut bindings = self
7322 .session_bindings
7323 .lock()
7324 .unwrap_or_else(|poisoned| poisoned.into_inner());
7325 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7326 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7327 bindings.insert(address.session.instance_id, SessionBinding {
7328 workspace_id: address.workspace_id.clone(),
7329 generation: address.session.generation,
7330 runtime_policy,
7331 pending_resume: None,
7332 record_id: None,
7333 managed_worktree_lease_id: None,
7334 environment_profile,
7335 bundle,
7336 context,
7337 materialization_id,
7338 });
7339 }
7340
7341 #[cfg(test)]
7342 fn bind_managed_session(
7343 &self,
7344 address: &SessionAddress,
7345 record_id: SessionRecordId,
7346 runtime_policy: ProviderRuntimePolicy,
7347 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7348 ) {
7349 self.bind_managed_session_with_materialization(
7350 address,
7351 record_id,
7352 runtime_policy,
7353 environment_profile,
7354 None,
7355 None,
7356 None,
7357 );
7358 }
7359
7360 fn bind_managed_session_with_materialization(
7361 &self,
7362 address: &SessionAddress,
7363 record_id: SessionRecordId,
7364 runtime_policy: ProviderRuntimePolicy,
7365 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7366 bundle: Option<ResolvedBundleReceipt>,
7367 context: Option<ResolvedContextPackReceipt>,
7368 materialization_id: Option<MaterializationId>,
7369 ) {
7370 let mut bindings = self
7371 .session_bindings
7372 .lock()
7373 .unwrap_or_else(|poisoned| poisoned.into_inner());
7374 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7375 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7376 bindings.insert(
7377 address.session.instance_id,
7378 SessionBinding {
7379 workspace_id: address.workspace_id.clone(),
7380 generation: address.session.generation,
7381 runtime_policy,
7382 pending_resume: None,
7383 record_id: Some(record_id),
7384 managed_worktree_lease_id: None,
7385 environment_profile,
7386 bundle,
7387 context,
7388 materialization_id,
7389 },
7390 );
7391 }
7392
7393 #[cfg(test)]
7394 fn bind_spawn_session(
7395 &self,
7396 address: &SessionAddress,
7397 provider: AgentId,
7398 mode: SessionMode,
7399 runtime_policy: ProviderRuntimePolicy,
7400 record_policy: SpawnRecordPolicy,
7401 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7402 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7403 self.bind_spawn_session_with_materialization(
7404 address,
7405 provider,
7406 mode,
7407 runtime_policy,
7408 record_policy,
7409 environment_profile,
7410 None,
7411 None,
7412 None,
7413 )
7414 }
7415
7416 fn bind_spawn_session_with_materialization(
7417 &self,
7418 address: &SessionAddress,
7419 provider: AgentId,
7420 mode: SessionMode,
7421 runtime_policy: ProviderRuntimePolicy,
7422 record_policy: SpawnRecordPolicy,
7423 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7424 bundle: Option<ResolvedBundleReceipt>,
7425 context: Option<ResolvedContextPackReceipt>,
7426 materialization_id: Option<MaterializationId>,
7427 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7428 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7429 && !runtime_policy.provider_session_identity
7430 {
7431 self.bind_session_with_materialization(
7432 address,
7433 runtime_policy,
7434 environment_profile,
7435 bundle,
7436 context,
7437 materialization_id,
7438 );
7439 return Ok(None);
7440 }
7441 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7442 let record = self.new_record(
7443 address,
7444 provider,
7445 mode,
7446 active_record_state(runtime_policy.provider_session_identity, false),
7447 None,
7448 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7449 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7450 record_owns_lifecycle.then(|| context.clone()).flatten(),
7451 )?;
7452 let record_id = record.record_id.clone();
7453 let transaction = self
7454 .state_transaction
7455 .lock()
7456 .unwrap_or_else(|poisoned| poisoned.into_inner());
7457 self.insert_record(record.clone())?;
7458 let previous_materialization = if record_owns_lifecycle {
7459 if let Some(materialization_id) = materialization_id.as_ref() {
7460 let mut materializations = self
7461 .materializations
7462 .lock()
7463 .unwrap_or_else(|poisoned| poisoned.into_inner());
7464 let Some(ownership) = materializations.get_mut(materialization_id) else {
7465 drop(materializations);
7466 self.remove_record_memory(&record_id);
7467 return Err(failure(
7468 NodeFailureCode::BackendOperationFailed,
7469 "session environment ownership disappeared before binding",
7470 ));
7471 };
7472 let previous = ownership.clone();
7473 if ownership
7474 .transfer_to_record(record_id.clone(), unix_time_ms())
7475 .is_err()
7476 {
7477 drop(materializations);
7478 self.remove_record_memory(&record_id);
7479 return Err(failure(
7480 NodeFailureCode::BackendOperationFailed,
7481 "session environment ownership transfer failed",
7482 ));
7483 }
7484 Some(previous)
7485 } else {
7486 None
7487 }
7488 } else {
7489 None
7490 };
7491 self.bind_managed_session_with_materialization(
7492 address,
7493 record_id.clone(),
7494 runtime_policy,
7495 environment_profile,
7496 bundle,
7497 context,
7498 materialization_id.clone(),
7499 );
7500 if let Err(error) = self.persist_state_locked() {
7501 self.remove_binding(address);
7502 self.remove_record_memory(&record_id);
7503 if let (Some(materialization_id), Some(previous)) =
7504 (materialization_id.as_ref(), previous_materialization)
7505 {
7506 self.materializations
7507 .lock()
7508 .unwrap_or_else(|poisoned| poisoned.into_inner())
7509 .insert(materialization_id.clone(), previous);
7510 }
7511 return Err(persistence_failure(error));
7512 }
7513 drop(transaction);
7514 self.publish_record(record);
7515 Ok(Some(record_id))
7516 }
7517
7518 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7519 for _ in 0..8 {
7520 let bytes = random_nonce().map_err(|error| {
7521 failure(
7522 NodeFailureCode::BackendOperationFailed,
7523 &format!("session record identity generation failed: {error}"),
7524 )
7525 })?;
7526 let mut value = String::with_capacity(27);
7527 value.push_str("sr-");
7528 for byte in &bytes[..12] {
7529 use std::fmt::Write as _;
7530 let _ = write!(&mut value, "{byte:02x}");
7531 }
7532 let record_id = SessionRecordId::new(value).map_err(|error| {
7533 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7534 })?;
7535 let records = self
7536 .session_records
7537 .lock()
7538 .unwrap_or_else(|poisoned| poisoned.into_inner());
7539 if !records.records.contains_key(&record_id) {
7540 return Ok(record_id);
7541 }
7542 }
7543 Err(failure(
7544 NodeFailureCode::BackendBusy,
7545 "could not allocate a unique session record ID",
7546 ))
7547 }
7548
7549 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7550 for _ in 0..8 {
7551 let bytes = random_nonce().map_err(|error| {
7552 failure(
7553 NodeFailureCode::BackendOperationFailed,
7554 &format!("task identity generation failed: {error}"),
7555 )
7556 })?;
7557 let mut nonce = [0_u8; 12];
7558 nonce.copy_from_slice(&bytes[..12]);
7559 let task_id = TaskId::from_nonce(nonce);
7560 let records = self
7561 .session_records
7562 .lock()
7563 .unwrap_or_else(|poisoned| poisoned.into_inner());
7564 if !records.records.values().any(|record| {
7565 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7566 == Some(&task_id)
7567 }) {
7568 return Ok(task_id);
7569 }
7570 }
7571 Err(failure(
7572 NodeFailureCode::BackendBusy,
7573 "could not allocate a unique task ID",
7574 ))
7575 }
7576
7577 fn default_record_name(&self, provider: &AgentId) -> String {
7578 let records = self
7579 .session_records
7580 .lock()
7581 .unwrap_or_else(|poisoned| poisoned.into_inner());
7582 let ordinal = records
7583 .records
7584 .values()
7585 .filter(|record| &record.provider == provider)
7586 .count()
7587 .saturating_add(1);
7588 format!("{} #{ordinal}", provider.as_str())
7589 }
7590
7591 fn new_record(
7592 &self,
7593 address: &SessionAddress,
7594 provider: AgentId,
7595 mode: SessionMode,
7596 state: ManagedSessionState,
7597 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7598 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7599 bundle: Option<ResolvedBundleReceipt>,
7600 context: Option<ResolvedContextPackReceipt>,
7601 ) -> Result<ManagedSessionRecord, NodeFailure> {
7602 let canonical_root = self.workspace_root(&address.workspace_id)?;
7603 let now = unix_time_ms();
7604 Ok(ManagedSessionRecord {
7605 record_id: self.allocate_record_id()?,
7606 display_name: self.default_record_name(&provider),
7607 provider,
7608 mode,
7609 state,
7610 workspace_id: address.workspace_id.clone(),
7611 canonical_root: opaque_windows_path(canonical_root),
7612 provider_session,
7613 active_session: Some(address.clone()),
7614 environment_profile,
7615 bundle,
7616 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7617 context,
7618 exported_context: None,
7619 task_binding: None,
7620 created_at_unix_ms: now,
7621 updated_at_unix_ms: now,
7622 last_error: None,
7623 })
7624 }
7625
7626 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7627 let mut records = self
7628 .session_records
7629 .lock()
7630 .unwrap_or_else(|poisoned| poisoned.into_inner());
7631 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7632 return Err(failure(
7633 NodeFailureCode::BackendBusy,
7634 "managed session record capacity is full; forget an inactive record first",
7635 ));
7636 }
7637 if let Some(identity) = record.provider_session.as_ref() {
7638 if records.records.values().any(|candidate| {
7639 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7640 session_registry::same_provider_session(
7641 &candidate.provider,
7642 candidate_identity,
7643 &record.provider,
7644 identity,
7645 )
7646 })
7647 }) {
7648 return Err(failure(
7649 NodeFailureCode::SessionRecordConflict,
7650 "provider session is already represented by another managed record",
7651 ));
7652 }
7653 }
7654 match records.records.entry(record.record_id.clone()) {
7655 std::collections::btree_map::Entry::Vacant(entry) => {
7656 entry.insert(record);
7657 }
7658 std::collections::btree_map::Entry::Occupied(_) => {
7659 return Err(failure(
7660 NodeFailureCode::SessionRecordConflict,
7661 "session record ID collision",
7662 ));
7663 }
7664 }
7665 Ok(())
7666 }
7667
7668 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7669 self.session_records
7670 .lock()
7671 .unwrap_or_else(|poisoned| poisoned.into_inner())
7672 .records
7673 .get(record_id)
7674 .cloned()
7675 .ok_or_else(|| {
7676 failure(
7677 NodeFailureCode::UnknownSessionRecord,
7678 "managed session record does not exist",
7679 )
7680 })
7681 }
7682
7683 fn revalidate_session_record_preview(
7684 &self,
7685 expected: &ManagedSessionRecord,
7686 identity: &ProviderSessionIdentity,
7687 ) -> Result<(), NodeFailure> {
7688 let current = self.record(&expected.record_id)?;
7689 let current_root = self.workspace_root(¤t.workspace_id)?;
7690 if current.provider != expected.provider
7691 || current.workspace_id != expected.workspace_id
7692 || current.canonical_root != expected.canonical_root
7693 || current.provider_session.as_ref() != Some(identity)
7694 || !platform::roots_equal(
7695 ¤t_root,
7696 windows_path_text(¤t.canonical_root),
7697 )
7698 {
7699 return Err(failure(
7700 NodeFailureCode::SessionRecordConflict,
7701 "managed session changed while its preview was loading",
7702 ));
7703 }
7704 Ok(())
7705 }
7706
7707 fn revalidate_session_record_context_export(
7708 &self,
7709 expected: &ManagedSessionRecord,
7710 identity: Option<&ProviderSessionIdentity>,
7711 session: &SessionAddress,
7712 allow_clean_detachment: bool,
7713 ) -> Result<(), NodeFailure> {
7714 let current = self.record(&expected.record_id)?;
7715 let current_root = self.workspace_root(¤t.workspace_id)?;
7716 let snapshot = self.internal_session_snapshot(session)?;
7717 let binding = self
7718 .session_bindings
7719 .lock()
7720 .unwrap_or_else(|poisoned| poisoned.into_inner())
7721 .get(&session.session.instance_id)
7722 .cloned()
7723 .ok_or_else(|| {
7724 failure(
7725 NodeFailureCode::SessionRecordConflict,
7726 "managed session runtime binding disappeared during context export",
7727 )
7728 })?;
7729 if !session_record_context_export_binding_is_exact(
7730 expected,
7731 ¤t,
7732 identity,
7733 session,
7734 &binding,
7735 &snapshot.agent_id,
7736 ¤t_root,
7737 allow_clean_detachment,
7738 ) || !session_record_context_export_source_is_usable(
7739 ¤t,
7740 &snapshot.status,
7741 allow_clean_detachment,
7742 ) {
7743 return Err(failure(
7744 NodeFailureCode::SessionRecordConflict,
7745 "managed session binding changed during context export",
7746 ));
7747 }
7748 Ok(())
7749 }
7750
7751 fn revalidate_loaded_history(
7752 &self,
7753 session: &SessionAddress,
7754 candidate_id: &str,
7755 loaded: &HistorySessionRecord,
7756 ) -> Result<(), NodeFailure> {
7757 let snapshot = self.internal_session_snapshot(session)?;
7758 if snapshot.history.pending.is_some()
7759 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7760 || snapshot.history.loaded.as_ref() != Some(loaded)
7761 {
7762 return Err(failure(
7763 NodeFailureCode::SessionRecordConflict,
7764 "managed session history changed during context export",
7765 ));
7766 }
7767 Ok(())
7768 }
7769
7770 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7771 self.session_records
7772 .lock()
7773 .unwrap_or_else(|poisoned| poisoned.into_inner())
7774 .records
7775 .remove(record_id)
7776 }
7777
7778 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7779 if self.destroy_record_materialization(record_id)? {
7780 self.publish(NodeEvent::SessionRecordRemoved {
7781 record_id: record_id.clone(),
7782 });
7783 return Ok(());
7784 }
7785 let transaction = self
7786 .state_transaction
7787 .lock()
7788 .unwrap_or_else(|poisoned| poisoned.into_inner());
7789 let Some(removed) = self.remove_record_memory(record_id) else {
7790 return Ok(());
7791 };
7792 if let Err(error) = self.persist_state_locked() {
7793 let _ = self.insert_record(removed);
7794 return Err(persistence_failure(error));
7795 }
7796 drop(transaction);
7797 self.publish(NodeEvent::SessionRecordRemoved {
7798 record_id: record_id.clone(),
7799 });
7800 Ok(())
7801 }
7802
7803 fn publish_record(&self, record: ManagedSessionRecord) {
7804 self.publish(NodeEvent::SessionRecordUpserted { record });
7805 }
7806
7807 fn mark_record_error(
7808 &self,
7809 record_id: &SessionRecordId,
7810 message: &str,
7811 ) -> Result<(), NodeFailure> {
7812 let transaction = self
7813 .state_transaction
7814 .lock()
7815 .unwrap_or_else(|poisoned| poisoned.into_inner());
7816 let previous = {
7817 let mut records = self
7818 .session_records
7819 .lock()
7820 .unwrap_or_else(|poisoned| poisoned.into_inner());
7821 let Some(record) = records.records.get_mut(record_id) else {
7822 return Ok(());
7823 };
7824 let previous = record.clone();
7825 record.state = ManagedSessionState::Unavailable;
7826 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7827 record.updated_at_unix_ms = unix_time_ms();
7828 previous
7829 };
7830 if let Err(error) = self.persist_state_locked() {
7831 self.session_records
7832 .lock()
7833 .unwrap_or_else(|poisoned| poisoned.into_inner())
7834 .records
7835 .insert(record_id.clone(), previous);
7836 return Err(persistence_failure(error));
7837 }
7838 let record = self.record(record_id)?;
7839 drop(transaction);
7840 self.publish_record(record);
7841 Ok(())
7842 }
7843
7844 fn rename_session_record(
7845 &self,
7846 record_id: &SessionRecordId,
7847 display_name: String,
7848 ) -> Result<ManagedSessionRecord, NodeFailure> {
7849 validate_display_name(&display_name)
7850 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7851 let _transaction = self
7852 .state_transaction
7853 .lock()
7854 .unwrap_or_else(|poisoned| poisoned.into_inner());
7855 let previous = {
7856 let mut records = self
7857 .session_records
7858 .lock()
7859 .unwrap_or_else(|poisoned| poisoned.into_inner());
7860 let record = records.records.get_mut(record_id).ok_or_else(|| {
7861 failure(
7862 NodeFailureCode::UnknownSessionRecord,
7863 "managed session record does not exist",
7864 )
7865 })?;
7866 let previous = record.clone();
7867 record.display_name = display_name;
7868 record.updated_at_unix_ms = unix_time_ms();
7869 previous
7870 };
7871 if let Err(error) = self.persist_state_locked() {
7872 self.session_records
7873 .lock()
7874 .unwrap_or_else(|poisoned| poisoned.into_inner())
7875 .records
7876 .insert(record_id.clone(), previous);
7877 return Err(persistence_failure(error));
7878 }
7879 let updated = self.record(record_id)?;
7880 self.publish_record(updated.clone());
7881 Ok(updated)
7882 }
7883
7884 fn set_session_task(
7885 &self,
7886 record_id: &SessionRecordId,
7887 expected_revision: u64,
7888 target: SessionTaskTargetV1,
7889 ) -> Result<ManagedSessionRecord, NodeFailure> {
7890 let transaction = self
7891 .state_transaction
7892 .lock()
7893 .unwrap_or_else(|poisoned| poisoned.into_inner());
7894 let current = self.record(record_id)?;
7895 let current_task_id = current
7896 .task_binding
7897 .as_ref()
7898 .and_then(|binding| binding.task_id.as_ref());
7899 let current_revision = current
7900 .task_binding
7901 .as_ref()
7902 .map_or(0, |binding| binding.revision);
7903 if expected_revision != current_revision {
7904 return Err(failure(
7905 NodeFailureCode::SessionRecordConflict,
7906 "managed session task binding revision changed",
7907 ));
7908 }
7909 let exact = match &target {
7910 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7911 SessionTaskTargetV1::Clear => current_task_id.is_none(),
7912 SessionTaskTargetV1::New => false,
7913 };
7914 if exact {
7915 return Ok(current);
7916 }
7917 let revision = current_revision.checked_add(1).ok_or_else(|| {
7918 failure(
7919 NodeFailureCode::SessionRecordConflict,
7920 "managed session task binding revision is exhausted",
7921 )
7922 })?;
7923 let task_id = match target {
7924 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7925 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7926 SessionTaskTargetV1::Clear => None,
7927 };
7928 let changed_at_unix_ms = unix_time_ms()
7929 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7930 failure(
7931 NodeFailureCode::SessionRecordConflict,
7932 "managed session record timestamp is exhausted",
7933 )
7934 })?);
7935 let updated = {
7936 let mut records = self
7937 .session_records
7938 .lock()
7939 .unwrap_or_else(|poisoned| poisoned.into_inner());
7940 let record = records.records.get_mut(record_id).ok_or_else(|| {
7941 failure(
7942 NodeFailureCode::UnknownSessionRecord,
7943 "managed session record does not exist",
7944 )
7945 })?;
7946 record.task_binding = Some(SessionTaskBindingV1 {
7947 revision,
7948 task_id,
7949 changed_at_unix_ms,
7950 });
7951 record.updated_at_unix_ms = changed_at_unix_ms;
7952 record.clone()
7953 };
7954 if let Err(error) = self.persist_state_locked() {
7955 self.session_records
7956 .lock()
7957 .unwrap_or_else(|poisoned| poisoned.into_inner())
7958 .records
7959 .insert(record_id.clone(), current);
7960 return Err(persistence_failure(error));
7961 }
7962 drop(transaction);
7963 self.publish_record(updated.clone());
7964 Ok(updated)
7965 }
7966
7967 fn index_provider_session(
7968 &self,
7969 workspace_id: WorkspaceId,
7970 provider: AgentId,
7971 identity: ProviderSessionIdentity,
7972 display_name: String,
7973 ) -> Result<ManagedSessionRecord, NodeFailure> {
7974 self.index_provider_session_with_policy(
7975 workspace_id,
7976 provider,
7977 identity,
7978 display_name,
7979 false,
7980 )
7981 }
7982
7983 fn index_provider_session_with_policy(
7984 &self,
7985 workspace_id: WorkspaceId,
7986 provider: AgentId,
7987 identity: ProviderSessionIdentity,
7988 display_name: String,
7989 allow_validated_transcript_path: bool,
7990 ) -> Result<ManagedSessionRecord, NodeFailure> {
7991 validate_display_name(&display_name)
7992 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7993 identity
7994 .validate()
7995 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7996 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7997 return Err(failure(
7998 NodeFailureCode::InvalidRequest,
7999 "provider session reference index v1 does not accept transcript paths",
8000 ));
8001 }
8002 let canonical_root = self.workspace_root(&workspace_id)?;
8003 if !self.enabled_providers.contains(&provider) {
8004 return Err(failure(
8005 NodeFailureCode::InvalidRequest,
8006 "provider is not enabled on this node",
8007 ));
8008 }
8009
8010 let transaction = self
8011 .state_transaction
8012 .lock()
8013 .unwrap_or_else(|poisoned| poisoned.into_inner());
8014 {
8015 let records = self
8016 .session_records
8017 .lock()
8018 .unwrap_or_else(|poisoned| poisoned.into_inner());
8019 if let Some(existing) = records.records.values().find(|candidate| {
8020 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8021 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8022 })
8023 }) {
8024 if existing.provider == provider && existing.workspace_id == workspace_id {
8025 return Ok(existing.clone());
8026 }
8027 return Err(failure(
8028 NodeFailureCode::SessionRecordConflict,
8029 "provider session reference conflicts with another provider or workspace",
8030 ));
8031 }
8032 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8033 return Err(failure(
8034 NodeFailureCode::BackendBusy,
8035 "managed session record capacity is full; forget an inactive record first",
8036 ));
8037 }
8038 }
8039
8040 let now = unix_time_ms();
8041 let record = ManagedSessionRecord {
8042 record_id: self.allocate_record_id()?,
8043 display_name,
8044 provider,
8045 mode: SessionMode::Pty,
8046 state: ManagedSessionState::Dormant,
8047 workspace_id,
8048 canonical_root: opaque_windows_path(canonical_root),
8049 provider_session: Some(identity),
8050 active_session: None,
8051 environment_profile: None,
8052 bundle: None,
8053 context_id: None,
8054 context: None,
8055 exported_context: None,
8056 task_binding: None,
8057 created_at_unix_ms: now,
8058 updated_at_unix_ms: now,
8059 last_error: None,
8060 };
8061 self.insert_record(record.clone())?;
8062 if let Err(error) = self.persist_state_locked() {
8063 self.remove_record_memory(&record.record_id);
8064 return Err(persistence_failure(error));
8065 }
8066 drop(transaction);
8067 self.publish_record(record.clone());
8068 Ok(record)
8069 }
8070
8071 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8072 self.session_bindings
8073 .lock()
8074 .unwrap_or_else(|poisoned| poisoned.into_inner())
8075 .iter()
8076 .find_map(|(instance_id, binding)| {
8077 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8078 workspace_id: binding.workspace_id.clone(),
8079 session: SessionKey {
8080 instance_id: *instance_id,
8081 generation: binding.generation,
8082 },
8083 })
8084 })
8085 }
8086
8087 async fn forget_session_record(
8088 &self,
8089 record_id: &SessionRecordId,
8090 ) -> Result<(), NodeFailure> {
8091 let record = self.record(record_id)?;
8092 if record.active_session.is_some()
8093 || matches!(
8094 record.state,
8095 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8096 )
8097 {
8098 return Err(failure(
8099 NodeFailureCode::SessionRecordBusy,
8100 "stop the managed session before forgetting it",
8101 ));
8102 }
8103 if let Some(address) = self.bound_address_for_record(record_id) {
8104 self.remove_session(&address).await?;
8105 }
8106 self.remove_managed_session_record(record_id, &record.workspace_id).await
8107 }
8108
8109 async fn remove_managed_session_record(
8124 &self,
8125 record_id: &SessionRecordId,
8126 workspace_id: &WorkspaceId,
8127 ) -> Result<(), NodeFailure> {
8128 let managed_lease_id = self.managed_worktrees.lock()
8129 .unwrap_or_else(|poisoned| poisoned.into_inner())
8130 .lease_for_workspace(workspace_id);
8131 if !self.destroy_record_materialization(record_id)? {
8132 let transaction = self
8133 .state_transaction
8134 .lock()
8135 .unwrap_or_else(|poisoned| poisoned.into_inner());
8136 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8137 failure(
8138 NodeFailureCode::UnknownSessionRecord,
8139 "managed session record does not exist",
8140 )
8141 })?;
8142 if let Err(error) = self.persist_state_locked() {
8143 let _ = self.insert_record(removed);
8144 return Err(persistence_failure(error));
8145 }
8146 drop(transaction);
8147 }
8148 self.publish(NodeEvent::SessionRecordRemoved {
8149 record_id: record_id.clone(),
8150 });
8151 if let Some(lease_id) = managed_lease_id {
8152 if let Some(snapshot) = self.managed_worktrees.lock()
8153 .unwrap_or_else(|poisoned| poisoned.into_inner())
8154 .get(&lease_id)
8155 .map(ManagedWorktreeLeaseRecord::snapshot)
8156 {
8157 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8158 }
8159 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8160 }
8161 Ok(())
8162 }
8163
8164 async fn retire_unavailable_session_records(&self) {
8177 let policy = self.session_record_retention;
8178 let age_enabled = policy.age_ms > 0;
8179 let keep_enabled = policy.keep_per_workspace > 0;
8180 if !age_enabled && !keep_enabled {
8181 return;
8182 }
8183 let now = unix_time_ms();
8184 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8185 self.session_records
8186 .lock()
8187 .unwrap_or_else(|poisoned| poisoned.into_inner())
8188 .records
8189 .values()
8190 .filter(|record| record.state == ManagedSessionState::Unavailable)
8191 .map(|record| {
8192 (
8193 record.record_id.clone(),
8194 record.workspace_id.clone(),
8195 record.provider.clone(),
8196 record.updated_at_unix_ms,
8197 )
8198 })
8199 .collect()
8200 };
8201 if candidates.is_empty() {
8202 return;
8203 }
8204 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8205 BTreeMap::new();
8206 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8207 by_workspace
8208 .entry(workspace_id)
8209 .or_default()
8210 .push((record_id, provider, updated_at_unix_ms));
8211 }
8212 let reason = if age_enabled && keep_enabled {
8213 "age+keep-n"
8214 } else if age_enabled {
8215 "age"
8216 } else {
8217 "keep-n"
8218 };
8219 let mut retired: u64 = 0;
8220 for (workspace_id, mut records) in by_workspace {
8221 records.sort_by(|left, right| right.2.cmp(&left.2));
8224 for (index, (record_id, provider, updated_at_unix_ms)) in
8225 records.into_iter().enumerate()
8226 {
8227 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8228 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8229 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8230 let should_retire = if age_enabled && keep_enabled {
8231 age_hit && keep_hit
8232 } else {
8233 age_hit || keep_hit
8234 };
8235 if !should_retire {
8236 continue;
8237 }
8238 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8239 Ok(()) => {
8240 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8241 retired += 1;
8242 tracing::info!(
8243 node_id = %self.node_id,
8244 record_id = %record_id,
8245 workspace_id = %workspace_id,
8246 provider = %provider,
8247 age_ms = record_age_ms,
8248 reason,
8249 "managed session record retired"
8250 );
8251 }
8252 Err(error) => tracing::warn!(
8253 node_id = %self.node_id,
8254 record_id = %record_id,
8255 workspace_id = %workspace_id,
8256 code = ?error.code,
8257 cause = %error.message,
8258 "session record retention sweep failed to retire a record"
8259 ),
8260 }
8261 }
8262 }
8263 if retired > 0 {
8264 let remaining_unavailable = self
8265 .session_records
8266 .lock()
8267 .unwrap_or_else(|poisoned| poisoned.into_inner())
8268 .records
8269 .values()
8270 .filter(|record| record.state == ManagedSessionState::Unavailable)
8271 .count();
8272 tracing::info!(
8273 node_id = %self.node_id,
8274 retired,
8275 remaining_unavailable,
8276 "session record retention sweep completed"
8277 );
8278 }
8279 }
8280
8281 async fn resume_session_record(
8282 &self,
8283 record_id: &SessionRecordId,
8284 terminal_size: gate4agent_types::TerminalSize,
8285 initial_prompt: Option<String>,
8286 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8287 let record = self.record(record_id)?;
8288 if record.active_session.is_some()
8289 || matches!(
8290 record.state,
8291 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8292 )
8293 {
8294 return Err(failure(
8295 NodeFailureCode::SessionRecordBusy,
8296 "managed session already has a live runtime binding",
8297 ));
8298 }
8299 let identity = record.provider_session.clone().ok_or_else(|| {
8300 failure(
8301 NodeFailureCode::SessionRecordNotResumable,
8302 "managed session has no verified provider session identity",
8303 )
8304 })?;
8305 let working_directory = self.workspace_root(&record.workspace_id)?;
8306 if !platform::roots_equal(
8307 &working_directory,
8308 windows_path_text(&record.canonical_root),
8309 ) {
8310 return Err(failure(
8311 NodeFailureCode::SessionRecordConflict,
8312 "managed session workspace root changed; refusing to resume in another directory",
8313 ));
8314 }
8315 {
8316 let records = self
8317 .session_records
8318 .lock()
8319 .unwrap_or_else(|poisoned| poisoned.into_inner());
8320 if records.records.values().any(|candidate| {
8321 candidate.record_id != *record_id
8322 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8323 session_registry::same_provider_session(
8324 &candidate.provider,
8325 candidate_identity,
8326 &record.provider,
8327 &identity,
8328 )
8329 })
8330 && (candidate.active_session.is_some()
8331 || matches!(
8332 candidate.state,
8333 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8334 ))
8335 }) {
8336 return Err(failure(
8337 NodeFailureCode::SessionRecordConflict,
8338 "provider session is already active through another managed record",
8339 ));
8340 }
8341 }
8342 let runtime_requirement = if initial_prompt.is_some() {
8343 ProviderRuntimeRequirement::ResumeWithPrompt
8344 } else {
8345 ProviderRuntimeRequirement::Resume
8346 };
8347 let runtime_policy = self.admit_provider_runtime(
8348 &record.provider,
8349 runtime_requirement,
8350 )
8351 .await?;
8352 let request = ResumeLaunchRequest {
8353 working_directory,
8354 terminal_size,
8355 initial_prompt,
8356 };
8357 request
8358 .validate()
8359 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8360 self.ensure_binding_capacity()?;
8361 let resolved_materialization = match self.resolve_record_materialization(
8362 record_id,
8363 &record.provider,
8364 record.mode,
8365 record.environment_profile.as_ref(),
8366 record.bundle.as_ref(),
8367 record.context.as_ref(),
8368 ) {
8369 Ok(materialization) => materialization,
8370 Err(error) => {
8371 let label = match error.code {
8372 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8373 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8374 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8375 _ => "environment-profile-unavailable",
8376 };
8377 self.mark_record_error(record_id, label)?;
8378 return Err(error);
8379 }
8380 };
8381 let (materialization_id, environment_overlay) = match resolved_materialization {
8382 Some((id, overlay)) => (Some(id), overlay),
8383 None => (None, None),
8384 };
8385 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8386 let mut environment_selection = match SpawnPreparer::select_environment(self,
8387 instance_id,
8388 &record.provider,
8389 record.mode,
8390 record.environment_profile.as_ref(),
8391 ) {
8392 Ok(selection) => selection,
8393 Err(error) => {
8394 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8395 return Err(error);
8396 }
8397 };
8398 let mut instance_overlay = environment_overlay
8399 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8400 .transpose()?
8401 .flatten();
8402 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8403 self.remove_session(&stale_address).await?;
8404 }
8405 let address = SessionAddress {
8406 workspace_id: record.workspace_id.clone(),
8407 session: SessionKey {
8408 instance_id,
8409 generation: SessionGeneration(1),
8410 },
8411 };
8412 let transaction = self
8413 .state_transaction
8414 .lock()
8415 .unwrap_or_else(|poisoned| poisoned.into_inner());
8416 let current = self.record(record_id)?;
8417 if current.active_session.is_some()
8418 || matches!(
8419 current.state,
8420 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8421 )
8422 {
8423 return Err(failure(
8424 NodeFailureCode::SessionRecordBusy,
8425 "managed session became active while resume was being prepared",
8426 ));
8427 }
8428 self.bind_managed_session_with_materialization(
8429 &address,
8430 record_id.clone(),
8431 runtime_policy,
8432 record.environment_profile.clone(),
8433 record.bundle.clone(),
8434 record.context.clone(),
8435 materialization_id,
8436 );
8437 if let Some(selection) = environment_selection.take() {
8438 selection.retain();
8439 }
8440 if let Some(overlay) = instance_overlay.take() {
8441 overlay.retain();
8442 }
8443 drop(transaction);
8444
8445 let transport = match record.mode {
8446 SessionMode::Pty => TransportKind::Pty,
8447 SessionMode::Inline => TransportKind::Pipe,
8448 SessionMode::Acp => TransportKind::Acp,
8449 };
8450 let agent_id = record.provider.clone();
8451 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8452 if let Err(error) = self
8453 .dispatch_bounded(
8454 ControlCommand::Register {
8455 instance_id,
8456 agent_id,
8457 transport,
8458 },
8459 dispatch_timeout,
8460 )
8461 .await
8462 {
8463 if let Some(binding) = self.remove_binding(&address) {
8464 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8465 }
8466 return Err(error);
8467 }
8468 let command = self.prepare_command(ControlCommand::Resume {
8469 instance_id,
8470 target: ResumeTarget::ProviderSession { identity },
8471 runtime_policy,
8472 request,
8473 });
8474 let started_after = self.current_sequence();
8475 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8476 self.rollback_spawn(&address, dispatch_timeout).await?;
8477 return Err(error);
8478 }
8479 if let Err(error) = self.dispatch_envelope(command) {
8480 self.clear_armed_resume(&address);
8481 self.rollback_spawn(&address, dispatch_timeout).await?;
8482 return Err(error);
8483 }
8484 match self
8485 .wait_for_record_resume(&address, started_after)
8486 .await
8487 {
8488 Ok(settled_address) => {
8489 let managed_lease_id = {
8490 self.managed_worktrees.lock()
8491 .unwrap_or_else(|poisoned| poisoned.into_inner())
8492 .lease_for_workspace(&record.workspace_id)
8493 };
8494 if let Some(lease_id) = managed_lease_id {
8495 {
8496 let mut bindings = self.session_bindings.lock()
8497 .unwrap_or_else(|poisoned| poisoned.into_inner());
8498 let binding = bindings.get_mut(&settled_address.session.instance_id)
8499 .ok_or_else(|| failure(
8500 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8501 "resumed managed session lost its exact binding",
8502 ))?;
8503 if binding.generation != settled_address.session.generation {
8504 return Err(failure(
8505 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8506 "resumed managed session generation diverged",
8507 ));
8508 }
8509 binding.managed_worktree_lease_id = Some(lease_id.clone());
8510 }
8511 let snapshot = self.managed_worktrees.lock()
8512 .unwrap_or_else(|poisoned| poisoned.into_inner())
8513 .bind_session(
8514 &lease_id,
8515 ManagedWorktreeSessionHolder {
8516 incarnation_id: self.incarnation_id,
8517 instance_id: settled_address.session.instance_id,
8518 generation: settled_address.session.generation,
8519 },
8520 Some(record_id.clone()),
8521 unix_time_ms(),
8522 ).map_err(|message| failure(
8523 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8524 &message,
8525 ))?;
8526 if let Err(error) = self.persist_state() {
8527 let _ = self.remove_session(&settled_address).await;
8528 return Err(persistence_failure(error));
8529 }
8530 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8531 }
8532 Ok((self.record(record_id)?, settled_address))
8533 }
8534 Err(error) => {
8535 if matches!(
8536 error.code,
8537 NodeFailureCode::SessionRecordNotResumable
8538 | NodeFailureCode::BackendOperationFailed
8539 ) {
8540 if let Some(bound) = self.bound_address_for_record(record_id) {
8541 let _ = self.remove_session(&bound).await;
8542 }
8543 }
8544 Err(error)
8545 }
8546 }
8547 }
8548
8549 async fn wait_for_record_resume(
8550 &self,
8551 initial_address: &SessionAddress,
8552 after_sequence: u64,
8553 ) -> Result<SessionAddress, NodeFailure> {
8554 let deadline = Instant::now()
8555 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8556 let mut scan_after = after_sequence;
8557 loop {
8558 let events = self
8559 .history
8560 .lock()
8561 .unwrap_or_else(|poisoned| poisoned.into_inner())
8562 .events
8563 .iter()
8564 .filter(|event| event.sequence > scan_after)
8565 .cloned()
8566 .collect::<Vec<_>>();
8567 for envelope in events {
8568 scan_after = scan_after.max(envelope.sequence);
8569 let NodeEvent::Control { address, event } = envelope.event else {
8570 continue;
8571 };
8572 if address.session.instance_id != initial_address.session.instance_id {
8573 continue;
8574 }
8575 match event.event {
8576 ControlEventKind::Resumed { .. } => return Ok(address),
8577 ControlEventKind::ResumeDenied { reason } => {
8578 return Err(failure(
8579 NodeFailureCode::SessionRecordNotResumable,
8580 &reason,
8581 ));
8582 }
8583 ControlEventKind::ResumeFailed { message }
8584 | ControlEventKind::CommandRejected { message } => {
8585 return Err(failure(
8586 NodeFailureCode::BackendOperationFailed,
8587 &message,
8588 ));
8589 }
8590 _ => {}
8591 }
8592 }
8593 if Instant::now() >= deadline {
8594 return Err(failure(
8595 NodeFailureCode::BackendBusy,
8596 "managed resume did not settle before the bounded deadline",
8597 ));
8598 }
8599 sleep(Duration::from_millis(5)).await;
8600 }
8601 }
8602
8603 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8604 self.workspaces
8605 .read()
8606 .unwrap_or_else(|poisoned| poisoned.into_inner())
8607 .get(workspace_id)
8608 .cloned()
8609 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8610 }
8611
8612 fn workspace_roots(&self) -> Vec<PathBuf> {
8613 self.workspaces
8614 .read()
8615 .unwrap_or_else(|poisoned| poisoned.into_inner())
8616 .values()
8617 .map(PathBuf::from)
8618 .collect()
8619 }
8620
8621 async fn inspect_workspace(
8622 &self,
8623 workspace_id: WorkspaceId,
8624 ) -> Result<WorkspaceInspection, NodeFailure> {
8625 let _permit = self
8626 .inspection_slots
8627 .clone()
8628 .try_acquire_owned()
8629 .map_err(|_| {
8630 tracing::warn!(
8631 workspace_id = %workspace_id,
8632 "workspace inspection rejected: inspection capacity is busy",
8633 );
8634 failure(
8635 NodeFailureCode::BackendBusy,
8636 "workspace inspection capacity is busy",
8637 )
8638 })?;
8639 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8640 tracing::warn!(
8641 workspace_id = %workspace_id,
8642 code = ?error.code,
8643 "workspace inspection rejected: workspace is not registered",
8644 );
8645 error
8646 })?;
8647 let tree_root = canonical_root.clone();
8648 let started_at = Instant::now();
8649 let time_budget_ms = workspace_inspection_time_budget_ms();
8650 let entry_cap = workspace_inspection_entry_cap();
8651 let deadline = started_at + Duration::from_millis(time_budget_ms);
8652 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8653 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8654 })
8655 .await
8656 .map_err(|error| {
8657 tracing::warn!(
8658 workspace_id = %workspace_id,
8659 error = %error,
8660 "workspace inspection rejected: walk task failed",
8661 );
8662 failure(
8663 NodeFailureCode::BackendOperationFailed,
8664 &format!("workspace inspection task failed: {error}"),
8665 )
8666 })?;
8667 let (mut git, git_time_budget_exceeded) =
8668 inspect_git_workspace(&canonical_root, deadline).await;
8669 if !git.worktrees.is_empty() {
8670 let registered = self
8671 .workspaces
8672 .read()
8673 .unwrap_or_else(|poisoned| poisoned.into_inner())
8674 .iter()
8675 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8676 .collect::<Vec<_>>();
8677 for worktree in &mut git.worktrees {
8678 worktree.workspace_id = registered
8679 .iter()
8680 .find(|(_, root)| {
8681 worktree_paths_equal(root, windows_path_text(&worktree.path))
8682 })
8683 .map(|(workspace_id, _)| workspace_id.clone());
8684 }
8685 }
8686 if git.is_repository {
8687 git.managed_worktree = self
8688 .managed_worktrees
8689 .lock()
8690 .unwrap_or_else(|poisoned| poisoned.into_inner())
8691 .git_scope(&workspace_id, git.branch.as_deref());
8692 }
8693 let truncation = workspace_inspection_truncation(
8694 &walk_budget,
8695 git_time_budget_exceeded,
8696 started_at.elapsed(),
8697 );
8698 if let Some(truncation) = &truncation {
8699 tracing::info!(
8700 workspace_id = %workspace_id,
8701 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8702 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8703 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8704 entries_visited = truncation.entries_visited,
8705 elapsed_ms = truncation.elapsed_ms,
8706 "workspace inspection truncated by its inner budget",
8707 );
8708 }
8709 Ok(WorkspaceInspection {
8710 workspace_id,
8711 entries,
8712 tree_truncated,
8713 git,
8714 truncation,
8715 })
8716 }
8717
8718 async fn browse_host_directories(
8719 &self,
8720 directory: Option<OpaqueHostPath>,
8721 after: Option<OpaqueHostPath>,
8722 ) -> Result<HostDirectoryListing, NodeFailure> {
8723 let permit = self
8724 .inspection_slots
8725 .clone()
8726 .try_acquire_owned()
8727 .map_err(|_| {
8728 failure(
8729 NodeFailureCode::BackendBusy,
8730 "host directory browse capacity is busy",
8731 )
8732 })?;
8733 let task = tokio::task::spawn_blocking(move || {
8734 let _permit = permit;
8735 browse_host_directories(directory, after)
8736 });
8737 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8738 .await
8739 .map_err(|_| {
8740 failure(
8741 NodeFailureCode::HostDirectoryReadTimedOut,
8742 "host directory browse exceeded its bounded deadline",
8743 )
8744 })?
8745 .map_err(|_| {
8746 failure(
8747 NodeFailureCode::HostDirectoryReadFailed,
8748 "host directory browse task failed",
8749 )
8750 })?
8751 .map_err(host_directory_failure)
8752 }
8753
8754 async fn read_workspace_file(
8755 &self,
8756 workspace_id: WorkspaceId,
8757 path: RepositoryPath,
8758 ) -> Result<WorkspaceFileRead, NodeFailure> {
8759 let canonical_root = self.workspace_root(&workspace_id)?;
8760 let permit = self
8761 .inspection_slots
8762 .clone()
8763 .try_acquire_owned()
8764 .map_err(|_| {
8765 failure(
8766 NodeFailureCode::BackendBusy,
8767 "workspace file read capacity is busy",
8768 )
8769 })?;
8770 let path_to_read = path.clone();
8771 let task = tokio::task::spawn_blocking(move || {
8772 let _permit = permit;
8773 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8774 });
8775 let content = timeout(
8776 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8777 task,
8778 )
8779 .await
8780 .map_err(|_| {
8781 failure(
8782 NodeFailureCode::RepositoryFileReadTimedOut,
8783 "repository file read exceeded its bounded deadline",
8784 )
8785 })?
8786 .map_err(|_| {
8787 failure(
8788 NodeFailureCode::RepositoryFileReadFailed,
8789 "repository file read task failed",
8790 )
8791 })?
8792 .map_err(workspace_file_failure)?;
8793 let content = match content {
8794 WorkspaceFileBytes::Utf8(text) => {
8795 let revision = Some(workspace_file_revision(text.as_bytes()));
8796 return Ok(WorkspaceFileRead {
8797 workspace_id,
8798 path,
8799 content: WorkspaceFileContent::Utf8 {
8800 byte_len: u32::try_from(text.len())
8801 .expect("bounded workspace text length must fit u32"),
8802 text,
8803 },
8804 revision,
8805 });
8806 }
8807 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8808 WorkspaceFileContent::NonUtf8 {
8809 byte_len: u32::try_from(byte_length)
8810 .expect("bounded workspace file length must fit u32"),
8811 }
8812 }
8813 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8814 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8815 },
8816 };
8817 Ok(WorkspaceFileRead {
8818 workspace_id,
8819 path,
8820 content,
8821 revision: None,
8822 })
8823 }
8824
8825 async fn write_workspace_file(
8826 &self,
8827 workspace_id: WorkspaceId,
8828 path: RepositoryPath,
8829 expected_revision: WorkspaceFileRevision,
8830 text: String,
8831 ) -> Result<WorkspaceFileRead, NodeFailure> {
8832 let canonical_root = self.workspace_root(&workspace_id)?;
8833 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8834 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8835 })?;
8836 let path_to_write = path.clone();
8837 let expected = expected_revision.as_str().to_owned();
8838 let text_to_write = text.clone();
8839 let task = tokio::task::spawn_blocking(move || {
8840 let _permit = permit;
8841 write_workspace_file_to_disk(
8842 Path::new(&canonical_root),
8843 &path_to_write,
8844 &expected,
8845 &text_to_write,
8846 )
8847 });
8848 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8849 .await
8850 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8851 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8852 .map_err(workspace_file_write_failure)?;
8853 Ok(WorkspaceFileRead {
8854 workspace_id,
8855 path,
8856 content: WorkspaceFileContent::Utf8 {
8857 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8858 text,
8859 },
8860 revision: Some(WorkspaceFileRevision::new(revision)
8861 .expect("workspace writer returns a valid SHA-256 revision")),
8862 })
8863 }
8864
8865 async fn create_workspace_file(
8866 &self,
8867 workspace_id: WorkspaceId,
8868 path: RepositoryPath,
8869 ) -> Result<WorkspaceFileRead, NodeFailure> {
8870 let canonical_root = self.workspace_root(&workspace_id)?;
8871 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8872 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8873 })?;
8874 let path_to_create = path.clone();
8875 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8876 let task_commit_state = Arc::clone(&commit_state);
8877 let task = tokio::task::spawn_blocking(move || {
8878 let _permit = permit;
8879 create_workspace_file_on_disk(
8880 Path::new(&canonical_root),
8881 &path_to_create,
8882 &task_commit_state,
8883 )
8884 });
8885 let revision = settle_workspace_entry_create(
8886 task,
8887 commit_state,
8888 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8889 "repository file create exceeded its bounded deadline",
8890 "repository file create task failed",
8891 )
8892 .await?;
8893 Ok(WorkspaceFileRead {
8894 workspace_id,
8895 path,
8896 content: WorkspaceFileContent::Utf8 {
8897 text: String::new(),
8898 byte_len: 0,
8899 },
8900 revision: Some(WorkspaceFileRevision::new(revision)
8901 .expect("workspace creator returns a valid SHA-256 revision")),
8902 })
8903 }
8904
8905 async fn create_workspace_directory(
8906 &self,
8907 workspace_id: WorkspaceId,
8908 path: RepositoryPath,
8909 ) -> Result<WorkspaceEntry, NodeFailure> {
8910 let canonical_root = self.workspace_root(&workspace_id)?;
8911 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8912 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8913 })?;
8914 let path_to_create = path.clone();
8915 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8916 let task_commit_state = Arc::clone(&commit_state);
8917 let task = tokio::task::spawn_blocking(move || {
8918 let _permit = permit;
8919 create_workspace_directory_on_disk(
8920 Path::new(&canonical_root),
8921 &path_to_create,
8922 &task_commit_state,
8923 )
8924 });
8925 settle_workspace_entry_create(
8926 task,
8927 commit_state,
8928 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8929 "repository directory create exceeded its bounded deadline",
8930 "repository directory create task failed",
8931 )
8932 .await?;
8933 Ok(WorkspaceEntry {
8934 relative_path: path,
8935 kind: WorkspaceEntryKind::Directory,
8936 })
8937 }
8938
8939 async fn read_git_history(
8940 &self,
8941 workspace_id: WorkspaceId,
8942 path: Option<RepositoryPath>,
8943 before: Option<GitObjectId>,
8944 limit: u16,
8945 ) -> Result<GitHistoryPage, NodeFailure> {
8946 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8947 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8948 })?;
8949 let root = self.workspace_root(&workspace_id)?;
8950 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8951 }
8952
8953 async fn read_git_diff(
8954 &self,
8955 workspace_id: WorkspaceId,
8956 request: GitDiffRequest,
8957 ) -> Result<GitDiff, NodeFailure> {
8958 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8959 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8960 })?;
8961 let root = self.workspace_root(&workspace_id)?;
8962 read_git_diff_bounded(&root, request).await
8963 }
8964
8965 fn native_session_route_root(
8966 &self,
8967 route: &NativeSessionCatalogRoute,
8968 ) -> Result<Option<String>, NodeFailure> {
8969 route
8970 .validate()
8971 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8972 match route.scope {
8973 gate4agent_types::NativeSessionCatalogScope::Workspace => route
8974 .workspace_id
8975 .as_ref()
8976 .map(|workspace_id| self.workspace_root(workspace_id))
8977 .transpose(),
8978 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8979 }
8980 }
8981
8982 fn project_native_session_entries(
8983 &self,
8984 route: &NativeSessionCatalogRoute,
8985 entries: Vec<ScopedNativeSessionCatalogEntry>,
8986 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8987 let current_root = route
8988 .workspace_id
8989 .as_ref()
8990 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8991 let records = self
8992 .session_records
8993 .lock()
8994 .unwrap_or_else(|poisoned| poisoned.into_inner());
8995 let mut seen_records = std::collections::HashSet::new();
8996 entries
8997 .into_iter()
8998 .map(|entry| {
8999 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9000 let matches = records
9001 .records
9002 .values()
9003 .filter(|record| {
9004 record.provider == route.provider
9005 && record.workspace_id == *workspace_id
9006 && current_root.as_deref().is_some_and(|root| {
9007 platform::roots_equal(
9008 root,
9009 windows_path_text(&record.canonical_root),
9010 )
9011 })
9012 && record.provider_session.as_ref().is_some_and(|identity| {
9013 session_registry::same_provider_session(
9014 &record.provider,
9015 identity,
9016 &route.provider,
9017 &entry.provider_session,
9018 )
9019 })
9020 })
9021 .map(|record| record.record_id.clone())
9022 .collect::<Vec<_>>();
9023 (matches.len() == 1
9024 && seen_records.insert(matches[0].clone()))
9025 .then(|| matches[0].clone())
9026 });
9027 let metadata = entry.metadata;
9028 crate::protocol::NativeSessionCatalogEntry {
9029 selection_id: metadata.selection_id,
9030 title: metadata.title,
9031 modified_at_unix_ms: metadata.modified_at_unix_ms,
9032 model: metadata.model,
9033 message_count: metadata.message_count,
9034 completed_turn_count: metadata.completed_turn_count,
9035 external_group: entry.external_group,
9036 record_id,
9037 }
9038 })
9039 .collect()
9040 }
9041
9042 async fn catalog_native_sessions(
9043 &self,
9044 route: NativeSessionCatalogRoute,
9045 limit: u16,
9046 ) -> Result<(
9047 Vec<crate::protocol::NativeSessionCatalogEntry>,
9048 crate::protocol::NativeSessionCatalogSummary,
9049 ), NodeFailure> {
9050 let canonical_root = self.native_session_route_root(&route)?;
9051 let registered_workspace_roots = self.workspace_roots();
9052 let provider = route.provider.clone();
9053 let scope = route.scope;
9054 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9055 failure(
9056 NodeFailureCode::InvalidRequest,
9057 "native session catalog is not configured",
9058 )
9059 })?;
9060 let _permit = Arc::clone(&self.inspection_slots)
9061 .try_acquire_owned()
9062 .map_err(|_| {
9063 failure(
9064 NodeFailureCode::BackendBusy,
9065 "native session catalog capacity is busy",
9066 )
9067 })?;
9068 let page = run_native_session_catalog_operation(
9069 catalog,
9070 "native session catalog",
9071 move |catalog| {
9072 catalog.catalog_initial_for_scope(
9073 &provider,
9074 scope,
9075 canonical_root.as_deref().map(Path::new),
9076 ®istered_workspace_roots,
9077 limit,
9078 )
9079 },
9080 )
9081 .await?;
9082 let summary = crate::protocol::NativeSessionCatalogSummary {
9083 catalog_revision: page.revision,
9084 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9085 recent_total_count: page.recent_total_count,
9086 older_total_count: page.older_total_count,
9087 recent_next_after_selection_id: page.next_after_selection_id,
9088 recent_has_more: page.remaining_count > 0,
9089 };
9090 Ok((self.project_native_session_entries(&route, page.entries), summary))
9091 }
9092
9093 async fn page_native_sessions(
9094 &self,
9095 route: NativeSessionCatalogRoute,
9096 window: crate::protocol::NativeSessionCatalogWindow,
9097 catalog_revision: u64,
9098 recent_cutoff_unix_ms: u64,
9099 after_selection_id: Option<String>,
9100 limit: u16,
9101 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9102 let canonical_root = self.native_session_route_root(&route)?;
9103 let registered_workspace_roots = self.workspace_roots();
9104 let provider = route.provider.clone();
9105 let scope = route.scope;
9106 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9107 failure(
9108 NodeFailureCode::InvalidRequest,
9109 "native session catalog is not configured",
9110 )
9111 })?;
9112 let _permit = Arc::clone(&self.inspection_slots)
9113 .try_acquire_owned()
9114 .map_err(|_| {
9115 failure(
9116 NodeFailureCode::BackendBusy,
9117 "native session catalog capacity is busy",
9118 )
9119 })?;
9120 let page = run_native_session_catalog_operation(
9121 catalog,
9122 "native session catalog page",
9123 move |catalog| {
9124 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9125 &provider,
9126 scope,
9127 canonical_root.as_deref().map(Path::new),
9128 ®istered_workspace_roots,
9129 window,
9130 catalog_revision,
9131 recent_cutoff_unix_ms,
9132 after_selection_id.as_deref(),
9133 limit,
9134 ))
9135 },
9136 )
9137 .await?;
9138 let page = page.map_err(|error| match error {
9139 NativeSessionCatalogError::StaleCatalog => failure(
9140 NodeFailureCode::StaleNativeSessionCatalog,
9141 "native session catalog revision or cursor is stale",
9142 ),
9143 _ => failure(
9144 NodeFailureCode::BackendOperationFailed,
9145 "native session catalog page failed",
9146 ),
9147 })?;
9148 let entries = self.project_native_session_entries(&route, page.entries);
9149 Ok(crate::protocol::NativeSessionCatalogPage {
9150 window: page.window,
9151 revision: page.revision,
9152 entries,
9153 next_after_selection_id: page.next_after_selection_id,
9154 remaining_count: page.remaining_count,
9155 has_more: page.remaining_count > 0,
9156 })
9157 }
9158
9159 async fn preview_native_session(
9160 &self,
9161 selection: NativeSessionSelection,
9162 message_limit: u16,
9163 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9164 selection
9165 .validate()
9166 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9167 let canonical_root = self.native_session_route_root(&selection.route)?;
9168 let registered_workspace_roots = self.workspace_roots();
9169 let provider = selection.route.provider.clone();
9170 let scope = selection.route.scope;
9171 let catalog_revision = selection.catalog_revision;
9172 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9173 let selection_id = selection.selection_id.clone();
9174 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9175 failure(
9176 NodeFailureCode::InvalidRequest,
9177 "native session preview is not configured",
9178 )
9179 })?;
9180 let _permit = Arc::clone(&self.inspection_slots)
9181 .try_acquire_owned()
9182 .map_err(|_| {
9183 failure(
9184 NodeFailureCode::BackendBusy,
9185 "native session preview capacity is busy",
9186 )
9187 })?;
9188 let preview = run_native_session_catalog_operation(
9189 catalog,
9190 "native session preview",
9191 move |catalog| {
9192 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9193 &provider,
9194 scope,
9195 canonical_root.as_deref().map(Path::new),
9196 ®istered_workspace_roots,
9197 catalog_revision,
9198 recent_cutoff_unix_ms,
9199 &selection_id,
9200 message_limit,
9201 ))
9202 },
9203 )
9204 .await?;
9205 preview
9206 .map(Into::into)
9207 .map_err(native_session_preview_failure)
9208 }
9209
9210 async fn index_native_session(
9211 &self,
9212 selection: NativeSessionSelection,
9213 display_name: String,
9214 ) -> Result<ManagedSessionRecord, NodeFailure> {
9215 selection
9216 .validate()
9217 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9218 if selection.route.scope
9219 != gate4agent_types::NativeSessionCatalogScope::Workspace
9220 {
9221 return Err(failure(
9222 NodeFailureCode::WorkspaceRegistrationRequired,
9223 "register the external project as a workspace before indexing or resuming it",
9224 ));
9225 }
9226 let workspace_id = selection
9227 .route
9228 .workspace_id
9229 .clone()
9230 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9231 let canonical_root = self
9232 .native_session_route_root(&selection.route)?
9233 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9234 let registered_workspace_roots = self.workspace_roots();
9235 let provider = selection.route.provider.clone();
9236 let catalog_revision = selection.catalog_revision;
9237 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9238 let selection_id = selection.selection_id.clone();
9239 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9240 failure(
9241 NodeFailureCode::InvalidRequest,
9242 "native session index is not configured",
9243 )
9244 })?;
9245 let _permit = Arc::clone(&self.inspection_slots)
9246 .try_acquire_owned()
9247 .map_err(|_| {
9248 failure(
9249 NodeFailureCode::BackendBusy,
9250 "native session index capacity is busy",
9251 )
9252 })?;
9253 let resolved = run_native_session_catalog_operation(
9254 catalog,
9255 "native session index resolution",
9256 move |catalog| {
9257 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9258 &provider,
9259 gate4agent_types::NativeSessionCatalogScope::Workspace,
9260 Some(Path::new(&canonical_root)),
9261 ®istered_workspace_roots,
9262 catalog_revision,
9263 recent_cutoff_unix_ms,
9264 &selection_id,
9265 ))
9266 },
9267 )
9268 .await?
9269 .map_err(native_session_preview_failure)?;
9270 self.index_provider_session_with_policy(
9271 workspace_id,
9272 selection.route.provider,
9273 resolved.identity,
9274 display_name,
9275 true,
9276 )
9277 }
9278
9279 async fn preview_native_session_by_session_id(
9280 &self,
9281 workspace_id: WorkspaceId,
9282 provider: AgentId,
9283 session_id: String,
9284 message_limit: u16,
9285 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9286 let canonical_root = self.workspace_root(&workspace_id)?;
9287 let registered_workspace_roots = self.workspace_roots();
9288 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9289 failure(
9290 NodeFailureCode::InvalidRequest,
9291 "native session preview is not configured",
9292 )
9293 })?;
9294 let _permit = Arc::clone(&self.inspection_slots)
9295 .try_acquire_owned()
9296 .map_err(|_| {
9297 failure(
9298 NodeFailureCode::BackendBusy,
9299 "native session preview capacity is busy",
9300 )
9301 })?;
9302 run_native_session_catalog_operation(
9303 catalog,
9304 "native session preview",
9305 move |catalog| {
9306 catalog.preview_session_id_for_workspace(
9307 &provider,
9308 Path::new(&canonical_root),
9309 ®istered_workspace_roots,
9310 &session_id,
9311 message_limit,
9312 )
9313 },
9314 )
9315 .await
9316 }
9317
9318 async fn register_workspace(
9319 &self,
9320 workspace_id: WorkspaceId,
9321 root: String,
9322 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9323 validate_workspace_request_root(&root)?;
9324 let canonicalize_workspace_id = workspace_id.clone();
9325 let workspace = tokio::task::spawn_blocking(move || {
9326 WorkspaceConfig::new(canonicalize_workspace_id, root)
9327 })
9328 .await
9329 .map_err(|error| {
9330 failure(
9331 NodeFailureCode::BackendOperationFailed,
9332 &format!("workspace canonicalization task failed: {error}"),
9333 )
9334 })?
9335 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9336 let transaction = self
9337 .state_transaction
9338 .lock()
9339 .unwrap_or_else(|poisoned| poisoned.into_inner());
9340 let mut workspaces = self
9341 .workspaces
9342 .write()
9343 .unwrap_or_else(|poisoned| poisoned.into_inner());
9344 if workspaces.contains_key(&workspace_id) {
9345 return Err(failure(
9346 NodeFailureCode::DuplicateWorkspaceId,
9347 "workspace ID is already registered",
9348 ));
9349 }
9350 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9351 platform::roots_equal(existing_root, workspace.canonical_root())
9352 }) {
9353 return Err(failure(
9354 NodeFailureCode::DuplicateWorkspaceRoot,
9355 &format!("workspace root is already registered as '{existing_id}'"),
9356 ));
9357 }
9358 {
9359 let records = self
9360 .session_records
9361 .lock()
9362 .unwrap_or_else(|poisoned| poisoned.into_inner());
9363 if let Some(record) = records.records.values().find(|record| {
9364 record.workspace_id == workspace_id
9365 && !record
9366 .canonical_root
9367 .as_utf8()
9368 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9369 }) {
9370 return Err(failure(
9371 NodeFailureCode::SessionRecordConflict,
9372 &format!(
9373 "workspace ID is retained by session record '{}' at another root",
9374 record.record_id,
9375 ),
9376 ));
9377 }
9378 }
9379 let snapshot = WorkspaceSnapshot {
9380 workspace_id: workspace_id.clone(),
9381 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9382 sessions: Vec::new(),
9383 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9384 managed_worktree_profiles: Some(WorktreeProfileInventory {
9385 profiles: Vec::new(),
9386 }),
9387 };
9388 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9389 drop(workspaces);
9390 let previous_records = {
9391 let mut records = self
9392 .session_records
9393 .lock()
9394 .unwrap_or_else(|poisoned| poisoned.into_inner());
9395 let previous = records.records.clone();
9396 for record in records.records.values_mut().filter(|record| {
9397 record.workspace_id == workspace_id
9398 && record
9399 .canonical_root
9400 .as_utf8()
9401 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9402 }) {
9403 if record.active_session.is_none() {
9404 record.state = detached_record_state(record);
9405 }
9406 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9407 record.last_error = None;
9408 }
9409 record.updated_at_unix_ms = unix_time_ms();
9410 }
9411 previous
9412 };
9413 if let Err(error) = self.persist_state_locked() {
9414 self.workspaces
9415 .write()
9416 .unwrap_or_else(|poisoned| poisoned.into_inner())
9417 .remove(&workspace_id);
9418 self.session_records
9419 .lock()
9420 .unwrap_or_else(|poisoned| poisoned.into_inner())
9421 .records = previous_records;
9422 return Err(persistence_failure(error));
9423 }
9424 let restored = self
9425 .session_records
9426 .lock()
9427 .unwrap_or_else(|poisoned| poisoned.into_inner())
9428 .records
9429 .values()
9430 .filter(|record| record.workspace_id == workspace_id)
9431 .cloned()
9432 .collect::<Vec<_>>();
9433 drop(transaction);
9434 self.publish(NodeEvent::WorkspaceAdded {
9435 workspace: snapshot.clone(),
9436 });
9437 for record in restored {
9438 self.publish_record(record);
9439 }
9440 Ok(snapshot)
9441 }
9442
9443 async fn create_standalone_workspace(
9444 &self,
9445 workspace_id: WorkspaceId,
9446 root: String,
9447 initial_branch: Option<String>,
9448 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9449 validate_workspace_request_root(&root)?;
9450 validate_git_revision("initial branch", initial_branch.as_deref())?;
9451 let preflight_workspace_id = workspace_id.clone();
9452 let preflight_root = root.clone();
9453 let canonical_candidate = tokio::task::spawn_blocking(move || {
9454 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9455 })
9456 .await
9457 .map_err(|_| failure(
9458 NodeFailureCode::BackendOperationFailed,
9459 "standalone workspace root preflight task failed",
9460 ))?
9461 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9462 {
9463 let workspaces = self
9464 .workspaces
9465 .read()
9466 .unwrap_or_else(|poisoned| poisoned.into_inner());
9467 if workspaces.contains_key(&workspace_id) {
9468 return Err(failure(
9469 NodeFailureCode::DuplicateWorkspaceId,
9470 "workspace ID is already registered",
9471 ));
9472 }
9473 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9474 platform::roots_equal(existing_root, &canonical_candidate)
9475 }) {
9476 return Err(failure(
9477 NodeFailureCode::DuplicateWorkspaceRoot,
9478 &format!("workspace root is already registered as '{existing_id}'"),
9479 ));
9480 }
9481 }
9482 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9483 .await
9484 .map_err(standalone_workspace_failure)?;
9485 prepared
9486 .verify_for_registration()
9487 .await
9488 .map_err(standalone_workspace_failure)?;
9489 match self
9490 .register_workspace(workspace_id, prepared.root().to_owned())
9491 .await
9492 {
9493 Ok(workspace) => Ok(workspace),
9494 Err(registration_error) => {
9495 if prepared.compensate_registration_failure().await {
9496 Err(registration_error)
9497 } else {
9498 Err(failure(
9499 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9500 "standalone workspace initialization succeeded but registration failed; recovery is required",
9501 ))
9502 }
9503 }
9504 }
9505 }
9506
9507 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9508 let bindings = self
9509 .session_bindings
9510 .lock()
9511 .unwrap_or_else(|poisoned| poisoned.into_inner());
9512 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9513 return Err(failure(
9514 NodeFailureCode::WorkspaceBusy,
9515 "workspace retains a bound session; remove every session before unregistering it",
9516 ));
9517 }
9518 drop(bindings);
9519 let transaction = self
9520 .state_transaction
9521 .lock()
9522 .unwrap_or_else(|poisoned| poisoned.into_inner());
9523 let mut workspaces = self
9524 .workspaces
9525 .write()
9526 .unwrap_or_else(|poisoned| poisoned.into_inner());
9527 if !workspaces.contains_key(workspace_id) {
9528 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9529 }
9530 if workspaces.len() == 1 {
9531 return Err(failure(
9532 NodeFailureCode::LastWorkspace,
9533 "node must retain at least one workspace",
9534 ));
9535 }
9536 let removed_root = workspaces
9537 .remove(workspace_id)
9538 .expect("workspace presence was validated");
9539 drop(workspaces);
9540 let previous_records = {
9541 let mut records = self
9542 .session_records
9543 .lock()
9544 .unwrap_or_else(|poisoned| poisoned.into_inner());
9545 let previous = records.records.clone();
9546 for record in records
9547 .records
9548 .values_mut()
9549 .filter(|record| &record.workspace_id == workspace_id)
9550 {
9551 record.active_session = None;
9552 record.state = ManagedSessionState::Unavailable;
9553 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9554 record.updated_at_unix_ms = unix_time_ms();
9555 }
9556 previous
9557 };
9558 if let Err(error) = self.persist_state_locked() {
9559 self.workspaces
9560 .write()
9561 .unwrap_or_else(|poisoned| poisoned.into_inner())
9562 .insert(workspace_id.clone(), removed_root);
9563 self.session_records
9564 .lock()
9565 .unwrap_or_else(|poisoned| poisoned.into_inner())
9566 .records = previous_records;
9567 return Err(persistence_failure(error));
9568 }
9569 let unavailable = self
9570 .session_records
9571 .lock()
9572 .unwrap_or_else(|poisoned| poisoned.into_inner())
9573 .records
9574 .values()
9575 .filter(|record| &record.workspace_id == workspace_id)
9576 .cloned()
9577 .collect::<Vec<_>>();
9578 drop(transaction);
9579 self.publish(NodeEvent::WorkspaceRemoved {
9580 workspace_id: workspace_id.clone(),
9581 });
9582 for record in unavailable {
9583 self.publish_record(record);
9584 }
9585 Ok(())
9586 }
9587
9588 async fn create_worktree(
9589 &self,
9590 source_workspace_id: WorkspaceId,
9591 workspace_id: WorkspaceId,
9592 target_root: String,
9593 branch: String,
9594 base: Option<String>,
9595 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9596 self.require_worktree_service(&source_workspace_id)?;
9597 validate_workspace_request_root(&target_root)?;
9598 validate_git_revision("worktree base", base.as_deref())?;
9599 let source_root = self.workspace_root(&source_workspace_id)?;
9600 {
9601 let workspaces = self
9602 .workspaces
9603 .read()
9604 .unwrap_or_else(|poisoned| poisoned.into_inner());
9605 if workspaces.contains_key(&workspace_id) {
9606 return Err(failure(
9607 NodeFailureCode::DuplicateWorkspaceId,
9608 "workspace ID is already registered",
9609 ));
9610 }
9611 if let Some((existing_id, _)) = workspaces
9612 .iter()
9613 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9614 {
9615 return Err(failure(
9616 NodeFailureCode::DuplicateWorkspaceRoot,
9617 &format!("workspace root is already registered as '{existing_id}'"),
9618 ));
9619 }
9620 }
9621 let mut worktree = create_git_worktree(
9622 &source_root,
9623 &target_root,
9624 &branch,
9625 base.as_deref(),
9626 )
9627 .await
9628 .map_err(git_worktree_failure)?;
9629 let workspace = match self
9630 .register_workspace(workspace_id, worktree.path.clone())
9631 .await
9632 {
9633 Ok(workspace) => workspace,
9634 Err(registration_error) => {
9635 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9636 return Err(failure(
9637 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9638 "worktree registration failed and compensation requires recovery",
9639 ));
9640 }
9641 return Err(failure(
9642 registration_error.code,
9643 ®istration_error.message,
9644 ));
9645 }
9646 };
9647 worktree.workspace_id = Some(workspace.workspace_id.clone());
9648 Ok((protocol_worktree(worktree), workspace))
9649 }
9650
9651 async fn remove_worktree(
9652 &self,
9653 source_workspace_id: WorkspaceId,
9654 target_root: String,
9655 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9656 self.require_worktree_service(&source_workspace_id)?;
9657 validate_workspace_request_root(&target_root)?;
9658 let source_root = self.workspace_root(&source_workspace_id)?;
9659 let target_root = normalize_worktree_removal_target(&target_root)
9660 .map_err(git_worktree_failure)?;
9661 let target = list_git_worktrees(&source_root)
9662 .await
9663 .map_err(git_worktree_failure)?
9664 .into_iter()
9665 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9666 .ok_or_else(|| {
9667 failure(
9668 NodeFailureCode::WorktreeProtected,
9669 "refusing to remove a path that is not in Git's worktree listing",
9670 )
9671 })?;
9672 let registered_workspace_id = {
9673 let workspaces = self
9674 .workspaces
9675 .read()
9676 .unwrap_or_else(|poisoned| poisoned.into_inner());
9677 let matched = workspaces
9678 .iter()
9679 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9680 .map(|(workspace_id, _)| workspace_id.clone());
9681 if matched.is_some() && workspaces.len() == 1 {
9682 return Err(failure(
9683 NodeFailureCode::LastWorkspace,
9684 "node must retain at least one workspace",
9685 ));
9686 }
9687 matched
9688 };
9689 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9690 let bindings = self
9691 .session_bindings
9692 .lock()
9693 .unwrap_or_else(|poisoned| poisoned.into_inner());
9694 if bindings
9695 .values()
9696 .any(|binding| &binding.workspace_id == workspace_id)
9697 {
9698 return Err(failure(
9699 NodeFailureCode::WorkspaceBusy,
9700 "worktree workspace retains a bound session; remove every session first",
9701 ));
9702 }
9703 }
9704 remove_git_worktree(&source_root, &target.path)
9705 .await
9706 .map_err(git_worktree_failure)?;
9707 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9708 self.unregister_workspace(workspace_id)
9709 .map_err(|error| failure(error.code, &error.message))?;
9710 }
9711 Ok(registered_workspace_id)
9712 }
9713
9714 async fn cleanup_managed_worktree(
9715 &self,
9716 lease_id: &ManagedWorktreeLeaseId,
9717 explicit: bool,
9718 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9719 let lease = self.managed_worktrees.lock()
9720 .unwrap_or_else(|poisoned| poisoned.into_inner())
9721 .get(lease_id)
9722 .cloned()
9723 .ok_or_else(|| failure(
9724 NodeFailureCode::UnknownManagedWorktreeLease,
9725 "managed worktree lease does not exist",
9726 ))?;
9727 if lease.state == ManagedWorktreeLeaseState::Removed {
9728 return Ok(lease.snapshot());
9729 }
9730 if lease.has_holders() {
9731 return Err(failure(
9732 NodeFailureCode::ManagedWorktreeBusy,
9733 "managed worktree lease still has a session or durable-record holder",
9734 ));
9735 }
9736 if self
9737 .materializations
9738 .lock()
9739 .unwrap_or_else(|poisoned| poisoned.into_inner())
9740 .values()
9741 .any(|record| record.managed_lease_id() == Some(lease_id))
9742 {
9743 return Err(failure(
9744 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9745 "managed worktree cleanup is blocked by session environment ownership",
9746 ));
9747 }
9748 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9749 let snapshot = {
9750 let mut registry = self.managed_worktrees.lock()
9751 .unwrap_or_else(|poisoned| poisoned.into_inner());
9752 let current = registry.get_mut(lease_id)
9753 .expect("managed worktree lease remains present");
9754 current.state = ManagedWorktreeLeaseState::Retained;
9755 current.cleanup_failure = None;
9756 current.updated_at_unix_ms = unix_time_ms();
9757 current.snapshot()
9758 };
9759 if self.persist_state().is_err() {
9760 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9761 }
9762 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9763 return Ok(snapshot);
9764 }
9765 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9766 if profile.revision() != &lease.profile_revision {
9767 self.mark_managed_recovery_required(
9768 lease_id,
9769 ManagedWorktreeCleanupFailure::OwnershipConflict,
9770 );
9771 return Err(failure(
9772 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9773 "managed worktree profile revision changed",
9774 ));
9775 }
9776 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9777 self.mark_managed_recovery_required(
9778 lease_id,
9779 ManagedWorktreeCleanupFailure::OwnershipConflict,
9780 );
9781 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9782 })?;
9783 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9784 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9785 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9786 managed_git_worktree_failure(error)
9787 })?;
9788 let target = listed.iter()
9789 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9790 if let Some(target) = target {
9791 if target.is_main || target.is_bare || !exact_owned_worktree(
9792 &lease,
9793 &target.path,
9794 target.branch.as_deref(),
9795 &target.head,
9796 ) {
9797 self.mark_managed_recovery_required(
9798 lease_id,
9799 ManagedWorktreeCleanupFailure::OwnershipConflict,
9800 );
9801 return Err(failure(
9802 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9803 "Git worktree identity no longer matches the managed lease",
9804 ));
9805 }
9806 if target.prunable {
9807 self.mark_managed_cleanup_blocked(
9808 lease_id,
9809 ManagedWorktreeCleanupFailure::Prunable,
9810 );
9811 return Err(failure(
9812 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9813 "managed worktree is prunable and requires recovery",
9814 ));
9815 }
9816 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9817 let failure_kind = match error.kind {
9818 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9819 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9820 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9821 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9822 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9823 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9824 };
9825 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9826 self.mark_managed_recovery_required(lease_id, failure_kind);
9827 } else {
9828 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9829 }
9830 return Err(managed_git_worktree_failure(error));
9831 }
9832 } else {
9833 match std::fs::symlink_metadata(&lease.target_root) {
9834 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9835 Ok(_) => {
9836 self.mark_managed_recovery_required(
9837 lease_id,
9838 ManagedWorktreeCleanupFailure::OwnershipConflict,
9839 );
9840 return Err(failure(
9841 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9842 "managed target exists but is not owned by Git",
9843 ));
9844 }
9845 Err(_) => {
9846 self.mark_managed_cleanup_blocked(
9847 lease_id,
9848 ManagedWorktreeCleanupFailure::Backend,
9849 );
9850 return Err(failure(
9851 NodeFailureCode::BackendOperationFailed,
9852 "managed target identity could not be inspected",
9853 ));
9854 }
9855 }
9856 }
9857 let registered = self.workspaces.read()
9858 .unwrap_or_else(|poisoned| poisoned.into_inner())
9859 .contains_key(&lease.workspace_id);
9860 if registered {
9861 self.unregister_workspace(&lease.workspace_id)?;
9862 }
9863 let removed = {
9864 self.managed_worktrees.lock()
9865 .unwrap_or_else(|poisoned| poisoned.into_inner())
9866 .tombstone(lease_id, unix_time_ms())
9867 .expect("managed worktree lease remains present during cleanup")
9868 };
9869 self.persist_state().map_err(persistence_failure)?;
9870 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9871 Ok(removed.snapshot())
9872 }
9873
9874 fn mark_managed_cleanup_blocked(
9875 &self,
9876 lease_id: &ManagedWorktreeLeaseId,
9877 failure_kind: ManagedWorktreeCleanupFailure,
9878 ) {
9879 let snapshot = {
9880 let mut registry = self.managed_worktrees.lock()
9881 .unwrap_or_else(|poisoned| poisoned.into_inner());
9882 let Some(lease) = registry.get_mut(lease_id) else { return };
9883 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9884 lease.cleanup_failure = Some(failure_kind);
9885 lease.updated_at_unix_ms = unix_time_ms();
9886 lease.snapshot()
9887 };
9888 let _ = self.persist_state();
9889 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9890 }
9891
9892 fn require_worktree_service(
9893 &self,
9894 source_workspace_id: &WorkspaceId,
9895 ) -> Result<(), NodeFailure> {
9896 if self.managed_worktrees.lock()
9897 .unwrap_or_else(|poisoned| poisoned.into_inner())
9898 .lease_for_workspace(source_workspace_id)
9899 .is_some()
9900 {
9901 return Err(failure(
9902 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9903 "managed worktree targets are internal-only workspaces",
9904 ));
9905 }
9906 match self
9907 .worktree_service_modes
9908 .get(source_workspace_id)
9909 .copied()
9910 .unwrap_or(WorktreeServiceMode::Manual)
9911 {
9912 WorktreeServiceMode::Manual => Ok(()),
9913 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9914 NodeFailureCode::UnsupportedSpawnCapability,
9915 "Git worktree service mode is unavailable in this node build",
9916 )),
9917 }
9918 }
9919
9920 fn managed_reservation_conflict(
9921 &self,
9922 workspace_id: Option<&WorkspaceId>,
9923 root: Option<&str>,
9924 ) -> bool {
9925 self.managed_worktrees.lock()
9926 .unwrap_or_else(|poisoned| poisoned.into_inner())
9927 .active_records()
9928 .any(|lease| {
9929 workspace_id.is_some_and(|workspace_id| {
9930 workspace_id == &lease.source_workspace_id
9931 || workspace_id == &lease.workspace_id
9932 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9933 })
9934 }
9935
9936 fn reject_managed_reservation(
9937 &self,
9938 workspace_id: Option<&WorkspaceId>,
9939 root: Option<&str>,
9940 ) -> Result<(), NodeFailure> {
9941 if self.managed_reservation_conflict(workspace_id, root) {
9942 Err(failure(
9943 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9944 "managed worktree ownership may only be mutated through its lease cleanup",
9945 ))
9946 } else {
9947 Ok(())
9948 }
9949 }
9950
9951 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9952 self.validate_address(address)?;
9953 self.workspace_root(&address.workspace_id)
9954 }
9955
9956 fn require_session_runtime_policy(
9990 &self,
9991 address: &SessionAddress,
9992 requirement: ProviderRuntimeRequirement,
9993 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9994 let runtime_policy = {
9995 let bindings = self
9996 .session_bindings
9997 .lock()
9998 .unwrap_or_else(|poisoned| poisoned.into_inner());
9999 let binding = bindings
10000 .get(&address.session.instance_id)
10001 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10002 if binding.workspace_id != address.workspace_id {
10003 return Err(failure(
10004 NodeFailureCode::SessionWorkspaceMismatch,
10005 "session belongs to another workspace",
10006 ));
10007 }
10008 if binding.generation != address.session.generation {
10009 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10010 }
10011 binding.runtime_policy
10012 };
10013 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10014 let snapshot = self.internal_session_snapshot(address)?;
10015 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10016 && snapshot.provider.lead_activity != ProviderActivity::Idle
10017 {
10018 return Err(failure(
10019 NodeFailureCode::TurnInFlight,
10020 "session already has a turn in flight; wait for it to complete before sending another prompt",
10021 ));
10022 }
10023 }
10024 require_policy(runtime_policy, false, false, requirement)
10025 .map(|()| runtime_policy)
10026 .map_err(|_| failure(
10027 NodeFailureCode::UnsupportedCapability,
10028 "session runtime policy does not admit this semantic operation",
10029 ))
10030 }
10031
10032 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10033 let mut bindings = self
10034 .session_bindings
10035 .lock()
10036 .unwrap_or_else(|poisoned| poisoned.into_inner());
10037 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10038 binding.workspace_id == address.workspace_id
10039 && binding.generation == address.session.generation
10040 }) {
10041 let removed = bindings.remove(&address.session.instance_id);
10042 self.clear_terminal_frame_watermark(address);
10043 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10044 return removed;
10045 }
10046 None
10047 }
10048
10049 fn cleanup_session_owned_materialization(
10050 &self,
10051 address: &SessionAddress,
10052 binding: &SessionBinding,
10053 ) -> Result<(), NodeFailure> {
10054 let Some(id) = binding.materialization_id.as_ref() else {
10055 return Ok(());
10056 };
10057 let owner = self
10058 .materializations
10059 .lock()
10060 .unwrap_or_else(|poisoned| poisoned.into_inner())
10061 .get(id)
10062 .map(|record| record.owner().clone());
10063 match owner {
10064 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10065 Some(MaterializationOwner::Session {
10066 incarnation_id,
10067 instance_id,
10068 generation,
10069 }) if incarnation_id == self.incarnation_id
10070 && instance_id == address.session.instance_id
10071 && generation == address.session.generation =>
10072 {
10073 self.cleanup_materialization(id)
10074 }
10075 Some(MaterializationOwner::Session { .. }) => Err(failure(
10076 NodeFailureCode::BackendOperationFailed,
10077 "session environment ownership requires recovery",
10078 )),
10079 }
10080 }
10081
10082 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10083 let mut watermarks = self
10084 .terminal_frame_watermarks
10085 .lock()
10086 .unwrap_or_else(|poisoned| poisoned.into_inner());
10087 if watermarks
10088 .get(&address.session.instance_id)
10089 .is_some_and(|(current, _)| current == address)
10090 {
10091 watermarks.remove(&address.session.instance_id);
10092 }
10093 }
10094
10095 #[cfg(test)]
10096 fn address_for(
10097 &self,
10098 instance_id: AgentInstanceId,
10099 generation: SessionGeneration,
10100 ) -> Option<SessionAddress> {
10101 let binding = self.session_bindings
10102 .lock()
10103 .unwrap_or_else(|poisoned| poisoned.into_inner())
10104 .get(&instance_id)
10105 .cloned()?;
10106 (binding.generation == generation).then_some(SessionAddress {
10107 workspace_id: binding.workspace_id,
10108 session: SessionKey { instance_id, generation },
10109 })
10110 }
10111
10112 fn arm_resume(
10113 &self,
10114 address: &SessionAddress,
10115 command_id: CommandId,
10116 runtime_policy: ProviderRuntimePolicy,
10117 ) -> Result<(), NodeFailure> {
10118 let mut bindings = self
10119 .session_bindings
10120 .lock()
10121 .unwrap_or_else(|poisoned| poisoned.into_inner());
10122 let binding = bindings
10123 .get_mut(&address.session.instance_id)
10124 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10125 if binding.workspace_id != address.workspace_id {
10126 return Err(failure(
10127 NodeFailureCode::SessionWorkspaceMismatch,
10128 "session belongs to another workspace",
10129 ));
10130 }
10131 if binding.generation != address.session.generation {
10132 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10133 }
10134 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10135 Ok(())
10136 }
10137
10138 fn clear_armed_resume(&self, address: &SessionAddress) {
10139 let mut bindings = self
10140 .session_bindings
10141 .lock()
10142 .unwrap_or_else(|poisoned| poisoned.into_inner());
10143 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10144 if binding.workspace_id == address.workspace_id
10145 && binding.generation == address.session.generation
10146 {
10147 binding.pending_resume = None;
10148 }
10149 }
10150 }
10151
10152 fn publish_control(&self, event: ControlEvent) {
10153 let revokes_harness_mcp = matches!(
10154 &event.event,
10155 ControlEventKind::Exited { .. }
10156 | ControlEventKind::Failed { .. }
10157 | ControlEventKind::Removed
10158 );
10159 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10160 let mut bindings = self
10161 .session_bindings
10162 .lock()
10163 .unwrap_or_else(|poisoned| poisoned.into_inner());
10164 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10165 return;
10166 };
10167 let previous_address = SessionAddress {
10168 workspace_id: binding.workspace_id.clone(),
10169 session: SessionKey {
10170 instance_id: event.instance_id,
10171 generation: binding.generation,
10172 },
10173 };
10174 let mut resume_command_rejected = false;
10175 if binding.generation == event.generation {
10176 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10177 let rejected_resume = matches!(
10178 &event.event,
10179 ControlEventKind::CommandRejected { .. }
10180 ) && event.command_id == Some(pending_command_id);
10181 let resume_failed = matches!(
10182 &event.event,
10183 ControlEventKind::ResumeDenied { .. }
10184 | ControlEventKind::ResumeFailed { .. }
10185 );
10186 if pending_generation == binding.generation
10187 && (rejected_resume || resume_failed)
10188 {
10189 resume_command_rejected = rejected_resume;
10190 binding.pending_resume = None;
10191 }
10192 }
10193 } else {
10194 let expected = binding.generation.0.checked_add(1);
10195 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10196 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10197 generation == binding.generation
10198 })
10199 || expected != Some(event.generation.0)
10200 || !authorized
10201 {
10202 return;
10203 }
10204 let (_, _, runtime_policy) = binding
10205 .pending_resume
10206 .expect("authorized resume retains its pending runtime policy");
10207 binding.generation = event.generation;
10208 binding.runtime_policy = runtime_policy;
10209 binding.pending_resume = None;
10210 }
10211 (
10212 SessionAddress {
10213 workspace_id: binding.workspace_id.clone(),
10214 session: SessionKey {
10215 instance_id: event.instance_id,
10216 generation: event.generation,
10217 },
10218 },
10219 (previous_address.session.generation != event.generation)
10220 .then_some(previous_address),
10221 binding.record_id.clone(),
10222 binding.runtime_policy.provider_session_identity,
10223 resume_command_rejected,
10224 )
10225 };
10226 if let Some(previous_address) = previous_address.as_ref() {
10227 self.clear_terminal_frame_watermark(previous_address);
10228 }
10229 if let Some(record_id) = record_id {
10230 self.reconcile_managed_record(
10231 &record_id,
10232 &address,
10233 &event,
10234 identity_admitted,
10235 resume_command_rejected,
10236 );
10237 }
10238 match &event.event {
10246 ControlEventKind::Running { process_id } => {
10247 tracing::info!(
10248 node_id = %self.node_id,
10249 workspace_id = %address.workspace_id,
10250 session = ?address.session,
10251 pid = ?process_id,
10252 "session process is running",
10253 );
10254 }
10255 ControlEventKind::Exited { exit_code, forced } => {
10256 tracing::info!(
10257 node_id = %self.node_id,
10258 workspace_id = %address.workspace_id,
10259 session = ?address.session,
10260 exit_code = ?exit_code,
10261 forced = forced,
10262 "session process exited",
10263 );
10264 }
10265 ControlEventKind::Failed { message } => {
10266 tracing::warn!(
10267 node_id = %self.node_id,
10268 workspace_id = %address.workspace_id,
10269 session = ?address.session,
10270 cause = %message,
10271 "session failed",
10272 );
10273 }
10274 ControlEventKind::CommandRejected { message } => {
10275 tracing::warn!(
10276 node_id = %self.node_id,
10277 workspace_id = %address.workspace_id,
10278 session = ?address.session,
10279 cause = %message,
10280 "session command rejected",
10281 );
10282 }
10283 ControlEventKind::Removed => {
10284 tracing::info!(
10285 node_id = %self.node_id,
10286 workspace_id = %address.workspace_id,
10287 session = ?address.session,
10288 "session removed",
10289 );
10290 }
10291 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10292 tracing::info!(
10293 node_id = %self.node_id,
10294 workspace_id = %address.workspace_id,
10295 session = ?address.session,
10296 provider_session = ?provider_session,
10297 "session resume authorized",
10298 );
10299 }
10300 ControlEventKind::Resumed { session: provider_session, process_id } => {
10301 tracing::info!(
10302 node_id = %self.node_id,
10303 workspace_id = %address.workspace_id,
10304 session = ?address.session,
10305 provider_session = ?provider_session,
10306 pid = ?process_id,
10307 "session resumed",
10308 );
10309 }
10310 ControlEventKind::ResumeDenied { reason } => {
10311 tracing::warn!(
10312 node_id = %self.node_id,
10313 workspace_id = %address.workspace_id,
10314 session = ?address.session,
10315 cause = %reason,
10316 "session resume denied",
10317 );
10318 }
10319 ControlEventKind::ResumeFailed { message } => {
10320 tracing::warn!(
10321 node_id = %self.node_id,
10322 workspace_id = %address.workspace_id,
10323 session = ?address.session,
10324 cause = %message,
10325 "session resume failed",
10326 );
10327 }
10328 _ => {}
10329 }
10330 let stream_chunk = agent_stream_chunk(&event);
10331 self.publish(NodeEvent::Control {
10332 address: address.clone(),
10333 event,
10334 });
10335 if let Some(chunk) = stream_chunk {
10336 self.publish_agent_stream_chunk(address.clone(), chunk);
10337 }
10338 if revokes_harness_mcp {
10339 SessionLifecycleHook::on_session_ended(self, &address);
10340 }
10341 }
10342
10343 fn reconcile_managed_record(
10344 &self,
10345 record_id: &SessionRecordId,
10346 address: &SessionAddress,
10347 event: &ControlEvent,
10348 identity_admitted: bool,
10349 resume_command_rejected: bool,
10350 ) {
10351 let _transaction = self
10352 .state_transaction
10353 .lock()
10354 .unwrap_or_else(|poisoned| poisoned.into_inner());
10355 let observed_identity = match &event.event {
10356 ControlEventKind::ProviderEvent {
10357 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10358 ..
10359 } if identity_admitted => Some(identity.clone()),
10360 _ => None,
10361 };
10362 let identity_was_observed = observed_identity.is_some();
10363 let replacement_id = observed_identity
10364 .as_ref()
10365 .and_then(|_| self.allocate_record_id().ok());
10366 let mut upserts = Vec::new();
10367 let mut removals = Vec::new();
10368 let mut rebind = None;
10369 let original_records;
10370 let original_materializations = self
10371 .materializations
10372 .lock()
10373 .unwrap_or_else(|poisoned| poisoned.into_inner())
10374 .clone();
10375 {
10376 let mut records = self
10377 .session_records
10378 .lock()
10379 .unwrap_or_else(|poisoned| poisoned.into_inner());
10380 original_records = records.records.clone();
10381 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10382 return;
10383 };
10384 if let Some(identity) = observed_identity {
10385 let existing_id = records
10386 .records
10387 .values()
10388 .find(|record| {
10389 record.record_id != *record_id
10390 && record.provider_session.as_ref().is_some_and(|record_identity| {
10391 session_registry::same_provider_session(
10392 &record.provider,
10393 record_identity,
10394 ¤t_snapshot.provider,
10395 &identity,
10396 )
10397 })
10398 })
10399 .map(|record| record.record_id.clone());
10400 if let Some(existing_id) = existing_id {
10401 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10402 record.mode == current_snapshot.mode
10403 && record.workspace_id == current_snapshot.workspace_id
10404 && record.environment_profile == current_snapshot.environment_profile
10405 && record.bundle == current_snapshot.bundle
10406 && record.context_id == current_snapshot.context_id
10407 && record.context == current_snapshot.context
10408 && record
10409 .canonical_root
10410 .as_utf8()
10411 .zip(current_snapshot.canonical_root.as_utf8())
10412 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10413 });
10414 if !scope_matches {
10415 if let Some(current) = records.records.get_mut(record_id) {
10416 current.state = ManagedSessionState::Unavailable;
10417 current.last_error =
10418 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10419 current.updated_at_unix_ms = unix_time_ms();
10420 upserts.push(current.clone());
10421 }
10422 } else {
10423 let existing_is_active = records
10424 .records
10425 .get(&existing_id)
10426 .is_some_and(|record| record.active_session.is_some());
10427 if existing_is_active {
10428 if let Some(current) = records.records.get_mut(record_id) {
10429 current.state = ManagedSessionState::Unavailable;
10430 current.last_error =
10431 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10432 current.updated_at_unix_ms = unix_time_ms();
10433 upserts.push(current.clone());
10434 }
10435 } else {
10436 if current_snapshot.provider_session.is_some() {
10437 if let Some(current) = records.records.get_mut(record_id) {
10438 current.active_session = None;
10439 current.state = ManagedSessionState::Dormant;
10440 current.updated_at_unix_ms = unix_time_ms();
10441 upserts.push(current.clone());
10442 }
10443 } else {
10444 records.records.remove(record_id);
10445 removals.push(record_id.clone());
10446 }
10447 if let Some(existing) = records.records.get_mut(&existing_id) {
10448 existing.provider_session = Some(identity.clone());
10449 existing.active_session = Some(address.clone());
10450 existing.state = ManagedSessionState::Live;
10451 existing.updated_at_unix_ms = unix_time_ms();
10452 existing.last_error = None;
10453 upserts.push(existing.clone());
10454 }
10455 rebind = Some(existing_id);
10456 }
10457 }
10458 } else if current_snapshot
10459 .provider_session
10460 .as_ref()
10461 .is_some_and(|current| {
10462 !session_registry::same_provider_session(
10463 ¤t_snapshot.provider,
10464 current,
10465 ¤t_snapshot.provider,
10466 &identity,
10467 )
10468 })
10469 {
10470 if let Some(current) = records.records.get_mut(record_id) {
10471 current.active_session = None;
10472 if current.bundle.is_some() || current.context.is_some() {
10473 let had_bundle = current.bundle.is_some();
10474 current.provider_session = None;
10475 current.bundle = None;
10476 current.context_id = None;
10477 current.context = None;
10478 current.state = ManagedSessionState::Unavailable;
10479 current.last_error = Some(if had_bundle {
10480 "bundle-unavailable".to_owned()
10481 } else {
10482 "context-unavailable".to_owned()
10483 });
10484 } else {
10485 current.state = ManagedSessionState::Dormant;
10486 }
10487 current.updated_at_unix_ms = unix_time_ms();
10488 upserts.push(current.clone());
10489 }
10490 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10491 if let Some(current) = records.records.get_mut(record_id) {
10492 current.state = ManagedSessionState::Unavailable;
10493 current.last_error =
10494 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10495 current.updated_at_unix_ms = unix_time_ms();
10496 upserts.push(current.clone());
10497 }
10498 } else if let Some(new_record_id) = replacement_id {
10499 let now = unix_time_ms();
10500 let replacement = ManagedSessionRecord {
10501 record_id: new_record_id.clone(),
10502 display_name: format!(
10503 "{} #{}",
10504 current_snapshot.provider.as_str(),
10505 records
10506 .records
10507 .values()
10508 .filter(|record| {
10509 record.provider == current_snapshot.provider
10510 })
10511 .count()
10512 .saturating_add(1),
10513 ),
10514 provider: current_snapshot.provider.clone(),
10515 mode: current_snapshot.mode,
10516 state: ManagedSessionState::Live,
10517 workspace_id: current_snapshot.workspace_id,
10518 canonical_root: current_snapshot.canonical_root,
10519 provider_session: Some(identity),
10520 active_session: Some(address.clone()),
10521 environment_profile: current_snapshot.environment_profile,
10522 bundle: current_snapshot.bundle,
10523 context_id: current_snapshot.context_id,
10524 context: current_snapshot.context,
10525 exported_context: None,
10531 task_binding: current_snapshot.task_binding,
10532 created_at_unix_ms: now,
10533 updated_at_unix_ms: now,
10534 last_error: None,
10535 };
10536 records
10537 .records
10538 .insert(new_record_id.clone(), replacement.clone());
10539 upserts.push(replacement);
10540 rebind = Some(new_record_id);
10541 } else if let Some(current) = records.records.get_mut(record_id) {
10542 current.state = ManagedSessionState::Unavailable;
10543 current.last_error =
10544 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10545 upserts.push(current.clone());
10546 }
10547 } else if let Some(current) = records.records.get_mut(record_id) {
10548 current.provider_session = Some(identity);
10549 current.active_session = Some(address.clone());
10550 current.state = ManagedSessionState::Live;
10551 current.updated_at_unix_ms = unix_time_ms();
10552 current.last_error = None;
10553 upserts.push(current.clone());
10554 }
10555 } else if let Some(current) = records.records.get_mut(record_id) {
10556 if resume_command_rejected {
10557 current.active_session = None;
10558 current.state = detached_record_state(current);
10559 current.updated_at_unix_ms = unix_time_ms();
10560 current.last_error =
10561 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10562 upserts.push(current.clone());
10563 } else {
10564 match &event.event {
10565 ControlEventKind::Running { .. }
10566 | ControlEventKind::ResumeAuthorized { .. }
10567 | ControlEventKind::Resumed { .. } => {
10568 current.active_session = Some(address.clone());
10569 current.state = active_record_state(
10570 identity_admitted,
10571 current.provider_session.is_some(),
10572 );
10573 current.updated_at_unix_ms = unix_time_ms();
10574 current.last_error = None;
10575 upserts.push(current.clone());
10576 }
10577 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10578 current.active_session = None;
10579 current.state = detached_record_state(current);
10580 current.updated_at_unix_ms = unix_time_ms();
10581 upserts.push(current.clone());
10582 }
10583 ControlEventKind::Failed { message }
10584 | ControlEventKind::ResumeFailed { message } => {
10585 current.active_session = None;
10586 current.state = detached_record_state(current);
10587 current.updated_at_unix_ms = unix_time_ms();
10588 current.last_error = Some(
10589 session_registry::sanitized_record_error_summary(message),
10590 );
10591 upserts.push(current.clone());
10592 }
10593 ControlEventKind::ResumeDenied { reason } => {
10594 current.active_session = None;
10595 current.state = detached_record_state(current);
10596 current.updated_at_unix_ms = unix_time_ms();
10597 current.last_error = Some(
10598 session_registry::sanitized_record_error_summary(reason),
10599 );
10600 upserts.push(current.clone());
10601 }
10602 _ => {}
10603 }
10604 }
10605 }
10606 }
10607 let materialization_owner_record = identity_was_observed.then(|| {
10608 rebind.clone().unwrap_or_else(|| record_id.clone())
10609 }).and_then(|candidate| {
10610 self.session_records
10611 .lock()
10612 .unwrap_or_else(|poisoned| poisoned.into_inner())
10613 .records
10614 .get(&candidate)
10615 .filter(|record| {
10616 record.provider_session.is_some()
10617 && record.active_session.as_ref() == Some(address)
10618 })
10619 .map(|_| candidate)
10620 });
10621 if let Some(owner_record_id) = materialization_owner_record {
10622 let materialization_id = self
10623 .session_bindings
10624 .lock()
10625 .unwrap_or_else(|poisoned| poisoned.into_inner())
10626 .get(&address.session.instance_id)
10627 .and_then(|binding| binding.materialization_id.clone());
10628 if let Some(materialization_id) = materialization_id {
10629 let mut transfer_failed = false;
10630 {
10631 let mut materializations = self
10632 .materializations
10633 .lock()
10634 .unwrap_or_else(|poisoned| poisoned.into_inner());
10635 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10636 match ownership.owner() {
10637 MaterializationOwner::Session {
10638 incarnation_id,
10639 instance_id,
10640 generation,
10641 } if *incarnation_id == self.incarnation_id
10642 && *instance_id == address.session.instance_id
10643 && *generation == address.session.generation =>
10644 {
10645 transfer_failed = ownership
10646 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10647 .is_err();
10648 }
10649 MaterializationOwner::Record { record_id }
10650 if record_id == &owner_record_id => {}
10651 MaterializationOwner::Record { record_id } => {
10652 let expected = record_id.clone();
10653 transfer_failed = ownership
10654 .transfer_record_owner(
10655 &expected,
10656 owner_record_id.clone(),
10657 unix_time_ms(),
10658 )
10659 .is_err();
10660 }
10661 _ => transfer_failed = true,
10662 }
10663 if transfer_failed {
10664 let _ = ownership.mark_recovery_required(unix_time_ms());
10665 }
10666 } else {
10667 transfer_failed = true;
10668 }
10669 }
10670 if transfer_failed {
10671 if let Some(record) = self
10672 .session_records
10673 .lock()
10674 .unwrap_or_else(|poisoned| poisoned.into_inner())
10675 .records
10676 .get_mut(&owner_record_id)
10677 {
10678 record.state = ManagedSessionState::Unavailable;
10679 record.last_error = Some("environment-profile-unavailable".to_owned());
10680 record.updated_at_unix_ms = unix_time_ms();
10681 upserts.push(record.clone());
10682 }
10683 }
10684 }
10685 }
10686 if let Some(new_record_id) = rebind.clone() {
10687 if let Some(binding) = self
10688 .session_bindings
10689 .lock()
10690 .unwrap_or_else(|poisoned| poisoned.into_inner())
10691 .get_mut(&address.session.instance_id)
10692 {
10693 binding.record_id = Some(new_record_id);
10694 }
10695 }
10696 if upserts.is_empty() && removals.is_empty() {
10697 return;
10698 }
10699 if self.persist_state_locked().is_err() {
10700 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10701 let mut records = self
10702 .session_records
10703 .lock()
10704 .unwrap_or_else(|poisoned| poisoned.into_inner());
10705 records.records = original_records;
10706 *self
10707 .materializations
10708 .lock()
10709 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10710 if let Some(current) = records.records.get_mut(record_id) {
10711 current.state = ManagedSessionState::Unavailable;
10712 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10713 current.updated_at_unix_ms = unix_time_ms();
10714 upserts = vec![current.clone()];
10715 } else {
10716 upserts.clear();
10717 }
10718 removals.clear();
10719 drop(records);
10720 if let Some(binding) = self
10721 .session_bindings
10722 .lock()
10723 .unwrap_or_else(|poisoned| poisoned.into_inner())
10724 .get_mut(&address.session.instance_id)
10725 {
10726 binding.record_id = Some(record_id.clone());
10727 }
10728 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10729 }
10730 for record in upserts {
10731 self.publish_record(record);
10732 }
10733 for record_id in removals {
10734 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10735 }
10736 }
10737
10738 fn snapshot(&self) -> NodeSnapshot {
10739 let control = self.handle.snapshot();
10740 let bindings = self
10741 .session_bindings
10742 .lock()
10743 .unwrap_or_else(|poisoned| poisoned.into_inner())
10744 .clone();
10745 let workspace_roots = self
10746 .workspaces
10747 .read()
10748 .unwrap_or_else(|poisoned| poisoned.into_inner())
10749 .clone();
10750 let environment_profiles = self
10751 .environment_profiles
10752 .read()
10753 .unwrap_or_else(|poisoned| poisoned.into_inner());
10754 let mut workspaces = workspace_roots
10755 .iter()
10756 .map(|(workspace_id, canonical_root)| {
10757 (
10758 workspace_id.clone(),
10759 WorkspaceSnapshot {
10760 workspace_id: workspace_id.clone(),
10761 canonical_root: opaque_windows_path(canonical_root.clone()),
10762 sessions: Vec::new(),
10763 worktree_service_mode: Some(
10764 self.worktree_service_modes
10765 .get(workspace_id)
10766 .copied()
10767 .unwrap_or(WorktreeServiceMode::Manual),
10768 ),
10769 managed_worktree_profiles: Some(WorktreeProfileInventory {
10770 profiles: self
10771 .managed_worktree_profiles
10772 .get(workspace_id)
10773 .into_iter()
10774 .flat_map(BTreeMap::values)
10775 .map(|profile| ManagedWorktreeProfileSummary {
10776 id: profile.profile_id().clone(),
10777 revision: profile.revision().clone(),
10778 retention: profile.retention(),
10779 })
10780 .collect(),
10781 }),
10782 },
10783 )
10784 })
10785 .collect::<BTreeMap<_, _>>();
10786 let mut agent_progress = Vec::new();
10787 for session in &control.sessions {
10788 let Some(binding) = bindings.get(&session.instance_id) else {
10789 continue;
10790 };
10791 if binding.generation != session.generation {
10792 continue;
10793 }
10794 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10795 let address = SessionAddress {
10796 workspace_id: binding.workspace_id.clone(),
10797 session: SessionKey {
10798 instance_id: session.instance_id,
10799 generation: session.generation,
10800 },
10801 };
10802 if let Some(progress) =
10803 agent_progress_from_provider_snapshot(address, &session.provider)
10804 {
10805 agent_progress.push(progress);
10806 }
10807 workspace.sessions.push(session.clone());
10808 }
10809 }
10810 NodeSnapshot {
10811 node_id: self.node_id.clone(),
10812 enabled_providers: self.enabled_providers.clone(),
10813 provider_runtime_statuses: {
10814 let updates = self
10815 .provider_runtime_status_updates
10816 .read()
10817 .unwrap_or_else(|poisoned| poisoned.into_inner());
10818 ProviderRuntimeStatuses::new(
10819 self.provider_runtime_statuses
10820 .iter()
10821 .filter(|status| !updates.contains_key(status.provider()))
10822 .cloned()
10823 .chain(updates.values().cloned()),
10824 )
10825 .expect("refreshed provider runtime inventory remains bounded and unique")
10826 },
10827 workspaces: workspaces.into_values().collect(),
10828 session_records: self
10829 .session_records
10830 .lock()
10831 .unwrap_or_else(|poisoned| poisoned.into_inner())
10832 .records
10833 .values()
10834 .cloned()
10835 .collect(),
10836 managed_worktrees: self.managed_worktrees
10837 .lock()
10838 .unwrap_or_else(|poisoned| poisoned.into_inner())
10839 .snapshots(),
10840 launch_inventory: Some(LaunchInventory {
10841 spawn_profiles: Some(
10842 self.spawn_profiles
10843 .iter()
10844 .filter_map(|profile| {
10845 resolved_spawn_profile_summary(profile, &environment_profiles)
10846 })
10847 .collect(),
10848 ),
10849 bundles: Some(
10850 self.bundle_catalog
10851 .read()
10852 .unwrap_or_else(|poisoned| poisoned.into_inner())
10853 .iter()
10854 .map(NodeBundle::receipt)
10855 .collect(),
10856 ),
10857 network_allowlists: {
10860 let catalog = self
10861 .network_allowlist_catalog
10862 .read()
10863 .unwrap_or_else(|poisoned| poisoned.into_inner());
10864 if catalog.is_empty() {
10865 None
10866 } else {
10867 Some(catalog.ids().cloned().collect())
10868 }
10869 },
10870 }),
10871 agent_progress,
10872 }
10873 }
10874
10875 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10876 assert!(
10877 !matches!(
10878 &event,
10879 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10880 ),
10881 "terminal frame and agent stream events must use their own dedicated live channel",
10882 );
10883 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10884 let sequence = history
10885 .last_sequence
10886 .checked_add(1)
10887 .expect("node event sequence exhausted");
10888 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10889 if let Some(evicted) = history.events.pop_front() {
10890 history.replay_floor_sequence = evicted
10891 .sequence
10892 .checked_add(1)
10893 .expect("node replay floor sequence exhausted");
10894 history.removed_record_providers.remove(&evicted.sequence);
10895 }
10896 }
10897 match &event {
10898 NodeEvent::HarnessMcpReadCall { .. } => {
10899 panic!("transient harness MCP calls must not enter durable event history")
10900 }
10901 NodeEvent::SessionRecordUpserted { record } => {
10902 history
10903 .record_providers
10904 .insert(record.record_id.clone(), record.provider.clone());
10905 }
10906 NodeEvent::SessionRecordRemoved { record_id } => {
10907 if let Some(provider) = history.record_providers.remove(record_id) {
10908 history.removed_record_providers.insert(sequence, provider);
10909 }
10910 }
10911 NodeEvent::ManagedWorktreeUpserted { .. }
10912 | NodeEvent::ManagedWorktreeRemoved { .. }
10913 | NodeEvent::Control { .. }
10914 | NodeEvent::ControllerChanged { .. }
10915 | NodeEvent::SessionRecordHistorySummarized { .. }
10916 | NodeEvent::WorkspaceAdded { .. }
10917 | NodeEvent::WorkspaceRemoved { .. }
10918 | NodeEvent::ResyncRequired { .. }
10919 | NodeEvent::AgentStream { .. }
10920 | NodeEvent::TerminalFrame { .. } => {}
10921 }
10922 let envelope = NodeEventEnvelope { sequence, event };
10923 history.events.push_back(envelope.clone());
10924 history.last_sequence = sequence;
10925 let _ = self.event_tx.send(envelope.clone());
10926 drop(history);
10927 envelope
10928 }
10929
10930 fn publish_transient(&self, event: NodeEvent) -> bool {
10941 assert!(event.requires_harness_mcp_proxy_capability());
10942 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10943 let envelope = NodeEventEnvelope { sequence: 0, event };
10944 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10945 Ok(_) => true,
10946 Err(broadcast::error::SendError(undelivered)) => {
10947 if let Some(NodeEventEnvelope {
10948 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10949 ..
10950 }) = undelivered.first() {
10951 tracing::warn!(
10957 call_id = call_id.as_str(),
10958 content_type = ?request.content_type,
10959 cause = "no-subscribers",
10960 "harness MCP proxy event could not be published",
10961 );
10962 }
10963 false
10964 }
10965 }
10966 }
10967
10968 fn publish_terminal_frames(&self) {
10969 let snapshot = self.handle.snapshot();
10970 let bindings = self
10971 .session_bindings
10972 .lock()
10973 .unwrap_or_else(|poisoned| poisoned.into_inner());
10974 let candidates = snapshot
10975 .sessions
10976 .iter()
10977 .filter_map(|session| {
10978 let binding = bindings.get(&session.instance_id)?;
10979 if binding.generation != session.generation {
10980 return None;
10981 }
10982 let frame = session.terminal_frame.clone()?;
10983 Some((
10984 SessionAddress {
10985 workspace_id: binding.workspace_id.clone(),
10986 session: SessionKey {
10987 instance_id: session.instance_id,
10988 generation: session.generation,
10989 },
10990 },
10991 frame,
10992 ))
10993 })
10994 .collect();
10995 drop(bindings);
10996 self.publish_terminal_frame_candidates(candidates);
10997 }
10998
10999 fn publish_terminal_frame_candidates(
11000 &self,
11001 candidates: Vec<(SessionAddress, TerminalFrame)>,
11002 ) {
11003 let mut watermarks = self
11004 .terminal_frame_watermarks
11005 .lock()
11006 .unwrap_or_else(|poisoned| poisoned.into_inner());
11007 let mut events = Vec::with_capacity(candidates.len());
11008 for (address, frame) in candidates {
11009 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11010 let advanced = watermarks
11011 .get(&address.session.instance_id)
11012 .map_or(true, |(current_address, sequence)| {
11013 current_address != &address || frame.sequence > *sequence
11014 });
11015 if !advanced {
11016 continue;
11017 }
11018 if is_first_frame {
11019 if self.controller_state().is_none() {
11027 tracing::warn!(
11028 node_id = %self.node_id,
11029 workspace_id = %address.workspace_id,
11030 session = ?address.session,
11031 terminal_size = ?frame.size,
11032 "session producing output with no controller attached",
11033 );
11034 } else {
11035 tracing::info!(
11036 node_id = %self.node_id,
11037 workspace_id = %address.workspace_id,
11038 session = ?address.session,
11039 terminal_size = ?frame.size,
11040 "session produced its first output",
11041 );
11042 }
11043 }
11044 watermarks.insert(
11045 address.session.instance_id,
11046 (address.clone(), frame.sequence),
11047 );
11048 if let Some((at, reported)) = self
11049 .pty_input_marks
11050 .lock()
11051 .unwrap_or_else(|poisoned| poisoned.into_inner())
11052 .get_mut(&address.session.instance_id)
11053 {
11054 if !*reported && at.elapsed() < Duration::from_secs(5) {
11056 *reported = true;
11057 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");
11058 }
11059 }
11060 events.push(NodeEvent::TerminalFrame { address, frame });
11061 }
11062 drop(watermarks);
11063 if events.is_empty() {
11064 return;
11065 }
11066 let mut history = self
11067 .history
11068 .lock()
11069 .unwrap_or_else(|poisoned| poisoned.into_inner());
11070 let mut envelopes = Vec::with_capacity(events.len());
11071 for event in events {
11072 let sequence = history
11073 .last_sequence
11074 .checked_add(1)
11075 .expect("node event sequence exhausted");
11076 history.last_sequence = sequence;
11077 envelopes.push(NodeEventEnvelope { sequence, event });
11078 }
11079 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11080 drop(history);
11081 }
11082
11083 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11092 let mut history = self
11093 .history
11094 .lock()
11095 .unwrap_or_else(|poisoned| poisoned.into_inner());
11096 let sequence = history
11097 .last_sequence
11098 .checked_add(1)
11099 .expect("node event sequence exhausted");
11100 history.last_sequence = sequence;
11101 drop(history);
11102 tracing::debug!(
11110 source_sequence = chunk.source_sequence,
11111 kind = agent_stream_chunk_kind_label(&chunk.kind),
11112 envelope_sequence = sequence,
11113 "publishing agent stream chunk",
11114 );
11115 let envelope = NodeEventEnvelope {
11116 sequence,
11117 event: NodeEvent::AgentStream { address, chunk },
11118 };
11119 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11120 }
11121
11122 fn current_sequence(&self) -> u64 {
11123 self.history
11124 .lock()
11125 .unwrap_or_else(|poisoned| poisoned.into_inner())
11126 .last_sequence
11127 }
11128
11129 fn controller_state(&self) -> Option<ControllerState> {
11130 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11131 let lease = controller.as_ref().copied()?;
11132 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11133 if remaining.is_zero() {
11134 *controller = None;
11135 return None;
11136 }
11137 Some(ControllerState {
11138 connection_id: lease.connection_id,
11139 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11140 })
11141 }
11142
11143 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11144 if role != ClientRole::Operator {
11145 tracing::warn!(
11146 node_id = %self.node_id,
11147 connection_id,
11148 "controller lease rejected: observer connections cannot acquire control",
11149 );
11150 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11151 }
11152 let lease_ms = if lease_ms == 0 {
11153 DEFAULT_CONTROLLER_LEASE_MS
11154 } else {
11155 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11156 };
11157 let now = Instant::now();
11158 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11159 if let Some(active) = controller.as_ref() {
11160 if active.connection_id != connection_id && active.expires_at > now {
11161 tracing::warn!(
11162 node_id = %self.node_id,
11163 connection_id,
11164 holder_connection_id = active.connection_id,
11165 "controller lease rejected: another operator holds it",
11166 );
11167 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11168 }
11169 }
11170 let is_new_attach = match controller.as_ref() {
11177 Some(active) => active.connection_id != connection_id,
11178 None => true,
11179 };
11180 *controller = Some(ControllerLease {
11181 connection_id,
11182 expires_at: now + Duration::from_millis(lease_ms),
11183 });
11184 drop(controller);
11185 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11186 if is_new_attach {
11187 tracing::info!(
11188 node_id = %self.node_id,
11189 connection_id,
11190 lease_ms,
11191 "controller lease acquired",
11192 );
11193 } else {
11194 tracing::debug!(
11195 node_id = %self.node_id,
11196 connection_id,
11197 lease_ms,
11198 "controller lease renewed",
11199 );
11200 }
11201 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11202 Ok(state)
11203 }
11204
11205 fn release_controller(&self, connection_id: u64) -> bool {
11206 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11207 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11208 if released {
11209 *controller = None;
11210 }
11211 drop(controller);
11212 if released {
11213 tracing::info!(
11214 node_id = %self.node_id,
11215 connection_id,
11216 "controller lease released",
11217 );
11218 self.publish(NodeEvent::ControllerChanged { controller: None });
11219 }
11220 released
11221 }
11222
11223 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11224 if role != ClientRole::Operator {
11225 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11226 }
11227 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11228 let now = Instant::now();
11229 match controller.as_ref().copied() {
11230 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11231 Some(active) if active.expires_at <= now => {
11232 *controller = None;
11233 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11234 }
11235 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11236 }
11237 }
11238
11239 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11240 if !self
11241 .workspaces
11242 .read()
11243 .unwrap_or_else(|poisoned| poisoned.into_inner())
11244 .contains_key(&address.workspace_id)
11245 {
11246 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11247 }
11248 let binding = self
11249 .session_bindings
11250 .lock()
11251 .unwrap_or_else(|poisoned| poisoned.into_inner())
11252 .get(&address.session.instance_id)
11253 .cloned()
11254 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11255 if binding.workspace_id != address.workspace_id {
11256 return Err(failure(
11257 NodeFailureCode::SessionWorkspaceMismatch,
11258 "session belongs to another workspace",
11259 ));
11260 }
11261 if binding.generation != address.session.generation {
11262 return Err(failure(
11263 NodeFailureCode::StaleGeneration,
11264 &format!(
11265 "stale session generation: expected {}, received {}",
11266 binding.generation.0,
11267 address.session.generation.0,
11268 ),
11269 ));
11270 }
11271 let snapshot = self.handle.snapshot();
11272 let Some(current) = snapshot
11273 .sessions
11274 .iter()
11275 .find(|item| item.instance_id == address.session.instance_id)
11276 else {
11277 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11278 };
11279 if current.generation != address.session.generation {
11280 return Err(failure(
11281 NodeFailureCode::StaleGeneration,
11282 &format!(
11283 "stale session generation: expected {}, received {}",
11284 current.generation.0,
11285 address.session.generation.0,
11286 ),
11287 ));
11288 }
11289 Ok(current.agent_id.clone())
11290 }
11291
11292 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11293 self.dispatch_envelope(self.prepare_command(command))
11294 }
11295
11296 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11297 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11298 CommandEnvelope {
11299 id: CommandId(id),
11300 command,
11301 }
11302 }
11303
11304 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11305 let command_id = envelope.id;
11306 self.handle
11307 .dispatch(envelope)
11308 .map_err(|error| match error {
11309 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11310 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11311 })?;
11312 Ok(command_id)
11313 }
11314
11315 fn record_rejected_command(
11324 &self,
11325 command_id: CommandId,
11326 message: String,
11327 unsupported_transport: bool,
11328 ) {
11329 let now = Instant::now();
11330 let mut reasons = self.rejected_command_reasons.lock()
11331 .unwrap_or_else(|poisoned| poisoned.into_inner());
11332 reasons.retain(|_, reason| reason.expires_at > now);
11333 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11334 if let Some(&oldest) = reasons.keys().next() {
11335 reasons.remove(&oldest);
11336 }
11337 }
11338 reasons.insert(
11339 command_id,
11340 RejectedCommandReason {
11341 message,
11342 unsupported_transport,
11343 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11344 },
11345 );
11346 }
11347
11348 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11355 let now = Instant::now();
11356 let mut reasons = self.rejected_command_reasons.lock()
11357 .unwrap_or_else(|poisoned| poisoned.into_inner());
11358 match reasons.remove(&command_id) {
11359 Some(reason) if reason.expires_at > now => Some(reason),
11360 _ => None,
11361 }
11362 }
11363
11364 async fn dispatch_bounded(
11365 &self,
11366 command: ControlCommand,
11367 timeout_duration: Duration,
11368 ) -> Result<CommandId, NodeFailure> {
11369 let deadline = Instant::now() + timeout_duration;
11370 loop {
11371 match self.dispatch(command.clone()) {
11372 Ok(command_id) => return Ok(command_id),
11373 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11374 sleep(Duration::from_millis(2)).await;
11375 }
11376 Err(error) => return Err(error),
11377 }
11378 }
11379 }
11380
11381 fn internal_session_snapshot(
11382 &self,
11383 address: &SessionAddress,
11384 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11385 self.validate_address(address)?;
11386 self.handle
11387 .snapshot()
11388 .sessions
11389 .clone()
11390 .into_iter()
11391 .find(|session| {
11392 session.instance_id == address.session.instance_id
11393 && session.generation == address.session.generation
11394 })
11395 .ok_or_else(|| {
11396 failure(
11397 NodeFailureCode::UnknownSession,
11398 "session snapshot is unavailable",
11399 )
11400 })
11401 }
11402
11403 fn require_acp_session(
11411 &self,
11412 address: &SessionAddress,
11413 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11414 let snapshot = self.internal_session_snapshot(address)?;
11415 if snapshot.transport != TransportKind::Acp {
11416 return Err(failure(
11417 NodeFailureCode::UnsupportedTransport,
11418 "this verb requires a session spawned over the ACP transport",
11419 ));
11420 }
11421 Ok(snapshot)
11422 }
11423
11424 async fn dispatch_history_bounded(
11425 &self,
11426 address: &SessionAddress,
11427 command: ControlCommand,
11428 operation: HistoryOperation,
11429 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11430 self.validate_address(address)?;
11431 let started_after = self.current_sequence();
11432 let command_id = self
11433 .dispatch_bounded(
11434 command,
11435 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11436 )
11437 .await?;
11438 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11439 let mut scan_after = started_after;
11440 let mut accepted_after = None;
11441 loop {
11442 let events = self
11443 .history
11444 .lock()
11445 .unwrap_or_else(|poisoned| poisoned.into_inner())
11446 .events
11447 .iter()
11448 .filter(|envelope| envelope.sequence > scan_after)
11449 .cloned()
11450 .collect::<Vec<_>>();
11451 for envelope in events {
11452 scan_after = scan_after.max(envelope.sequence);
11453 let NodeEvent::Control {
11454 address: event_address,
11455 event,
11456 } = envelope.event
11457 else {
11458 continue;
11459 };
11460 if event_address != *address {
11461 continue;
11462 }
11463 if event.command_id == Some(command_id) {
11464 match &event.event {
11465 ControlEventKind::CommandRejected { .. } => {
11466 return Err(failure(
11467 NodeFailureCode::InvalidRequest,
11468 "history request was rejected",
11469 ));
11470 }
11471 ControlEventKind::HistoryRequested {
11472 operation: requested,
11473 ..
11474 } if requested == &operation => {
11475 accepted_after = Some(envelope.sequence);
11476 continue;
11477 }
11478 _ => {}
11479 }
11480 }
11481 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11482 match (&operation, &event.event) {
11483 (
11484 HistoryOperation::Discover { .. },
11485 ControlEventKind::HistoryDiscovered { .. },
11486 )
11487 | (
11488 HistoryOperation::Load { .. },
11489 ControlEventKind::HistoryLoaded { .. },
11490 ) => return self.internal_session_snapshot(address),
11491 (_, ControlEventKind::HistoryFailed { .. }) => {
11492 return Err(failure(
11493 NodeFailureCode::BackendOperationFailed,
11494 "history operation failed",
11495 ));
11496 }
11497 _ => {}
11498 }
11499 }
11500 }
11501 if Instant::now() >= deadline {
11502 return Err(failure(
11503 NodeFailureCode::BackendBusy,
11504 if accepted_after.is_some() {
11505 "history operation did not settle before the bounded deadline"
11506 } else {
11507 "history operation was not accepted before the bounded deadline"
11508 },
11509 ));
11510 }
11511 sleep(Duration::from_millis(2)).await;
11512 }
11513 }
11514
11515 async fn discover_history(
11516 &self,
11517 address: &SessionAddress,
11518 limit: u16,
11519 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11520 let query = HistoryQuery {
11521 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11522 limit,
11523 };
11524 query
11525 .validate()
11526 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11527 let snapshot = self
11528 .dispatch_history_bounded(
11529 address,
11530 ControlCommand::DiscoverHistory {
11531 instance_id: address.session.instance_id,
11532 query: query.clone(),
11533 },
11534 HistoryOperation::Discover { query },
11535 )
11536 .await?;
11537 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11538 return Err(failure(
11539 NodeFailureCode::BackendOperationFailed,
11540 "history discovery settled into an invalid state",
11541 ));
11542 }
11543 Ok(snapshot.history.candidates)
11544 }
11545
11546 async fn load_history(
11547 &self,
11548 address: &SessionAddress,
11549 candidate_id: String,
11550 ) -> Result<HistorySessionRecord, NodeFailure> {
11551 validate_candidate_id(&candidate_id)
11552 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11553 let snapshot = self
11554 .dispatch_history_bounded(
11555 address,
11556 ControlCommand::LoadHistory {
11557 instance_id: address.session.instance_id,
11558 candidate_id: candidate_id.clone(),
11559 },
11560 HistoryOperation::Load {
11561 candidate_id: candidate_id.clone(),
11562 },
11563 )
11564 .await?;
11565 if snapshot.history.pending.is_some()
11566 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11567 {
11568 return Err(failure(
11569 NodeFailureCode::BackendOperationFailed,
11570 "history load settled into an invalid state",
11571 ));
11572 }
11573 snapshot.history.loaded.ok_or_else(|| {
11574 failure(
11575 NodeFailureCode::BackendOperationFailed,
11576 "history load produced no bounded session",
11577 )
11578 })
11579 }
11580
11581 async fn export_context_pack_for_session_record(
11588 &self,
11589 record_id: &SessionRecordId,
11590 session: &SessionAddress,
11591 allow_clean_detachment: bool,
11592 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11593 let outcome = self
11599 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11600 .await;
11601 if let Err(failure) = &outcome {
11602 tracing::warn!(
11603 record = %record_id,
11604 code = ?failure.code,
11605 reason = %failure.message,
11606 "session-record context-pack export refused",
11607 );
11608 }
11609 outcome
11610 }
11611
11612 async fn export_context_pack_for_session_record_inner(
11613 &self,
11614 record_id: &SessionRecordId,
11615 session: &SessionAddress,
11616 allow_clean_detachment: bool,
11617 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11618 let step = |name: &'static str| move |failure: NodeFailure| {
11623 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11624 failure
11625 };
11626 let record = self.record(record_id).map_err(step("record-lookup"))?;
11627 let identity = match record.provider_session.clone() {
11643 Some(identity) => {
11644 if identity.key != ProviderSessionKey::SessionId {
11645 return Err(step("provider-identity-key")(failure(
11646 NodeFailureCode::BackendOperationFailed,
11647 "managed session provider identity cannot bind history exactly",
11648 )));
11649 }
11650 Some(identity)
11651 }
11652 None if record.state == ManagedSessionState::IdentityPending => {
11653 return Err(step("provider-session-identity")(failure(
11654 NodeFailureCode::BackendOperationFailed,
11655 "managed session has not yet observed its provider session identity",
11656 )));
11657 }
11658 None => None,
11659 };
11660 self.revalidate_session_record_context_export(
11661 &record,
11662 identity.as_ref(),
11663 session,
11664 allow_clean_detachment,
11665 ).map_err(step("revalidate-before-discovery"))?;
11666 let candidates = self
11667 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11668 .await
11669 .map_err(step("discover-history"))?;
11670 let candidate_id = select_history_candidate(
11675 &candidates,
11676 identity.as_ref().map(|identity| identity.id.as_str()),
11677 ).map_err(step("unique-candidate"))?;
11678 let loaded = self.load_history(session, candidate_id.clone()).await
11679 .map_err(step("load-history"))?;
11680 if let Some(identity) = identity.as_ref() {
11681 if loaded.session_id != identity.id {
11682 return Err(failure(
11683 NodeFailureCode::SessionRecordConflict,
11684 "loaded history identity does not match the managed session record",
11685 ));
11686 }
11687 }
11688 self.revalidate_session_record_context_export(
11689 &record,
11690 identity.as_ref(),
11691 session,
11692 allow_clean_detachment,
11693 )?;
11694 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11695 .map_err(step("revalidate-loaded-history"))?;
11696 let pack = self.materialize_context_pack(session).await
11697 .map_err(step("materialize-context-pack"))?;
11698 self.commit_context_pack_for_session_record(
11699 &record,
11700 identity.as_ref(),
11701 session,
11702 &candidate_id,
11703 &loaded,
11704 pack,
11705 allow_clean_detachment,
11706 )
11707 }
11708
11709 async fn export_context_pack(
11710 &self,
11711 address: &SessionAddress,
11712 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11713 let pack = self.materialize_context_pack(address).await?;
11714 self.commit_context_pack(pack)
11715 }
11716
11717 async fn materialize_context_pack(
11718 &self,
11719 address: &SessionAddress,
11720 ) -> Result<NodeContextPack, NodeFailure> {
11721 let snapshot = self.internal_session_snapshot(address)?;
11722 if !context_pack_source_status_is_usable(&snapshot.status) {
11723 return Err(failure(
11724 NodeFailureCode::InvalidRequest,
11725 "context export requires a running or successfully exited source session",
11726 ));
11727 }
11728 if snapshot.history.pending.is_some()
11729 || snapshot.history.loaded_candidate_id.is_none()
11730 {
11731 return Err(failure(
11732 NodeFailureCode::InvalidRequest,
11733 "context export requires settled loaded history",
11734 ));
11735 }
11736 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11737 failure(
11738 NodeFailureCode::InvalidRequest,
11739 "context export requires settled loaded history",
11740 )
11741 })?;
11742 let source_provider = snapshot.agent_id.clone();
11743 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11744 let repository = self
11745 .context_pack_repository(&address.workspace_id)
11746 .await?;
11747 let current = self.internal_session_snapshot(address)?;
11748 if !context_pack_source_status_is_usable(¤t.status)
11749 || current.agent_id != source_provider
11750 || current.history.pending.is_some()
11751 || current.history.loaded_candidate_id != loaded_candidate_id
11752 || current.history.loaded.as_ref() != Some(&loaded)
11753 {
11754 return Err(failure(
11755 NodeFailureCode::ContextPackMaterializationFailed,
11756 "context source changed during bounded repository capture",
11757 ));
11758 }
11759 NodeContextPack::export_with_repository(
11760 ContextPackLineageReceipt {
11761 source_node_id: self.node_id.clone(),
11762 source_session: address.clone(),
11763 source_provider,
11764 },
11765 &loaded,
11766 Some(repository),
11767 )
11768 .map_err(|_| {
11769 failure(
11770 NodeFailureCode::ContextPackMaterializationFailed,
11771 "bounded context pack export failed",
11772 )
11773 })
11774 }
11775
11776 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11782 let mut store = self
11783 .context_pack_store
11784 .lock()
11785 .unwrap_or_else(|poisoned| poisoned.into_inner());
11786 let Some(store) = store.as_mut() else {
11787 return Ok(());
11788 };
11789 store.commit(pack).map_err(|_| {
11790 failure(
11791 NodeFailureCode::ContextPackMaterializationFailed,
11792 "durable context pack commit failed",
11793 )
11794 })
11795 }
11796
11797 fn commit_context_pack(
11798 &self,
11799 pack: NodeContextPack,
11800 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11801 self.commit_context_pack_durably(&pack)?;
11802 self.context_catalog
11803 .write()
11804 .unwrap_or_else(|poisoned| poisoned.into_inner())
11805 .insert(pack)
11806 .map_err(|_| {
11807 failure(
11808 NodeFailureCode::ContextPackMaterializationFailed,
11809 "context pack catalog is full",
11810 )
11811 })
11812 }
11813
11814 fn commit_context_pack_for_session_record(
11815 &self,
11816 expected: &ManagedSessionRecord,
11817 identity: Option<&ProviderSessionIdentity>,
11818 session: &SessionAddress,
11819 candidate_id: &str,
11820 loaded: &HistorySessionRecord,
11821 pack: NodeContextPack,
11822 allow_clean_detachment: bool,
11823 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11824 let current_root = self.workspace_root(&expected.workspace_id)?;
11825 let _transaction = self
11826 .state_transaction
11827 .lock()
11828 .unwrap_or_else(|poisoned| poisoned.into_inner());
11829 let records = self
11830 .session_records
11831 .lock()
11832 .unwrap_or_else(|poisoned| poisoned.into_inner());
11833 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11834 failure(
11835 NodeFailureCode::SessionRecordConflict,
11836 "managed session record disappeared during context export",
11837 )
11838 })?;
11839 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11840 return Err(failure(
11841 NodeFailureCode::SessionRecordConflict,
11842 "managed session record changed during context export",
11843 ));
11844 }
11845 let bindings = self
11846 .session_bindings
11847 .lock()
11848 .unwrap_or_else(|poisoned| poisoned.into_inner());
11849 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11850 failure(
11851 NodeFailureCode::SessionRecordConflict,
11852 "managed session runtime binding disappeared during context export",
11853 )
11854 })?;
11855 let runtime = self
11856 .handle
11857 .snapshot()
11858 .sessions
11859 .iter()
11860 .find(|snapshot| {
11861 snapshot.instance_id == session.session.instance_id
11862 && snapshot.generation == session.session.generation
11863 })
11864 .cloned()
11865 .ok_or_else(|| {
11866 failure(
11867 NodeFailureCode::SessionRecordConflict,
11868 "managed session runtime disappeared during context export",
11869 )
11870 })?;
11871 let receipt = pack.receipt();
11872 if !session_record_context_export_binding_is_exact(
11873 expected,
11874 current,
11875 identity,
11876 session,
11877 binding,
11878 &runtime.agent_id,
11879 ¤t_root,
11880 allow_clean_detachment,
11881 ) || !session_record_context_export_source_is_usable(
11882 current,
11883 &runtime.status,
11884 allow_clean_detachment,
11885 ) || runtime.history.pending.is_some()
11886 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11887 || runtime.history.loaded.as_ref() != Some(loaded)
11888 || receipt.lineage.source_node_id != self.node_id
11889 || receipt.lineage.source_session != *session
11890 || receipt.lineage.source_provider != current.provider
11891 || receipt.source_message_count != loaded.message_count
11892 {
11893 return Err(failure(
11894 NodeFailureCode::SessionRecordConflict,
11895 "managed session source changed before context catalog commit",
11896 ));
11897 }
11898 self.commit_context_pack_durably(&pack)?;
11899 self.context_catalog
11900 .write()
11901 .unwrap_or_else(|poisoned| poisoned.into_inner())
11902 .insert(pack)
11903 .map_err(|_| {
11904 failure(
11905 NodeFailureCode::ContextPackMaterializationFailed,
11906 "context pack catalog is full",
11907 )
11908 })
11909 }
11910
11911 async fn context_pack_repository(
11912 &self,
11913 workspace_id: &WorkspaceId,
11914 ) -> Result<ContextPackRepository, NodeFailure> {
11915 let first = self
11916 .context_pack_repository_observation(workspace_id)
11917 .await?;
11918 let second = self
11919 .context_pack_repository_observation(workspace_id)
11920 .await?;
11921 stable_context_pack_repository(first, second)
11922 }
11923
11924 async fn context_pack_repository_observation(
11925 &self,
11926 workspace_id: &WorkspaceId,
11927 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11928 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11929 let head = if git.is_repository {
11930 let root = self.workspace_root(workspace_id)?;
11931 match resolve_base_commit_with_timeout(
11932 &root,
11933 "HEAD",
11934 GIT_COMMAND_TIMEOUT_MS,
11935 )
11936 .await
11937 {
11938 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11939 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11940 ContextPackRepositoryHead::Unborn
11941 }
11942 Err(_) => {
11943 return Err(failure(
11944 NodeFailureCode::ContextPackMaterializationFailed,
11945 "context pack HEAD observation failed",
11946 ));
11947 }
11948 }
11949 } else {
11950 ContextPackRepositoryHead::NotRepository
11951 };
11952 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11953 for selected_path in CONTEXT_PACK_SELECTED_FILES {
11954 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11955 failure(
11956 NodeFailureCode::InvalidRepositoryPath,
11957 "context pack selected file path is invalid",
11958 )
11959 })?;
11960 match self.read_workspace_file(workspace_id.clone(), path).await {
11961 Ok(WorkspaceFileRead {
11962 content: WorkspaceFileContent::Utf8 { text, byte_len },
11963 ..
11964 }) => files.push(ContextPackRepositoryFileSource::utf8(
11965 selected_path,
11966 text,
11967 byte_len,
11968 )),
11969 Ok(WorkspaceFileRead {
11970 content: WorkspaceFileContent::NonUtf8 { byte_len },
11971 ..
11972 }) => files.push(ContextPackRepositoryFileSource::skipped(
11973 selected_path,
11974 ContextPackSelectedFileSkipReason::NonUtf8,
11975 Some(byte_len),
11976 )),
11977 Ok(WorkspaceFileRead {
11978 content: WorkspaceFileContent::TooLarge { .. },
11979 ..
11980 }) => files.push(ContextPackRepositoryFileSource::skipped(
11981 selected_path,
11982 ContextPackSelectedFileSkipReason::TooLarge,
11983 None,
11984 )),
11985 Err(error) => {
11986 let reason = if matches!(
11987 error.code,
11988 NodeFailureCode::InvalidRepositoryPath
11989 | NodeFailureCode::RepositoryFileNotRegular
11990 | NodeFailureCode::RepositoryPathUnsafe
11991 ) {
11992 ContextPackSelectedFileSkipReason::Unsafe
11993 } else {
11994 ContextPackSelectedFileSkipReason::Unavailable
11995 };
11996 files.push(ContextPackRepositoryFileSource::skipped(
11997 selected_path,
11998 reason,
11999 None,
12000 ));
12001 }
12002 }
12003 }
12004 Ok(ContextPackRepositoryObservation { head, git, files })
12005 }
12006
12007 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12008 let referenced_by_binding = self
12009 .session_bindings
12010 .lock()
12011 .unwrap_or_else(|poisoned| poisoned.into_inner())
12012 .values()
12013 .any(|binding| {
12014 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12015 });
12016 let referenced_by_record = self
12017 .session_records
12018 .lock()
12019 .unwrap_or_else(|poisoned| poisoned.into_inner())
12020 .records
12021 .values()
12022 .any(|record| record.context_id.as_ref() == Some(context_id));
12023 let referenced_by_materialization = self
12024 .materializations
12025 .lock()
12026 .unwrap_or_else(|poisoned| poisoned.into_inner())
12027 .values()
12028 .any(|ownership| {
12029 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12030 });
12031 if referenced_by_binding
12032 || referenced_by_record
12033 || referenced_by_materialization
12034 {
12035 return Err(failure(
12036 NodeFailureCode::ContextPackBusy,
12037 "context pack is still referenced",
12038 ));
12039 }
12040 let mut catalog = self.context_catalog
12041 .write()
12042 .unwrap_or_else(|poisoned| poisoned.into_inner());
12043 if catalog.get(context_id).is_none() {
12044 return Err(failure(
12045 NodeFailureCode::UnknownContextPack,
12046 "context pack is unavailable",
12047 ));
12048 }
12049 self.spawn_idempotency
12050 .lock()
12051 .unwrap_or_else(|poisoned| poisoned.into_inner())
12052 .entries
12053 .retain(|_, entry| !entry.value.references_context(context_id));
12054 let removed = catalog.remove(context_id);
12055 debug_assert!(removed.is_some(), "validated context pack remains present");
12056 Ok(())
12057 }
12058
12059 async fn dispatch_input_bounded(
12060 &self,
12061 address: &SessionAddress,
12062 action: InputAction,
12063 ) -> Result<(), NodeFailure> {
12064 let started_after = self.current_sequence();
12065 self.pty_input_marks
12066 .lock()
12067 .unwrap_or_else(|poisoned| poisoned.into_inner())
12068 .insert(address.session.instance_id, (Instant::now(), false));
12069 self.pty_input_wake.notify_one();
12070 let command_id = self
12071 .dispatch_bounded(
12072 ControlCommand::SendInput {
12073 instance_id: address.session.instance_id,
12074 action,
12075 },
12076 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12077 )
12078 .await?;
12079 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12083 let mut scan_after = started_after;
12084 let mut accepted_after = None;
12085 loop {
12086 let events = self
12087 .history
12088 .lock()
12089 .unwrap_or_else(|poisoned| poisoned.into_inner())
12090 .events
12091 .iter()
12092 .filter(|envelope| envelope.sequence > scan_after)
12093 .cloned()
12094 .collect::<Vec<_>>();
12095 for envelope in events {
12096 scan_after = scan_after.max(envelope.sequence);
12097 let NodeEvent::Control {
12098 address: event_address,
12099 event,
12100 } = envelope.event
12101 else {
12102 continue;
12103 };
12104 if event_address != *address {
12105 continue;
12106 }
12107 if event.command_id == Some(command_id) {
12108 match &event.event {
12109 ControlEventKind::CommandRejected { message } => {
12110 return Err(failure(NodeFailureCode::InvalidRequest, message));
12111 }
12112 ControlEventKind::InputRequested { .. } => {
12113 accepted_after = Some(envelope.sequence);
12114 continue;
12115 }
12116 _ => {}
12117 }
12118 }
12119 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12120 match &event.event {
12121 ControlEventKind::InputCompleted { .. } => return Ok(()),
12122 ControlEventKind::InputFailed { message, .. } => {
12123 return Err(failure(
12124 NodeFailureCode::BackendOperationFailed,
12125 message,
12126 ));
12127 }
12128 _ => {}
12129 }
12130 }
12131 }
12132 if Instant::now() >= deadline {
12133 return Err(failure(
12134 NodeFailureCode::BackendBusy,
12135 if accepted_after.is_some() {
12136 "input was accepted but did not settle before the bounded deadline"
12137 } else {
12138 "input was not accepted before the bounded deadline"
12139 },
12140 ));
12141 }
12142 sleep(Duration::from_millis(2)).await;
12143 }
12144 }
12145
12146 async fn dispatch_resize_bounded(
12147 &self,
12148 address: &SessionAddress,
12149 size: gate4agent_types::TerminalSize,
12150 ) -> Result<(), NodeFailure> {
12151 let started_after = self.current_sequence();
12152 let command_id = self
12153 .dispatch_bounded(
12154 ControlCommand::Resize {
12155 instance_id: address.session.instance_id,
12156 size,
12157 },
12158 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12159 )
12160 .await?;
12161 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12162 let mut scan_after = started_after;
12163 let mut accepted_after = None;
12164 loop {
12165 let events = self
12166 .history
12167 .lock()
12168 .unwrap_or_else(|poisoned| poisoned.into_inner())
12169 .events
12170 .iter()
12171 .filter(|envelope| envelope.sequence > scan_after)
12172 .cloned()
12173 .collect::<Vec<_>>();
12174 for envelope in events {
12175 scan_after = scan_after.max(envelope.sequence);
12176 let NodeEvent::Control {
12177 address: event_address,
12178 event,
12179 } = envelope.event
12180 else {
12181 continue;
12182 };
12183 if event_address != *address {
12184 continue;
12185 }
12186 if event.command_id == Some(command_id) {
12187 match &event.event {
12188 ControlEventKind::CommandRejected { message } => {
12189 return Err(failure(NodeFailureCode::InvalidRequest, message));
12190 }
12191 ControlEventKind::ResizeRequested {
12192 size: requested_size,
12193 ..
12194 } if *requested_size == size => {
12195 accepted_after = Some(envelope.sequence);
12196 continue;
12197 }
12198 _ => {}
12199 }
12200 }
12201 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12202 match &event.event {
12203 ControlEventKind::Resized { size: settled_size }
12204 if *settled_size == size =>
12205 {
12206 return Ok(());
12207 }
12208 ControlEventKind::ResizeFailed { message } => {
12209 return Err(failure(
12210 NodeFailureCode::BackendOperationFailed,
12211 message,
12212 ));
12213 }
12214 _ => {}
12215 }
12216 }
12217 }
12218 if Instant::now() >= deadline {
12219 return Err(failure(
12220 NodeFailureCode::BackendBusy,
12221 if accepted_after.is_some() {
12222 "resize was accepted but did not settle before the bounded deadline"
12223 } else {
12224 "resize was not accepted before the bounded deadline"
12225 },
12226 ));
12227 }
12228 sleep(Duration::from_millis(2)).await;
12229 }
12230 }
12231
12232 async fn spawn_session(
12233 &self,
12234 workspace_id: WorkspaceId,
12235 provider: AgentId,
12236 mode: SessionMode,
12237 terminal_size: gate4agent_types::TerminalSize,
12238 initial_prompt: Option<String>,
12239 ) -> Result<SessionAddress, NodeFailure> {
12240 if self.managed_worktrees.lock()
12241 .unwrap_or_else(|poisoned| poisoned.into_inner())
12242 .lease_for_workspace(&workspace_id)
12243 .is_some()
12244 {
12245 return Err(failure(
12246 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12247 "managed worktree targets may only be spawned through their lease request",
12248 ));
12249 }
12250 self
12251 .spawn_session_with_deadline(
12252 workspace_id,
12253 provider,
12254 mode,
12255 terminal_size,
12256 initial_prompt,
12257 None,
12258 None,
12259 None,
12260 None,
12261 None,
12262 SpawnRecordPolicy::ProviderIdentityOnly,
12263 None,
12264 &[],
12265 None,
12266 ApprovalLevel::default(),
12272 None,
12273 None,
12274 )
12275 .await
12276 .map(|(session, _runtime_policy)| session)
12277 }
12278
12279 async fn spawn_session_with_deadline(
12280 &self,
12281 workspace_id: WorkspaceId,
12282 provider: AgentId,
12283 mode: SessionMode,
12284 terminal_size: gate4agent_types::TerminalSize,
12285 initial_prompt: Option<String>,
12286 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12287 bundle: Option<ResolvedBundleReceipt>,
12288 context: Option<ResolvedContextPackReceipt>,
12289 managed_authority: Option<ManagedWorktreeLeaseId>,
12290 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12291 record_policy: SpawnRecordPolicy,
12292 deadline: Option<Instant>,
12293 required_capabilities: &[ProviderRuntimeCapability],
12294 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12295 approval_level: ApprovalLevel,
12296 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12297 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12303 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12304 let reserved_lease = self.managed_worktrees.lock()
12305 .unwrap_or_else(|poisoned| poisoned.into_inner())
12306 .lease_for_workspace(&workspace_id);
12307 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12308 (Some(expected), Some(provided)) if expected == provided => {}
12309 (None, None) => {}
12310 _ => {
12311 return Err(failure(
12312 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12313 "spawn lacks exact managed worktree lease authority",
12314 ));
12315 }
12316 }
12317 self.ensure_workspace_environment_profile_compatible(
12321 &workspace_id,
12322 environment_profile.as_ref(),
12323 )?;
12324 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12325 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12326 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12327 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12328 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12329 };
12330 let transport = match mode {
12336 SessionMode::Pty => TransportKind::Pty,
12337 SessionMode::Inline => TransportKind::Pipe,
12338 SessionMode::Acp => TransportKind::Acp,
12339 };
12340 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12341 let acp_transport = self.provider_supports_acp_transport(&provider);
12342 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12343 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12344 NodeFailureCode::UnsupportedSpawnCapability,
12345 "pre-admitted provider runtime no longer satisfies spawn requirements",
12346 ))?;
12347 runtime_policy
12348 } else if let Some(deadline) = deadline {
12349 let remaining = spawn_deadline_remaining(deadline)?;
12350 timeout(
12351 remaining,
12352 self.admit_provider_runtime(&provider, runtime_requirement),
12353 )
12354 .await
12355 .map_err(|_| {
12356 failure(
12357 NodeFailureCode::SpawnDeadlineExceeded,
12358 "provider admission exceeded the spawn deadline",
12359 )
12360 })??
12361 } else {
12362 self.admit_provider_runtime(&provider, runtime_requirement).await?
12363 };
12364 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12365 let runtime_policy =
12377 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12378 if required_capabilities
12379 .iter()
12380 .any(|capability| !runtime_policy.admits(*capability))
12381 {
12382 return Err(failure(
12383 NodeFailureCode::UnsupportedSpawnCapability,
12384 "provider runtime does not admit a required spawn capability",
12385 ));
12386 }
12387 if let Some(deadline) = deadline {
12388 spawn_deadline_remaining(deadline)?;
12389 }
12390 self.ensure_binding_capacity()?;
12391 let working_directory = self.workspace_root(&workspace_id)?;
12392 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12393 let session = SessionKey {
12394 instance_id,
12395 generation: SessionGeneration(1),
12396 };
12397 let address = SessionAddress {
12398 workspace_id: workspace_id.clone(),
12399 session,
12400 };
12401 tracing::info!(
12409 node_id = %self.node_id,
12410 workspace_id = %workspace_id,
12411 provider = %provider,
12412 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12413 session = ?address.session,
12414 "session spawn request accepted",
12415 );
12416 let mut prepared_spawn = SpawnPreparer::prepare(
12417 self,
12418 SpawnPrepareRequest {
12419 instance_id,
12420 address: &address,
12421 provider: &provider,
12422 mode,
12423 environment_profile: environment_profile.as_ref(),
12424 bundle: bundle.as_ref(),
12425 context: context.as_ref(),
12426 managed_authority: managed_authority.as_ref(),
12427 harness_mcp,
12428 approval_level,
12429 network_allowlist: network_allowlist.as_ref(),
12430 browser_profile_id,
12431 },
12432 )?;
12433 let materialization_id = prepared_spawn.materialization_id.take();
12434 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12435 let record_id = self.bind_spawn_session_with_materialization(
12436 &address,
12437 provider.clone(),
12438 mode,
12439 runtime_policy,
12440 record_policy,
12441 environment_profile,
12442 bundle,
12443 context,
12444 materialization_id,
12445 )?;
12446 prepared_spawn.retain_session_guards();
12447 let agent_id = provider;
12451 let register_command_id = match self.dispatch_bounded(
12452 ControlCommand::Register {
12453 instance_id,
12454 agent_id,
12455 transport,
12456 },
12457 dispatch_timeout,
12458 )
12459 .await {
12460 Ok(command_id) => command_id,
12461 Err(error) => {
12462 if let Some(binding) = self.remove_binding(&address) {
12463 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12464 }
12465 if let Some(record_id) = record_id.as_ref() {
12466 self.discard_record(record_id)?;
12467 }
12468 return Err(spawn_dispatch_error(error, deadline));
12469 }
12470 };
12471 let start_timeout = match spawn_dispatch_timeout(deadline) {
12472 Ok(timeout) => timeout,
12473 Err(error) => {
12474 let recovery = self
12475 .rollback_spawn(
12476 &address,
12477 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12478 )
12479 .await;
12480 return match recovery {
12481 Ok(()) => {
12482 if let Some(record_id) = record_id.as_ref() {
12483 self.discard_record(record_id)?;
12484 }
12485 Err(error)
12486 }
12487 Err(recovery_error) => {
12488 if let Some(record_id) = record_id.as_ref() {
12489 self.mark_record_error(
12490 record_id,
12491 "spawn deadline elapsed and registration recovery failed",
12492 )?;
12493 }
12494 Err(recovery_error)
12495 }
12496 };
12497 }
12498 };
12499 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12500 if let Err(recovery_error) = self
12501 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12502 .await
12503 {
12504 return Err(recovery_error);
12505 }
12506 if let Some(record_id) = record_id.as_ref() {
12507 self.discard_record(record_id)?;
12508 }
12509 return Err(start_error);
12510 }
12511 let start_result = self
12512 .dispatch_bounded(
12513 ControlCommand::Start {
12514 instance_id,
12515 runtime_policy,
12516 request: StartRequest {
12517 working_directory,
12518 terminal_size,
12519 initial_prompt,
12520 session_options: None,
12521 approval_level,
12522 },
12523 },
12524 start_timeout,
12525 )
12526 .await;
12527 let start_command_id = match start_result {
12528 Ok(command_id) => command_id,
12529 Err(start_error) => {
12530 let start_error = spawn_dispatch_error(start_error, deadline);
12531 let recovery = self
12532 .rollback_spawn(
12533 &address,
12534 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12535 )
12536 .await;
12537 return match recovery {
12538 Ok(()) => {
12539 if let Some(record_id) = record_id.as_ref() {
12540 self.discard_record(record_id)?;
12541 }
12542 Err(start_error)
12543 }
12544 Err(recovery_error) => {
12545 let diagnostic = format!(
12546 "start failed and registration recovery failed: {}",
12547 recovery_error.message,
12548 );
12549 if let Some(record_id) = record_id.as_ref() {
12550 self.mark_record_error(record_id, &diagnostic)?;
12551 }
12552 Err(failure(recovery_error.code, &diagnostic))
12553 }
12554 };
12555 }
12556 };
12557
12558 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12559 loop {
12560 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12569 .or_else(|| self.take_rejected_command_reason(start_command_id))
12570 {
12571 self.rollback_spawn(
12572 &address,
12573 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12574 ).await.map_err(|error| {
12575 failure(
12576 error.code,
12577 &format!(
12578 "spawn was rejected ({}) and registration recovery failed: {}",
12579 reason.message, error.message
12580 ),
12581 )
12582 })?;
12583 if let Some(record_id) = record_id.as_ref() {
12584 self.discard_record(record_id)?;
12585 }
12586 let code = if reason.unsupported_transport {
12587 NodeFailureCode::UnsupportedTransport
12588 } else {
12589 NodeFailureCode::BackendOperationFailed
12590 };
12591 return Err(failure(code, &reason.message));
12592 }
12593 if let Some(current) = self
12594 .handle
12595 .snapshot()
12596 .sessions
12597 .iter()
12598 .find(|current| current.instance_id == instance_id)
12599 {
12600 if current.generation != session.generation {
12601 let divergence = failure(
12602 NodeFailureCode::StaleGeneration,
12603 "spawn generation diverged",
12604 );
12605 return match self
12606 .rollback_spawn(
12607 &address,
12608 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12609 )
12610 .await
12611 {
12612 Ok(()) => {
12613 if let Some(record_id) = record_id.as_ref() {
12614 self.discard_record(record_id)?;
12615 }
12616 Err(divergence)
12617 }
12618 Err(recovery_error) => {
12619 if let Some(record_id) = record_id.as_ref() {
12620 self.mark_record_error(
12621 record_id,
12622 "spawn generation diverged and recovery failed",
12623 )?;
12624 }
12625 Err(recovery_error)
12626 }
12627 };
12628 }
12629 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12630 prepared_spawn.retain_launch_guards();
12631 return Ok((address, runtime_policy));
12632 }
12633 }
12634 if Instant::now() >= commit_deadline {
12635 self.rollback_spawn(
12636 &address,
12637 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12638 ).await.map_err(|error| {
12639 failure(
12640 error.code,
12641 &format!(
12642 "start did not commit and registration recovery failed: {}",
12643 error.message
12644 ),
12645 )
12646 })?;
12647 if let Some(record_id) = record_id.as_ref() {
12648 self.discard_record(record_id)?;
12649 }
12650 return Err(failure(
12651 if deadline.is_some() {
12652 NodeFailureCode::SpawnDeadlineExceeded
12653 } else {
12654 NodeFailureCode::BackendBusy
12655 },
12656 "start did not commit before the bounded deadline; registration was removed",
12657 ));
12658 }
12659 sleep(Duration::from_millis(2)).await;
12660 }
12661 }
12662
12663 fn effective_spawn_runtime_policy(
12664 &self,
12665 runtime_policy: ProviderRuntimePolicy,
12666 ) -> ProviderRuntimePolicy {
12667 #[cfg(feature = "fixture")]
12668 let runtime_policy = if self.fixture_semantic_hook_policy {
12669 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12670 .expect("monitoring Hook fixture policy is internally valid")
12671 } else {
12672 runtime_policy
12673 };
12674 runtime_policy
12675 }
12676
12677 fn verify_harness_mcp_before_start(
12678 &self,
12679 prepared: Option<&PreparedHarnessMcpSpawn>,
12680 ) -> Result<(), NodeFailure> {
12681 prepared
12682 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12683 .transpose()
12684 .map(|_| ())
12685 .map_err(harness_mcp_failure)
12686 }
12687
12688 async fn rollback_spawn(
12689 &self,
12690 address: &SessionAddress,
12691 commit_timeout: Duration,
12692 ) -> Result<(), NodeFailure> {
12693 self.dispatch_bounded(
12694 ControlCommand::Remove {
12695 instance_id: address.session.instance_id,
12696 },
12697 commit_timeout,
12698 )
12699 .await?;
12700 self.wait_until_removed(address, commit_timeout).await?;
12701 if let Some(binding) = self.remove_binding(address) {
12702 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12703 }
12704 Ok(())
12705 }
12706
12707 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12708 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12709 self.dispatch_bounded(
12710 ControlCommand::Remove {
12711 instance_id: address.session.instance_id,
12712 },
12713 commit_timeout,
12714 )
12715 .await?;
12716 self.wait_until_removed(address, commit_timeout).await?;
12717 let removed = self.remove_binding(address);
12718 if let Some(binding) = removed.as_ref() {
12719 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12720 }
12721 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12722 let snapshot = self.managed_worktrees.lock()
12723 .unwrap_or_else(|poisoned| poisoned.into_inner())
12724 .release_session(
12725 &lease_id,
12726 self.incarnation_id,
12727 address.session.instance_id,
12728 address.session.generation,
12729 unix_time_ms(),
12730 );
12731 if self.persist_state().is_err() {
12732 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12733 }
12734 if let Some(snapshot) = snapshot {
12735 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12736 }
12737 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12738 }
12739 Ok(())
12740 }
12741
12742 async fn wait_until_removed(
12788 &self,
12789 address: &SessionAddress,
12790 commit_timeout: Duration,
12791 ) -> Result<(), NodeFailure> {
12792 let deadline = Instant::now() + commit_timeout;
12793 let mut next_dispatch = Instant::now();
12794 loop {
12795 let snapshot = self.handle.snapshot();
12796 let status = snapshot
12797 .sessions
12798 .iter()
12799 .find(|session| session.instance_id == address.session.instance_id)
12800 .map(|session| session.status.clone());
12801 let Some(status) = status else {
12802 return Ok(());
12803 };
12804 let now = Instant::now();
12805 if now >= deadline {
12806 return Err(failure(
12807 NodeFailureCode::BackendBusy,
12808 "remove did not commit before the bounded deadline; session binding was retained",
12809 ));
12810 }
12811 if status.allows_remove() && now >= next_dispatch {
12812 let _ = self.dispatch(ControlCommand::Remove {
12813 instance_id: address.session.instance_id,
12814 });
12815 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12816 }
12817 sleep(Duration::from_millis(2)).await;
12818 }
12819 }
12820
12821 fn resync(&self, after_sequence: u64) -> NodeResponse {
12822 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12823 let event_sequence = history.last_sequence;
12824 let oldest_available_sequence = history.replay_floor_sequence;
12825 let events = history
12826 .events
12827 .iter()
12828 .filter(|event| event.sequence > after_sequence)
12829 .cloned()
12830 .collect();
12831 drop(history);
12832 NodeResponse::Resync {
12833 event_sequence,
12834 oldest_available_sequence,
12835 snapshot: self.snapshot(),
12836 events,
12837 }
12838 }
12839}
12840
12841fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12846 match kind {
12847 AgentStreamChunkKindV1::Text { .. } => "text",
12848 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12849 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12850 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12851 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12852 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12853 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12854 }
12855}
12856
12857async fn accept_connections(
12871 endpoint: &str,
12872 call_home: Option<std::net::SocketAddr>,
12873 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12874 shared: Arc<NodeShared>,
12875) -> Result<(), NodeServerError> {
12876 let mut connections = JoinSet::new();
12877 let result = match call_home {
12878 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12879 Some(relay) => {
12880 let listener =
12881 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12882 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12883 tokio::pin!(listener);
12884 tokio::pin!(dialler);
12885 tokio::select! {
12886 result = &mut listener => result,
12887 result = &mut dialler => result,
12888 }
12889 }
12890 };
12891 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12892 while !connections.is_empty() {
12893 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12894 if remaining.is_zero()
12895 || timeout(remaining, connections.join_next()).await.is_err()
12896 {
12897 break;
12898 }
12899 }
12900 connections.abort_all();
12901 while connections.join_next().await.is_some() {}
12902 result
12903}
12904
12905const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12909
12910async fn call_home_loop(
12924 relay: std::net::SocketAddr,
12925 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12926 shared: Arc<NodeShared>,
12927) -> Result<(), NodeServerError> {
12928 let mut failures = 0_usize;
12929 loop {
12930 if shared.shutdown.load(Ordering::Acquire) {
12931 return Ok(());
12932 }
12933 let permit = tokio::select! {
12934 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12935 permit.map_err(|_| NodeServerError::PreauthClosed)?
12936 }
12937 _ = shared.shutdown_notify.notified() => return Ok(()),
12938 };
12939 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
12940 Ok(()) => {
12941 failures = 0;
12945 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12946 }
12947 Err(error) => {
12948 failures = failures.saturating_add(1);
12949 tracing::warn!(
12950 node_id = %shared.node_id,
12951 %relay,
12952 cause = %error,
12953 attempt = failures,
12954 "call-home connection failed",
12955 );
12956 let backoff = CALL_HOME_BACKOFFS_MS
12957 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12958 tokio::select! {
12959 _ = sleep(Duration::from_millis(backoff)) => {}
12960 _ = shared.shutdown_notify.notified() => return Ok(()),
12961 }
12962 }
12963 }
12964 }
12965}
12966
12967async fn dial_relay_and_serve(
12969 relay: std::net::SocketAddr,
12970 identity: Option<&hatchery_enroll::NodeIdentity>,
12971 shared: Arc<NodeShared>,
12972 preauth_permit: OwnedSemaphorePermit,
12973) -> Result<(), NodeServerError> {
12974 let mut stream = tokio::net::TcpStream::connect(relay)
12975 .await
12976 .map_err(NodeServerError::CallHomeTransport)?;
12977 let _ = stream.set_nodelay(true);
12981 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12982 .await
12983 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12984 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12985 let mut session_token = None;
12986 if let Some(identity) = identity {
12987 let mut magic = [0_u8; 4];
12991 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
12992 .await
12993 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
12994 .map_err(NodeServerError::CallHomeTransport)?;
12995 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
12996 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
12997 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
12998 .await
12999 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13000 .map_err(NodeServerError::CallHomeTransport)?;
13001 let (answer, token) = identity
13002 .answer_challenge(shared.node_id.as_str(), &frame)
13003 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13004 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13005 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13006 session_token = Some(token);
13007 }
13008 }
13009 serve_connection(stream, shared, session_token, preauth_permit).await
13010}
13011
13012async fn accept_connections_inner(
13013 endpoint: &str,
13014 shared: Arc<NodeShared>,
13015 connections: &mut JoinSet<Result<(), NodeServerError>>,
13016) -> Result<(), NodeServerError> {
13017 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13018 loop {
13019 while connections.try_join_next().is_some() {}
13020 if shared.shutdown.load(Ordering::Acquire) {
13021 return Ok(());
13022 }
13023 let preauth_permit = tokio::select! {
13024 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13025 permit.map_err(|_| NodeServerError::PreauthClosed)?
13026 }
13027 _ = shared.shutdown_notify.notified() => return Ok(()),
13028 };
13029 let server = tokio::select! {
13030 result = listener.accept() => result?,
13031 _ = shared.shutdown_notify.notified() => return Ok(()),
13032 };
13033 let connection_shared = Arc::clone(&shared);
13034 connections.spawn(async move {
13035 serve_connection(server, connection_shared, None, preauth_permit).await
13036 });
13037 if shared.shutdown.load(Ordering::Acquire) {
13038 return Ok(());
13039 }
13040 }
13041}
13042
13043async fn serve_connection<S>(
13051 mut pipe: S,
13052 shared: Arc<NodeShared>,
13053 session_token: Option<String>,
13054 preauth_permit: OwnedSemaphorePermit,
13055) -> Result<(), NodeServerError>
13056where
13057 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13058{
13059 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13061 let hello_frame = timeout(
13062 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13063 read_json_frame_limited_body_timeout(
13064 &mut pipe,
13065 MAX_NODE_HELLO_FRAME_BYTES,
13066 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13067 ),
13068 )
13069 .await
13070 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13071 let ClientFrame::Hello(hello) = hello_frame else {
13072 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13073 };
13074 if hello.build_stamp != BUILD_STAMP {
13075 return Err(NodeServerError::Handshake(format!(
13076 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13077 hello.build_stamp,
13078 )));
13079 }
13080 let compatibility = match hello.compatibility.as_ref() {
13081 Some(offer) => {
13082 let mut selected = node_compatibility_support(&shared)?
13083 .negotiate(offer)
13084 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13085 project_negotiated_provider_ids(&mut selected);
13086 Some(selected)
13087 }
13088 None => None,
13089 };
13090 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13091 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13092 (Some(offer), Some(selected)) => negotiated_auth_proof(
13093 auth_token,
13094 AuthDirection::Server,
13095 hello.role,
13096 &hello.client_nonce,
13097 &server_nonce,
13098 offer,
13099 selected,
13100 ),
13101 (None, None) => auth_proof(
13102 auth_token,
13103 AuthDirection::Server,
13104 hello.role,
13105 &hello.client_nonce,
13106 &server_nonce,
13107 ),
13108 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13109 }
13110 .map_err(NodeServerError::Authentication)?;
13111 write_json_frame_limited(
13112 &mut pipe,
13113 &ServerFrame::Challenge(ServerChallenge {
13114 build_stamp: BUILD_STAMP.to_owned(),
13115 server_nonce,
13116 server_proof,
13117 compatibility: compatibility.clone(),
13118 }),
13119 MAX_NODE_HELLO_FRAME_BYTES,
13120 )
13121 .await?;
13122 let authentication = timeout(
13123 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13124 read_json_frame_limited_body_timeout(
13125 &mut pipe,
13126 MAX_NODE_HELLO_FRAME_BYTES,
13127 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13128 ),
13129 )
13130 .await
13131 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13132 let ClientFrame::Authenticate(authentication) = authentication else {
13133 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13134 };
13135 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13136 (Some(offer), Some(selected)) => negotiated_auth_proof(
13137 auth_token,
13138 AuthDirection::Client,
13139 hello.role,
13140 &hello.client_nonce,
13141 &server_nonce,
13142 offer,
13143 selected,
13144 ),
13145 (None, None) => auth_proof(
13146 auth_token,
13147 AuthDirection::Client,
13148 hello.role,
13149 &hello.client_nonce,
13150 &server_nonce,
13151 ),
13152 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13153 }
13154 .map_err(NodeServerError::Authentication)?;
13155 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13156 return Err(NodeServerError::Handshake("access denied".to_owned()));
13157 }
13158 let selected_capabilities = compatibility
13159 .as_ref()
13160 .map(|selected| selected.capabilities.clone())
13161 .unwrap_or_default();
13162 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13163 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13164 });
13165 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13166 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13167 });
13168 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13169 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13170 });
13171 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13172 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13173 });
13174 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13175 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13176 });
13177 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13178 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13179 }) && selected_capabilities.iter().any(|capability| {
13180 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13181 });
13182 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13183 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13184 });
13185 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13186 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13187 });
13188 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13189 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13190 });
13191 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13192 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13193 });
13194 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13195 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13196 });
13197 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13198 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13199 });
13200 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13201 .try_acquire_owned()
13202 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13203 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13204 let _release_guard = ControllerReleaseGuard {
13205 shared: Arc::clone(&shared),
13206 connection_id,
13207 };
13208 drop(preauth_permit);
13209 let _authenticated_permit = authenticated_permit;
13210 let mut event_rx = shared.event_tx.subscribe();
13211 let mut terminal_event_rx = terminal_event_subscription(
13212 &shared,
13213 include_terminal_frame_events,
13214 );
13215 let mut agent_stream_event_rx = agent_stream_event_subscription(
13216 &shared,
13217 include_agent_stream_events,
13218 );
13219 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13220 &shared,
13221 include_harness_mcp_proxy,
13222 );
13223 write_json_frame(
13224 &mut pipe,
13225 &ServerFrame::Hello(NodeHello {
13226 build_stamp: BUILD_STAMP.to_owned(),
13227 incarnation_id: shared.incarnation_id,
13228 connection_id,
13229 role: hello.role,
13230 event_sequence: shared.current_sequence(),
13231 controller: shared.controller_state(),
13232 snapshot: snapshot_for_wire(
13233 &shared,
13234 include_provider_runtime_status,
13235 include_open_provider_ids,
13236 include_spawn_profiles,
13237 include_managed_worktrees,
13238 include_child_environment_profiles,
13239 include_session_bundles,
13240 include_history_context_packs,
13241 include_agent_progress,
13242 include_session_task_correlation,
13243 ),
13244 compatibility,
13245 }),
13246 )
13247 .await?;
13248 let (mut reader, mut writer) = tokio::io::split(pipe);
13249 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13250 let reader_task = tokio::spawn(async move {
13251 loop {
13252 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13253 &mut reader,
13254 MAX_NODE_CLIENT_FRAME_BYTES,
13255 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13256 )
13257 .await;
13258 let terminal = frame.is_err();
13259 if frame_tx.send(frame).await.is_err() || terminal {
13260 break;
13261 }
13262 }
13263 });
13264 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13265 let mut pending_events = Vec::new();
13266 let mut resync_required = false;
13267 let mut discard_events_through = 0_u64;
13268 loop {
13269 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13274 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13275 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13276 for _ in 0..durable_drain_budget {
13277 match event_rx.try_recv() {
13278 Ok(event) => queue_connection_event(
13279 &mut pending_events,
13280 event,
13281 discard_events_through,
13282 ),
13283 Err(broadcast::error::TryRecvError::Empty) => break,
13284 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13285 resync_required = true;
13286 break;
13287 }
13288 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13289 }
13290 }
13291 if let Some(receiver) = terminal_event_rx.as_mut() {
13292 match receiver.try_recv() {
13293 Ok(events) => queue_connection_event_batch(
13294 &mut pending_events,
13295 &events,
13296 discard_events_through,
13297 ),
13298 Err(broadcast::error::TryRecvError::Empty) => {}
13299 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13300 resync_required = true;
13301 }
13302 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13303 }
13304 }
13305 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13306 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13316 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13317 for _ in 0..agent_stream_drain_budget {
13318 match receiver.try_recv() {
13319 Ok(events) => queue_connection_event_batch(
13320 &mut pending_events,
13321 &events,
13322 discard_events_through,
13323 ),
13324 Err(broadcast::error::TryRecvError::Empty) => break,
13325 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13326 tracing::warn!(
13327 connection_id,
13328 dropped,
13329 "agent stream event subscription lagged; forcing a resync",
13330 );
13331 resync_required = true;
13332 break;
13333 }
13334 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13335 }
13336 }
13337 }
13338 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13339 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13345 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13346 for _ in 0..harness_mcp_proxy_drain_budget {
13347 match receiver.try_recv() {
13348 Ok(events) => queue_connection_event_batch(
13349 &mut pending_events,
13350 &events,
13351 discard_events_through,
13352 ),
13353 Err(broadcast::error::TryRecvError::Empty) => break,
13354 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13355 tracing::warn!(
13356 connection_id,
13357 dropped,
13358 "harness MCP proxy event subscription lagged; forcing a resync",
13359 );
13360 resync_required = true;
13361 break;
13362 }
13363 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13364 }
13365 }
13366 }
13367 if resync_required {
13368 let event = resync_required_event(&shared);
13369 apply_connection_resync_watermark(
13370 &mut pending_events,
13371 &mut discard_events_through,
13372 event.sequence,
13373 );
13374 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13375 resync_required = false;
13376 continue;
13377 }
13378 if !pending_events.is_empty() {
13379 pending_events.sort_unstable_by_key(|event| event.sequence);
13380 let burst_len = connection_event_burst_len(pending_events.len());
13381 shared
13382 .connection_events_sent
13383 .fetch_add(burst_len as u64, Ordering::Relaxed);
13384 for event in pending_events.drain(..burst_len) {
13385 let event = if include_managed_worktrees {
13386 Some(event)
13387 } else {
13388 project_event_without_managed_worktrees(event)
13389 };
13390 let event = event.and_then(|event| if include_open_provider_ids {
13391 Some(event)
13392 } else {
13393 project_event_legacy_provider_ids(&shared, event)
13394 });
13395 let event = event.map(|event| if include_child_environment_profiles {
13396 event
13397 } else {
13398 project_event_without_child_environment_profile(event)
13399 });
13400 let event = event.map(|event| if include_session_bundles {
13401 event
13402 } else {
13403 project_event_without_session_bundle(event)
13404 });
13405 let event = event.map(|event| {
13406 project_event_history_for_wire(event, include_history_context_packs)
13407 });
13408 let event = event.map(|event| if include_history_context_packs {
13409 event
13410 } else {
13411 project_event_without_context_pack(event)
13412 });
13413 let event = event.map(|event| if include_session_task_correlation {
13414 event
13415 } else {
13416 project_event_without_session_task_binding(event)
13417 });
13418 let exact_harness_controller = hello.role == ClientRole::Operator
13419 && shared.controller_state().is_some_and(|controller| {
13420 controller.connection_id == connection_id
13421 });
13422 let event = event.and_then(|event| {
13423 if include_harness_mcp_proxy && exact_harness_controller {
13424 Some(event)
13425 } else {
13426 project_event_without_harness_mcp_proxy(event)
13427 }
13428 });
13429 if let Some(event) = event {
13430 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13431 }
13432 }
13433 }
13434 tokio::select! {
13435 biased;
13436 frame = frame_rx.recv() => {
13437 let frame = match frame {
13438 Some(frame) => frame,
13439 None => break,
13440 };
13441 let frame = match frame {
13442 Ok(frame) => frame,
13443 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13444 Err(error) => return Err(error.into()),
13445 };
13446 let ClientFrame::Request(request) = frame else {
13447 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13448 };
13449 #[cfg(feature = "fixture")]
13450 let probe_spawn_managed_worktree_v2 = matches!(
13451 &request.request,
13452 NodeRequest::SpawnManagedWorktreeV2 { .. }
13453 );
13454 let requires_open_provider_ids =
13455 request_requires_open_provider_ids(&shared, &request.request);
13456 let requires_child_environment_profile =
13457 request_requires_child_environment_profile(&shared, &request.request);
13458 let requires_session_bundle =
13459 request_requires_session_bundle(&shared, &request.request);
13460 let requires_history_context_pack =
13461 request_requires_history_context_pack(&shared, &request.request);
13462 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13463 ResponseEnvelope {
13464 request_id: request.request_id,
13465 result: Err(failure(
13466 NodeFailureCode::UnsupportedCapability,
13467 "open provider IDs were not negotiated",
13468 )),
13469 }
13470 } else if requires_child_environment_profile
13471 && !include_child_environment_profiles
13472 {
13473 ResponseEnvelope {
13474 request_id: request.request_id,
13475 result: Err(failure(
13476 NodeFailureCode::UnsupportedCapability,
13477 "child environment profiles were not negotiated",
13478 )),
13479 }
13480 } else if requires_session_bundle && !include_session_bundles {
13481 ResponseEnvelope {
13482 request_id: request.request_id,
13483 result: Err(failure(
13484 NodeFailureCode::UnsupportedCapability,
13485 "session bundles were not negotiated",
13486 )),
13487 }
13488 } else if requires_history_context_pack && !include_history_context_packs {
13489 ResponseEnvelope {
13490 request_id: request.request_id,
13491 result: Err(failure(
13492 NodeFailureCode::UnsupportedCapability,
13493 "history context packs were not negotiated",
13494 )),
13495 }
13496 } else if request_uses_unnegotiated_capability(
13497 &request.request,
13498 &selected_capabilities,
13499 )
13500 {
13501 ResponseEnvelope {
13502 request_id: request.request_id,
13503 result: Err(failure(
13504 NodeFailureCode::UnsupportedCapability,
13505 "request capability was not negotiated",
13506 )),
13507 }
13508 } else {
13509 process_request(&shared, connection_id, hello.role, request).await
13510 };
13511 #[cfg(feature = "fixture")]
13512 if probe_spawn_managed_worktree_v2 {
13513 shared
13514 .fixture_spawn_managed_worktree_v2_failure_probe
13515 .record(reply.result.as_ref().err().map(|failure| failure.code));
13516 }
13517 if !include_provider_runtime_status {
13518 clear_response_provider_runtime_status(&mut reply);
13519 }
13520 if !include_managed_worktrees {
13521 project_response_without_managed_worktrees(&mut reply);
13522 }
13523 if !include_open_provider_ids {
13524 project_response_legacy_provider_ids(&shared, &mut reply);
13525 }
13526 if !include_child_environment_profiles {
13527 project_response_without_child_environment_profile(&mut reply);
13528 }
13529 if !include_session_bundles {
13530 project_response_without_session_bundle(&mut reply);
13531 }
13532 project_response_history_for_wire(
13533 &mut reply,
13534 include_history_context_packs,
13535 );
13536 if !include_history_context_packs {
13537 project_response_without_context_pack(&mut reply);
13538 }
13539 if !include_agent_progress {
13540 project_response_without_agent_progress(&mut reply);
13541 }
13542 if !include_session_task_correlation {
13543 project_response_without_session_task_binding(&mut reply);
13544 }
13545 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13546 }
13547 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13548 event = event_rx.recv() => {
13549 match event {
13550 Ok(event) => queue_connection_event(
13551 &mut pending_events,
13552 event,
13553 discard_events_through,
13554 ),
13555 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13556 Err(broadcast::error::RecvError::Closed) => break,
13557 }
13558 }
13559 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13560 match events {
13561 Ok(events) => queue_connection_event_batch(
13562 &mut pending_events,
13563 &events,
13564 discard_events_through,
13565 ),
13566 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13567 Err(broadcast::error::RecvError::Closed) => break,
13568 }
13569 }
13570 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13571 match events {
13572 Ok(events) => queue_connection_event_batch(
13573 &mut pending_events,
13574 &events,
13575 discard_events_through,
13576 ),
13577 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13578 Err(broadcast::error::RecvError::Closed) => break,
13579 }
13580 }
13581 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13582 match events {
13583 Ok(events) => queue_connection_event_batch(
13584 &mut pending_events,
13585 &events,
13586 discard_events_through,
13587 ),
13588 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13589 Err(broadcast::error::RecvError::Closed) => break,
13590 }
13591 }
13592 _ = shared.shutdown_notify.notified() => break,
13593 }
13594 }
13595 Ok(())
13596}
13597
13598async fn receive_terminal_event_batch(
13599 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13600) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13601 match receiver {
13602 Some(receiver) => receiver.recv().await,
13603 None => std::future::pending().await,
13604 }
13605}
13606
13607fn terminal_event_subscription(
13608 shared: &NodeShared,
13609 enabled: bool,
13610) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13611 enabled.then(|| shared.terminal_event_tx.subscribe())
13612}
13613
13614fn agent_stream_event_subscription(
13618 shared: &NodeShared,
13619 enabled: bool,
13620) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13621 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13622}
13623
13624fn harness_mcp_proxy_event_subscription(
13629 shared: &NodeShared,
13630 enabled: bool,
13631) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13632 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13633}
13634
13635fn queue_connection_event(
13636 pending: &mut Vec<NodeEventEnvelope>,
13637 event: NodeEventEnvelope,
13638 discard_through: u64,
13639) {
13640 if event.sequence <= discard_through
13641 && !event.event.requires_harness_mcp_proxy_capability()
13642 {
13643 return;
13644 }
13645 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13646 if let Some(index) = pending.iter().position(|current| {
13647 matches!(
13648 ¤t.event,
13649 NodeEvent::TerminalFrame {
13650 address: current_address,
13651 ..
13652 } if current_address == address
13653 )
13654 }) {
13655 pending.remove(index);
13656 }
13657 }
13658 pending.push(event);
13659}
13660
13661fn project_event_without_harness_mcp_proxy(
13662 envelope: NodeEventEnvelope,
13663) -> Option<NodeEventEnvelope> {
13664 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13665}
13666
13667fn queue_connection_event_batch(
13668 pending: &mut Vec<NodeEventEnvelope>,
13669 events: &[NodeEventEnvelope],
13670 discard_through: u64,
13671) {
13672 for event in events {
13673 queue_connection_event(pending, event.clone(), discard_through);
13674 }
13675}
13676
13677fn apply_connection_resync_watermark(
13678 pending: &mut Vec<NodeEventEnvelope>,
13679 discard_through: &mut u64,
13680 marker_sequence: u64,
13681) {
13682 *discard_through = (*discard_through).max(marker_sequence);
13683 pending.retain(|event| event.sequence > *discard_through);
13684}
13685
13686fn connection_event_burst_len(pending_len: usize) -> usize {
13687 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13688}
13689
13690fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13691 let history = shared
13692 .history
13693 .lock()
13694 .unwrap_or_else(|poisoned| poisoned.into_inner());
13695 let sequence = history.last_sequence;
13696 let oldest_available_sequence = history.replay_floor_sequence;
13697 NodeEventEnvelope {
13698 sequence,
13699 event: NodeEvent::ResyncRequired {
13700 oldest_available_sequence,
13701 },
13702 }
13703}
13704
13705fn node_compatibility_support(
13706 shared: &NodeShared,
13707) -> Result<NodeCompatibilitySupport, NodeServerError> {
13708 let mut support = node_compatibility_support_for_manifest(
13709 &shared.provider_contracts,
13710 &shared.provider_adapter_contracts,
13711 )?;
13712 if shared
13713 .delivery_store
13714 .lock()
13715 .unwrap_or_else(|poisoned| poisoned.into_inner())
13716 .is_some()
13717 {
13718 support.capabilities.push(
13719 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13720 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13721 );
13722 }
13723 if shared.harness_mcp_registry.is_some() {
13724 support.capabilities.push(
13725 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13726 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13727 );
13728 }
13729 Ok(support)
13730}
13731
13732fn node_compatibility_support_for_manifest(
13733 provider_contracts: &[ProviderContractSupport],
13734 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13735) -> Result<NodeCompatibilitySupport, NodeServerError> {
13736 Ok(NodeCompatibilitySupport {
13737 build_stamp: BUILD_STAMP.to_owned(),
13738 capabilities: baseline_capabilities()?,
13739 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13740 path_semantics: platform::path_semantics(),
13741 local_transport: platform::local_transport(),
13742 state_schema: StateSchemaSupport {
13743 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13744 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13745 },
13746 provider_contracts: provider_contracts.to_vec(),
13747 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13748 })
13749}
13750
13751fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13752 let capabilities = [
13753 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13754 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13755 NODE_REPOSITORY_PATH_CAPABILITY,
13756 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13757 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13758 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13759 NODE_GIT_READ_CAPABILITY,
13760 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13761 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13762 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13763 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13764 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13765 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13766 NODE_ACP_CONTROL_CAPABILITY,
13767 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13768 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13769 NODE_WORKTREE_SELECTION_CAPABILITY,
13770 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13771 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13772 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13773 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13774 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13775 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13776 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13777 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13778 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13779 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13780 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13781 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13782 ]
13783 .into_iter()
13784 .map(|capability| {
13785 CapabilityId::new(capability)
13786 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13787 })
13788 .collect::<Result<Vec<_>, _>>()?;
13789 #[cfg(windows)]
13790 let capabilities = {
13791 let mut capabilities = capabilities;
13792 capabilities.push(
13793 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13794 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13795 );
13796 capabilities
13797 };
13798 Ok(capabilities)
13799}
13800
13801fn request_uses_unnegotiated_capability(
13802 request: &NodeRequest,
13803 selected_capabilities: &[CapabilityId],
13804) -> bool {
13805 let selected = |required: &str| {
13806 selected_capabilities
13807 .iter()
13808 .any(|capability| capability.as_str() == required)
13809 };
13810 request
13811 .required_capability()
13812 .is_some_and(|required| !selected(required))
13813 || (request.requires_worktree_selection_capability()
13814 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13815 || (request.requires_spawn_spec_defaults_overrides_capability()
13816 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13817 || (request.requires_spawn_profile_revision_capability()
13818 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13819 || (request.requires_child_environment_profile_capability()
13820 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13821 || (request.requires_session_bundle_materialization_capability()
13822 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13823 || (request.requires_history_context_pack_capability()
13824 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13825}
13826
13827fn snapshot_for_wire(
13828 shared: &NodeShared,
13829 include_provider_runtime_status: bool,
13830 include_open_provider_ids: bool,
13831 include_spawn_profiles: bool,
13832 include_managed_worktrees: bool,
13833 include_child_environment_profiles: bool,
13834 include_session_bundles: bool,
13835 include_history_context_packs: bool,
13836 include_agent_progress: bool,
13837 include_session_task_correlation: bool,
13838) -> NodeSnapshot {
13839 let mut snapshot = shared.snapshot();
13840 if !include_provider_runtime_status {
13841 snapshot.provider_runtime_statuses.clear();
13842 }
13843 if !include_open_provider_ids {
13844 project_snapshot_legacy_provider_ids(&mut snapshot);
13845 }
13846 if !include_managed_worktrees {
13847 snapshot.managed_worktrees.clear();
13848 for workspace in &mut snapshot.workspaces {
13849 workspace.managed_worktree_profiles = None;
13850 }
13851 }
13852 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13853 if !include_spawn_profiles {
13854 inventory.spawn_profiles = None;
13855 }
13856 if !include_session_bundles {
13857 inventory.bundles = None;
13858 }
13859 if inventory.spawn_profiles.is_none()
13860 && inventory.bundles.is_none()
13861 && inventory.network_allowlists.is_none()
13862 {
13863 snapshot.launch_inventory = None;
13864 }
13865 }
13866 if !include_child_environment_profiles {
13867 clear_snapshot_child_environment_profiles(&mut snapshot);
13868 }
13869 if !include_session_bundles {
13870 clear_snapshot_session_bundles(&mut snapshot);
13871 }
13872 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13873 if !include_history_context_packs {
13874 clear_snapshot_context_packs(&mut snapshot);
13875 }
13876 if !include_agent_progress {
13877 snapshot.agent_progress.clear();
13878 }
13879 if !include_session_task_correlation {
13880 clear_snapshot_session_task_bindings(&mut snapshot);
13881 }
13882 snapshot
13883}
13884
13885fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13886 for record in &mut snapshot.session_records {
13887 record.task_binding = None;
13888 }
13889}
13890
13891fn project_event_without_session_task_binding(
13892 mut envelope: NodeEventEnvelope,
13893) -> NodeEventEnvelope {
13894 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13895 record.task_binding = None;
13896 }
13897 envelope
13898}
13899
13900fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13901 let Ok(response) = reply.result.as_mut() else { return };
13902 match response {
13903 NodeResponse::Snapshot { snapshot, .. } => {
13904 clear_snapshot_session_task_bindings(snapshot);
13905 }
13906 NodeResponse::Resync { snapshot, events, .. } => {
13907 clear_snapshot_session_task_bindings(snapshot);
13908 for event in events {
13909 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13910 record.task_binding = None;
13911 }
13912 }
13913 }
13914 NodeResponse::SessionRecordUpdated { record }
13915 | NodeResponse::ProviderSessionIndexed { record }
13916 | NodeResponse::NativeSessionIndexed { record, .. }
13917 | NodeResponse::SessionRecordResumed { record, .. } => {
13918 record.task_binding = None;
13919 }
13920 _ => {}
13921 }
13922}
13923
13924fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13925 let Ok(response) = reply.result.as_mut() else { return };
13926 match response {
13927 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13928 snapshot.agent_progress.clear();
13929 }
13930 _ => {}
13931 }
13932}
13933
13934fn project_event_without_managed_worktrees(
13935 mut envelope: NodeEventEnvelope,
13936) -> Option<NodeEventEnvelope> {
13937 if matches!(
13938 envelope.event,
13939 NodeEvent::ManagedWorktreeUpserted { .. }
13940 | NodeEvent::ManagedWorktreeRemoved { .. }
13941 ) {
13942 return None;
13943 }
13944 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13945 workspace.managed_worktree_profiles = None;
13946 }
13947 Some(envelope)
13948}
13949
13950fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13951 if matches!(
13952 reply.result,
13953 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13954 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13955 ) {
13956 reply.result = Err(failure(
13957 NodeFailureCode::UnsupportedCapability,
13958 "managed worktree capability was not negotiated",
13959 ));
13960 return;
13961 }
13962 let Ok(response) = reply.result.as_mut() else { return };
13963 match response {
13964 NodeResponse::Snapshot { snapshot, .. } => {
13965 snapshot.managed_worktrees.clear();
13966 for workspace in &mut snapshot.workspaces {
13967 workspace.managed_worktree_profiles = None;
13968 }
13969 }
13970 NodeResponse::Resync { snapshot, events, .. } => {
13971 snapshot.managed_worktrees.clear();
13972 for workspace in &mut snapshot.workspaces {
13973 workspace.managed_worktree_profiles = None;
13974 }
13975 *events = std::mem::take(events)
13976 .into_iter()
13977 .filter_map(project_event_without_managed_worktrees)
13978 .collect();
13979 }
13980 NodeResponse::WorkspaceRegistered { workspace }
13981 | NodeResponse::StandaloneWorkspaceCreated { workspace }
13982 | NodeResponse::WorktreeCreated { workspace, .. } => {
13983 workspace.managed_worktree_profiles = None;
13984 }
13985 NodeResponse::WorkspaceInspected { inspection } => {
13986 inspection.git.managed_worktree = None;
13987 }
13988 _ => {}
13989 }
13990}
13991
13992fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13993 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13994 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13995 });
13996 if includes_open_provider_ids {
13997 return;
13998 }
13999 compatibility
14000 .provider_contracts
14001 .retain(|contract| provider_id_is_legacy(&contract.provider));
14002 compatibility
14003 .provider_adapter_contracts
14004 .retain(|contract| provider_id_is_legacy(&contract.provider));
14005}
14006
14007fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14008 if let Some(spec) = match request {
14009 NodeRequest::SpawnSpec { spec }
14010 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14011 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14012 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14013 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14014 _ => None,
14015 } {
14016 return shared
14017 .resolve_spawn_spec(spec)
14018 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14019 }
14020 request_requires_open_provider_ids_with(
14021 request,
14022 |session| shared.validate_address(session).ok(),
14023 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14024 )
14025}
14026
14027fn request_requires_child_environment_profile(
14028 shared: &NodeShared,
14029 request: &NodeRequest,
14030) -> bool {
14031 if let Some(spec) = match request {
14032 NodeRequest::SpawnSpec { spec }
14033 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14034 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14035 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14036 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14037 _ => None,
14038 } {
14039 return shared
14040 .resolve_spawn_spec(spec)
14041 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14042 }
14043 match request {
14044 NodeRequest::Resume { session, .. }
14045 | NodeRequest::Prompt { session, .. }
14046 | NodeRequest::Paste { session, .. }
14047 | NodeRequest::Input { session, .. }
14048 | NodeRequest::TerminalBytes { session, .. }
14049 | NodeRequest::TerminalControl { session, .. }
14050 | NodeRequest::Resize { session, .. }
14051 | NodeRequest::Interrupt { session }
14052 | NodeRequest::Stop { session, .. }
14053 | NodeRequest::Remove { session }
14054 | NodeRequest::ResolveInteraction { session, .. }
14055 | NodeRequest::SetSessionMode { session, .. }
14056 | NodeRequest::SetSessionConfigOption { session, .. }
14057 | NodeRequest::SetSessionModel { session, .. }
14058 | NodeRequest::DiscoverHistory { session, .. }
14059 | NodeRequest::LoadHistory { session, .. }
14060 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14061 | NodeRequest::ExportContextPack { session } => shared
14062 .session_bindings
14063 .lock()
14064 .unwrap_or_else(|poisoned| poisoned.into_inner())
14065 .get(&session.session.instance_id)
14066 .filter(|binding| {
14067 binding.workspace_id == session.workspace_id
14068 && binding.generation == session.session.generation
14069 })
14070 .map_or(true, |binding| binding.environment_profile.is_some()),
14071 NodeRequest::RenameSessionRecord { record_id, .. }
14072 | NodeRequest::SetSessionTask { record_id, .. }
14073 | NodeRequest::ResumeSessionRecord { record_id, .. }
14074 | NodeRequest::ForgetSessionRecord { record_id } => shared
14075 .record(record_id)
14076 .map_or(true, |record| record.environment_profile.is_some()),
14077 NodeRequest::ArmHarnessMcpReservation { .. }
14078 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14079 | NodeRequest::ActivateHarnessMcpReservation { .. }
14080 | NodeRequest::AbortHarnessMcpReservation { .. }
14081 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14082 | NodeRequest::RejectHarnessMcpCall { .. }
14083 | NodeRequest::Snapshot
14084 | NodeRequest::Resync { .. }
14085 | NodeRequest::BeginDeliveryStage { .. }
14086 | NodeRequest::PutDeliveryBlobChunk { .. }
14087 | NodeRequest::CommitDeliveryStage { .. }
14088 | NodeRequest::AbortDeliveryStage { .. }
14089 | NodeRequest::BrowseHostDirectories { .. }
14090 | NodeRequest::InspectWorkspace { .. }
14091 | NodeRequest::ReadWorkspaceFile { .. }
14092 | NodeRequest::WriteWorkspaceFile { .. }
14093 | NodeRequest::CreateWorkspaceFile { .. }
14094 | NodeRequest::CreateWorkspaceDirectory { .. }
14095 | NodeRequest::ReadGitHistory { .. }
14096 | NodeRequest::ReadGitDiff { .. }
14097 | NodeRequest::AcquireController { .. }
14098 | NodeRequest::ReleaseController
14099 | NodeRequest::Spawn { .. }
14100 | NodeRequest::SpawnSpec { .. }
14101 | NodeRequest::RegisterWorkspace { .. }
14102 | NodeRequest::CreateStandaloneWorkspace { .. }
14103 | NodeRequest::UnregisterWorkspace { .. }
14104 | NodeRequest::CreateWorktree { .. }
14105 | NodeRequest::RemoveWorktree { .. }
14106 | NodeRequest::SpawnManagedWorktree { .. }
14107 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14108 | NodeRequest::CleanupManagedWorktree { .. }
14109 | NodeRequest::IndexProviderSession { .. }
14110 | NodeRequest::IndexNativeSession { .. }
14111 | NodeRequest::CatalogNativeSessions { .. }
14112 | NodeRequest::PageNativeSessions { .. }
14113 | NodeRequest::PreviewNativeSession { .. }
14114 | NodeRequest::PreviewSessionRecord { .. }
14115 | NodeRequest::ForgetContextPack { .. }
14116 | NodeRequest::ResolveDurableContextPack { .. }
14117 | NodeRequest::ReadContextPack { .. }
14118 | NodeRequest::Shutdown => false,
14119 }
14120}
14121
14122fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14123 if let Some(spec) = match request {
14124 NodeRequest::SpawnSpec { spec }
14125 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14126 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14127 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14128 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14129 _ => None,
14130 } {
14131 return shared
14132 .resolve_spawn_spec(spec)
14133 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14134 }
14135 match request {
14136 NodeRequest::Resume { session, .. }
14137 | NodeRequest::Prompt { session, .. }
14138 | NodeRequest::Paste { session, .. }
14139 | NodeRequest::Input { session, .. }
14140 | NodeRequest::TerminalBytes { session, .. }
14141 | NodeRequest::TerminalControl { session, .. }
14142 | NodeRequest::Resize { session, .. }
14143 | NodeRequest::Interrupt { session }
14144 | NodeRequest::Stop { session, .. }
14145 | NodeRequest::Remove { session }
14146 | NodeRequest::ResolveInteraction { session, .. }
14147 | NodeRequest::SetSessionMode { session, .. }
14148 | NodeRequest::SetSessionConfigOption { session, .. }
14149 | NodeRequest::SetSessionModel { session, .. }
14150 | NodeRequest::DiscoverHistory { session, .. }
14151 | NodeRequest::LoadHistory { session, .. }
14152 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14153 | NodeRequest::ExportContextPack { session } => shared
14154 .session_bindings
14155 .lock()
14156 .unwrap_or_else(|poisoned| poisoned.into_inner())
14157 .get(&session.session.instance_id)
14158 .filter(|binding| {
14159 binding.workspace_id == session.workspace_id
14160 && binding.generation == session.session.generation
14161 })
14162 .map_or(true, |binding| binding.bundle.is_some()),
14163 NodeRequest::RenameSessionRecord { record_id, .. }
14164 | NodeRequest::SetSessionTask { record_id, .. }
14165 | NodeRequest::ResumeSessionRecord { record_id, .. }
14166 | NodeRequest::ForgetSessionRecord { record_id } => shared
14167 .record(record_id)
14168 .map_or(true, |record| record.bundle.is_some()),
14169 NodeRequest::ArmHarnessMcpReservation { .. }
14170 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14171 | NodeRequest::ActivateHarnessMcpReservation { .. }
14172 | NodeRequest::AbortHarnessMcpReservation { .. }
14173 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14174 | NodeRequest::RejectHarnessMcpCall { .. }
14175 | NodeRequest::Snapshot
14176 | NodeRequest::Resync { .. }
14177 | NodeRequest::BeginDeliveryStage { .. }
14178 | NodeRequest::PutDeliveryBlobChunk { .. }
14179 | NodeRequest::CommitDeliveryStage { .. }
14180 | NodeRequest::AbortDeliveryStage { .. }
14181 | NodeRequest::BrowseHostDirectories { .. }
14182 | NodeRequest::InspectWorkspace { .. }
14183 | NodeRequest::ReadWorkspaceFile { .. }
14184 | NodeRequest::WriteWorkspaceFile { .. }
14185 | NodeRequest::CreateWorkspaceFile { .. }
14186 | NodeRequest::CreateWorkspaceDirectory { .. }
14187 | NodeRequest::ReadGitHistory { .. }
14188 | NodeRequest::ReadGitDiff { .. }
14189 | NodeRequest::AcquireController { .. }
14190 | NodeRequest::ReleaseController
14191 | NodeRequest::Spawn { .. }
14192 | NodeRequest::SpawnSpec { .. }
14193 | NodeRequest::RegisterWorkspace { .. }
14194 | NodeRequest::CreateStandaloneWorkspace { .. }
14195 | NodeRequest::UnregisterWorkspace { .. }
14196 | NodeRequest::CreateWorktree { .. }
14197 | NodeRequest::RemoveWorktree { .. }
14198 | NodeRequest::SpawnManagedWorktree { .. }
14199 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14200 | NodeRequest::CleanupManagedWorktree { .. }
14201 | NodeRequest::IndexProviderSession { .. }
14202 | NodeRequest::IndexNativeSession { .. }
14203 | NodeRequest::CatalogNativeSessions { .. }
14204 | NodeRequest::PageNativeSessions { .. }
14205 | NodeRequest::PreviewNativeSession { .. }
14206 | NodeRequest::PreviewSessionRecord { .. }
14207 | NodeRequest::ForgetContextPack { .. }
14208 | NodeRequest::ResolveDurableContextPack { .. }
14209 | NodeRequest::ReadContextPack { .. }
14210 | NodeRequest::Shutdown => false,
14211 }
14212}
14213
14214fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14215 if request.requires_history_context_pack_capability() {
14216 return true;
14217 }
14218 if let Some(spec) = match request {
14219 NodeRequest::SpawnSpec { spec }
14220 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14221 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14222 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14223 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14224 _ => None,
14225 } {
14226 return shared
14227 .resolve_spawn_spec(spec)
14228 .map_or(true, |resolved| resolved.context_id.is_some());
14229 }
14230 match request {
14231 NodeRequest::Resume { session, .. }
14232 | NodeRequest::Prompt { session, .. }
14233 | NodeRequest::Paste { session, .. }
14234 | NodeRequest::Input { session, .. }
14235 | NodeRequest::TerminalBytes { session, .. }
14236 | NodeRequest::TerminalControl { session, .. }
14237 | NodeRequest::Resize { session, .. }
14238 | NodeRequest::Interrupt { session }
14239 | NodeRequest::Stop { session, .. }
14240 | NodeRequest::Remove { session }
14241 | NodeRequest::ResolveInteraction { session, .. }
14242 | NodeRequest::SetSessionMode { session, .. }
14243 | NodeRequest::SetSessionConfigOption { session, .. }
14244 | NodeRequest::SetSessionModel { session, .. } => shared
14245 .session_bindings
14246 .lock()
14247 .unwrap_or_else(|poisoned| poisoned.into_inner())
14248 .get(&session.session.instance_id)
14249 .filter(|binding| {
14250 binding.workspace_id == session.workspace_id
14251 && binding.generation == session.session.generation
14252 })
14253 .map_or(true, |binding| binding.context.is_some()),
14254 NodeRequest::RenameSessionRecord { record_id, .. }
14255 | NodeRequest::SetSessionTask { record_id, .. }
14256 | NodeRequest::ResumeSessionRecord { record_id, .. }
14257 | NodeRequest::ForgetSessionRecord { record_id } => shared
14258 .record(record_id)
14259 .map_or(true, |record| record.context.is_some()),
14260 NodeRequest::ArmHarnessMcpReservation { .. }
14261 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14262 | NodeRequest::ActivateHarnessMcpReservation { .. }
14263 | NodeRequest::AbortHarnessMcpReservation { .. }
14264 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14265 | NodeRequest::RejectHarnessMcpCall { .. }
14266 | NodeRequest::Snapshot
14267 | NodeRequest::Resync { .. }
14268 | NodeRequest::BeginDeliveryStage { .. }
14269 | NodeRequest::PutDeliveryBlobChunk { .. }
14270 | NodeRequest::CommitDeliveryStage { .. }
14271 | NodeRequest::AbortDeliveryStage { .. }
14272 | NodeRequest::BrowseHostDirectories { .. }
14273 | NodeRequest::InspectWorkspace { .. }
14274 | NodeRequest::ReadWorkspaceFile { .. }
14275 | NodeRequest::WriteWorkspaceFile { .. }
14276 | NodeRequest::CreateWorkspaceFile { .. }
14277 | NodeRequest::CreateWorkspaceDirectory { .. }
14278 | NodeRequest::ReadGitHistory { .. }
14279 | NodeRequest::ReadGitDiff { .. }
14280 | NodeRequest::AcquireController { .. }
14281 | NodeRequest::ReleaseController
14282 | NodeRequest::Spawn { .. }
14283 | NodeRequest::SpawnSpec { .. }
14284 | NodeRequest::RegisterWorkspace { .. }
14285 | NodeRequest::CreateStandaloneWorkspace { .. }
14286 | NodeRequest::UnregisterWorkspace { .. }
14287 | NodeRequest::CreateWorktree { .. }
14288 | NodeRequest::RemoveWorktree { .. }
14289 | NodeRequest::SpawnManagedWorktree { .. }
14290 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14291 | NodeRequest::CleanupManagedWorktree { .. }
14292 | NodeRequest::IndexProviderSession { .. }
14293 | NodeRequest::IndexNativeSession { .. }
14294 | NodeRequest::CatalogNativeSessions { .. }
14295 | NodeRequest::PageNativeSessions { .. }
14296 | NodeRequest::PreviewNativeSession { .. }
14297 | NodeRequest::PreviewSessionRecord { .. }
14298 | NodeRequest::DiscoverHistory { .. }
14299 | NodeRequest::LoadHistory { .. }
14300 | NodeRequest::ExportContextPackForSessionRecord { .. }
14301 | NodeRequest::ExportContextPack { .. }
14302 | NodeRequest::ForgetContextPack { .. }
14303 | NodeRequest::ResolveDurableContextPack { .. }
14304 | NodeRequest::ReadContextPack { .. }
14305 | NodeRequest::Shutdown => false,
14306 }
14307}
14308
14309fn request_requires_open_provider_ids_with(
14310 request: &NodeRequest,
14311 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14312 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14313) -> bool {
14314 match request {
14315 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14316 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14317 NodeRequest::CatalogNativeSessions { route, .. }
14318 | NodeRequest::PageNativeSessions { route, .. } => {
14319 !provider_id_is_legacy(&route.provider)
14320 }
14321 NodeRequest::PreviewNativeSession { selection, .. }
14322 | NodeRequest::IndexNativeSession { selection, .. } => {
14323 !provider_id_is_legacy(&selection.route.provider)
14324 }
14325 NodeRequest::SpawnSpec { .. }
14326 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14327 NodeRequest::SpawnManagedWorktree { .. }
14328 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14329 NodeRequest::Resume { session, .. }
14330 | NodeRequest::Prompt { session, .. }
14331 | NodeRequest::Paste { session, .. }
14332 | NodeRequest::Input { session, .. }
14333 | NodeRequest::TerminalBytes { session, .. }
14334 | NodeRequest::TerminalControl { session, .. }
14335 | NodeRequest::Resize { session, .. }
14336 | NodeRequest::Interrupt { session }
14337 | NodeRequest::Stop { session, .. }
14338 | NodeRequest::Remove { session }
14339 | NodeRequest::ResolveInteraction { session, .. }
14340 | NodeRequest::SetSessionMode { session, .. }
14341 | NodeRequest::SetSessionConfigOption { session, .. }
14342 | NodeRequest::SetSessionModel { session, .. }
14343 | NodeRequest::DiscoverHistory { session, .. }
14344 | NodeRequest::LoadHistory { session, .. }
14345 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14346 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14347 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14348 NodeRequest::RenameSessionRecord { record_id, .. }
14349 | NodeRequest::SetSessionTask { record_id, .. }
14350 | NodeRequest::ResumeSessionRecord { record_id, .. }
14351 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14352 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14353 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14354 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14355 NodeRequest::ArmHarnessMcpReservation { .. }
14356 | NodeRequest::ActivateHarnessMcpReservation { .. }
14357 | NodeRequest::AbortHarnessMcpReservation { .. }
14358 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14359 | NodeRequest::RejectHarnessMcpCall { .. }
14360 | NodeRequest::Snapshot
14361 | NodeRequest::Resync { .. }
14362 | NodeRequest::BeginDeliveryStage { .. }
14363 | NodeRequest::PutDeliveryBlobChunk { .. }
14364 | NodeRequest::CommitDeliveryStage { .. }
14365 | NodeRequest::AbortDeliveryStage { .. }
14366 | NodeRequest::BrowseHostDirectories { .. }
14367 | NodeRequest::InspectWorkspace { .. }
14368 | NodeRequest::ReadWorkspaceFile { .. }
14369 | NodeRequest::WriteWorkspaceFile { .. }
14370 | NodeRequest::CreateWorkspaceFile { .. }
14371 | NodeRequest::CreateWorkspaceDirectory { .. }
14372 | NodeRequest::ReadGitHistory { .. }
14373 | NodeRequest::ReadGitDiff { .. }
14374 | NodeRequest::AcquireController { .. }
14375 | NodeRequest::ReleaseController
14376 | NodeRequest::RegisterWorkspace { .. }
14377 | NodeRequest::CreateStandaloneWorkspace { .. }
14378 | NodeRequest::UnregisterWorkspace { .. }
14379 | NodeRequest::CreateWorktree { .. }
14380 | NodeRequest::RemoveWorktree { .. }
14381 | NodeRequest::CleanupManagedWorktree { .. }
14382 | NodeRequest::Shutdown => false,
14383 NodeRequest::ForgetContextPack { .. } => true,
14384 NodeRequest::ResolveDurableContextPack { .. }
14390 | NodeRequest::ReadContextPack { .. } => true,
14391 }
14392}
14393
14394fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14395 snapshot.enabled_providers.retain(provider_id_is_legacy);
14396 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14397 snapshot
14398 .provider_runtime_statuses
14399 .as_slice()
14400 .iter()
14401 .filter(|status| provider_id_is_legacy(status.provider()))
14402 .cloned(),
14403 )
14404 .expect("a filtered provider runtime projection remains valid");
14405 snapshot
14406 .session_records
14407 .retain(|record| provider_id_is_legacy(&record.provider));
14408 for workspace in &mut snapshot.workspaces {
14409 workspace
14410 .sessions
14411 .retain(|session| provider_id_is_legacy(&session.agent_id));
14412 }
14413}
14414
14415fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14416 for record in &mut snapshot.session_records {
14417 record.environment_profile = None;
14418 }
14419 if let Some(profiles) = snapshot
14420 .launch_inventory
14421 .as_mut()
14422 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14423 {
14424 for profile in profiles {
14425 profile.environment_profile = None;
14426 }
14427 }
14428}
14429
14430fn project_event_without_child_environment_profile(
14431 mut envelope: NodeEventEnvelope,
14432) -> NodeEventEnvelope {
14433 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14434 record.environment_profile = None;
14435 }
14436 envelope
14437}
14438
14439fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14440 let contains_environment_profile = match reply.result.as_ref() {
14441 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14442 receipt.environment_profile.is_some()
14443 }
14444 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14445 receipt.spawn.environment_profile.is_some()
14446 }
14447 _ => false,
14448 };
14449 if contains_environment_profile {
14450 reply.result = Err(failure(
14451 NodeFailureCode::UnsupportedCapability,
14452 "child environment profiles were not negotiated",
14453 ));
14454 return;
14455 }
14456 let Ok(response) = reply.result.as_mut() else {
14457 return;
14458 };
14459 match response {
14460 NodeResponse::Snapshot { snapshot, .. } => {
14461 clear_snapshot_child_environment_profiles(snapshot);
14462 }
14463 NodeResponse::Resync { snapshot, events, .. } => {
14464 clear_snapshot_child_environment_profiles(snapshot);
14465 for event in events {
14466 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14467 record.environment_profile = None;
14468 }
14469 }
14470 }
14471 NodeResponse::SessionRecordUpdated { record }
14472 | NodeResponse::ProviderSessionIndexed { record }
14473 | NodeResponse::NativeSessionIndexed { record, .. }
14474 | NodeResponse::SessionRecordResumed { record, .. } => {
14475 record.environment_profile = None;
14476 }
14477 NodeResponse::Armed { .. }
14478 | NodeResponse::Spawned { .. }
14479 | NodeResponse::Activated { .. }
14480 | NodeResponse::Aborted { .. }
14481 | NodeResponse::ReplyChunkAccepted { .. }
14482 | NodeResponse::CallRejected { .. }
14483 | NodeResponse::DeliveryStageBegun { .. }
14484 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14485 | NodeResponse::DeliveryCommitted { .. }
14486 | NodeResponse::DeliveryStageAborted { .. }
14487 | NodeResponse::WorkspaceInspected { .. }
14488 | NodeResponse::HostDirectoriesBrowsed { .. }
14489 | NodeResponse::WorkspaceFileRead { .. }
14490 | NodeResponse::WorkspaceFileWritten { .. }
14491 | NodeResponse::WorkspaceFileCreated { .. }
14492 | NodeResponse::WorkspaceDirectoryCreated { .. }
14493 | NodeResponse::GitHistoryRead { .. }
14494 | NodeResponse::GitDiffRead { .. }
14495 | NodeResponse::Controller { .. }
14496 | NodeResponse::SpawnAccepted { .. }
14497 | NodeResponse::SpawnSpecAccepted { .. }
14498 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14499 | NodeResponse::ManagedWorktreeCleanup { .. }
14500 | NodeResponse::NativeSessionsCataloged { .. }
14501 | NodeResponse::NativeSessionsPaged { .. }
14502 | NodeResponse::NativeSessionPreviewed { .. }
14503 | NodeResponse::SessionRecordPreviewed { .. }
14504 | NodeResponse::HistoryDiscovered { .. }
14505 | NodeResponse::HistoryLoaded { .. }
14506 | NodeResponse::ContextPackForSessionRecordExported { .. }
14507 | NodeResponse::ContextPackExported { .. }
14508 | NodeResponse::ContextPackForgotten { .. }
14509 | NodeResponse::DurableContextPackResolved { .. }
14510 | NodeResponse::ContextPackBytesRead { .. }
14511 | NodeResponse::SessionRecordForgotten { .. }
14512 | NodeResponse::WorkspaceRegistered { .. }
14513 | NodeResponse::StandaloneWorkspaceCreated { .. }
14514 | NodeResponse::WorkspaceUnregistered { .. }
14515 | NodeResponse::WorktreeCreated { .. }
14516 | NodeResponse::WorktreeRemoved { .. }
14517 | NodeResponse::Accepted
14518 | NodeResponse::ShuttingDown => {}
14519 }
14520}
14521
14522fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14523 for record in &mut snapshot.session_records {
14524 record.bundle = None;
14525 }
14526}
14527
14528fn project_event_without_session_bundle(
14529 mut envelope: NodeEventEnvelope,
14530) -> NodeEventEnvelope {
14531 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14532 record.bundle = None;
14533 }
14534 envelope
14535}
14536
14537fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14538 let contains_bundle = match reply.result.as_ref() {
14539 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14540 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14541 receipt.spawn.bundle.is_some()
14542 }
14543 _ => false,
14544 };
14545 if contains_bundle {
14546 reply.result = Err(failure(
14547 NodeFailureCode::UnsupportedCapability,
14548 "session bundles were not negotiated",
14549 ));
14550 return;
14551 }
14552 let Ok(response) = reply.result.as_mut() else {
14553 return;
14554 };
14555 match response {
14556 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14557 NodeResponse::Resync { snapshot, events, .. } => {
14558 clear_snapshot_session_bundles(snapshot);
14559 for event in events {
14560 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14561 record.bundle = None;
14562 }
14563 }
14564 }
14565 NodeResponse::SessionRecordUpdated { record }
14566 | NodeResponse::ProviderSessionIndexed { record }
14567 | NodeResponse::NativeSessionIndexed { record, .. }
14568 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14569 NodeResponse::Armed { .. }
14570 | NodeResponse::Spawned { .. }
14571 | NodeResponse::Activated { .. }
14572 | NodeResponse::Aborted { .. }
14573 | NodeResponse::ReplyChunkAccepted { .. }
14574 | NodeResponse::CallRejected { .. }
14575 | NodeResponse::DeliveryStageBegun { .. }
14576 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14577 | NodeResponse::DeliveryCommitted { .. }
14578 | NodeResponse::DeliveryStageAborted { .. }
14579 | NodeResponse::WorkspaceInspected { .. }
14580 | NodeResponse::HostDirectoriesBrowsed { .. }
14581 | NodeResponse::WorkspaceFileRead { .. }
14582 | NodeResponse::WorkspaceFileWritten { .. }
14583 | NodeResponse::WorkspaceFileCreated { .. }
14584 | NodeResponse::WorkspaceDirectoryCreated { .. }
14585 | NodeResponse::GitHistoryRead { .. }
14586 | NodeResponse::GitDiffRead { .. }
14587 | NodeResponse::Controller { .. }
14588 | NodeResponse::SpawnAccepted { .. }
14589 | NodeResponse::SpawnSpecAccepted { .. }
14590 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14591 | NodeResponse::ManagedWorktreeCleanup { .. }
14592 | NodeResponse::NativeSessionsCataloged { .. }
14593 | NodeResponse::NativeSessionsPaged { .. }
14594 | NodeResponse::NativeSessionPreviewed { .. }
14595 | NodeResponse::SessionRecordPreviewed { .. }
14596 | NodeResponse::HistoryDiscovered { .. }
14597 | NodeResponse::HistoryLoaded { .. }
14598 | NodeResponse::ContextPackForSessionRecordExported { .. }
14599 | NodeResponse::ContextPackExported { .. }
14600 | NodeResponse::ContextPackForgotten { .. }
14601 | NodeResponse::DurableContextPackResolved { .. }
14602 | NodeResponse::ContextPackBytesRead { .. }
14603 | NodeResponse::SessionRecordForgotten { .. }
14604 | NodeResponse::WorkspaceRegistered { .. }
14605 | NodeResponse::StandaloneWorkspaceCreated { .. }
14606 | NodeResponse::WorkspaceUnregistered { .. }
14607 | NodeResponse::WorktreeCreated { .. }
14608 | NodeResponse::WorktreeRemoved { .. }
14609 | NodeResponse::Accepted
14610 | NodeResponse::ShuttingDown => {}
14611 }
14612}
14613
14614fn project_snapshot_history_for_wire(
14615 snapshot: &mut NodeSnapshot,
14616 include_history_metadata: bool,
14617) {
14618 for workspace in &mut snapshot.workspaces {
14619 for session in &mut workspace.sessions {
14620 if include_history_metadata {
14621 session.history.loaded = None;
14622 } else {
14623 session.history = Default::default();
14624 }
14625 }
14626 }
14627}
14628
14629fn project_event_history_for_wire(
14630 mut envelope: NodeEventEnvelope,
14631 include_history_metadata: bool,
14632) -> NodeEventEnvelope {
14633 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14634 for session in &mut workspace.sessions {
14635 if include_history_metadata {
14636 session.history.loaded = None;
14637 } else {
14638 session.history = Default::default();
14639 }
14640 }
14641 }
14642 envelope
14643}
14644
14645fn project_response_history_for_wire(
14646 reply: &mut ResponseEnvelope,
14647 include_history_metadata: bool,
14648) {
14649 let Ok(response) = reply.result.as_mut() else {
14650 return;
14651 };
14652 match response {
14653 NodeResponse::Snapshot { snapshot, .. } => {
14654 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14655 }
14656 NodeResponse::Resync {
14657 snapshot, events, ..
14658 } => {
14659 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14660 for event in events {
14661 *event = project_event_history_for_wire(
14662 event.clone(),
14663 include_history_metadata,
14664 );
14665 }
14666 }
14667 NodeResponse::WorkspaceRegistered { workspace }
14668 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14669 | NodeResponse::WorktreeCreated { workspace, .. } => {
14670 for session in &mut workspace.sessions {
14671 if include_history_metadata {
14672 session.history.loaded = None;
14673 } else {
14674 session.history = Default::default();
14675 }
14676 }
14677 }
14678 _ => {}
14679 }
14680}
14681
14682fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14683 for record in &mut snapshot.session_records {
14684 record.context_id = None;
14685 record.context = None;
14686 record.exported_context = None;
14687 }
14688}
14689
14690fn project_event_without_context_pack(
14691 mut envelope: NodeEventEnvelope,
14692) -> NodeEventEnvelope {
14693 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14694 record.context_id = None;
14695 record.context = None;
14696 record.exported_context = None;
14697 }
14698 envelope
14699}
14700
14701fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14702 let contains_context = match reply.result.as_ref() {
14703 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14704 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14705 receipt.spawn.context.is_some()
14706 }
14707 Ok(NodeResponse::HistoryDiscovered { .. })
14708 | Ok(NodeResponse::HistoryLoaded { .. })
14709 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14710 | Ok(NodeResponse::ContextPackExported { .. })
14711 | Ok(NodeResponse::ContextPackForgotten { .. })
14712 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14713 _ => false,
14714 };
14715 if contains_context {
14716 reply.result = Err(failure(
14717 NodeFailureCode::UnsupportedCapability,
14718 "history context packs were not negotiated",
14719 ));
14720 return;
14721 }
14722 let Ok(response) = reply.result.as_mut() else {
14723 return;
14724 };
14725 match response {
14726 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14727 NodeResponse::Resync {
14728 snapshot, events, ..
14729 } => {
14730 clear_snapshot_context_packs(snapshot);
14731 for event in events {
14732 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14733 record.context_id = None;
14734 record.context = None;
14735 record.exported_context = None;
14736 }
14737 }
14738 }
14739 NodeResponse::SessionRecordUpdated { record }
14740 | NodeResponse::ProviderSessionIndexed { record }
14741 | NodeResponse::NativeSessionIndexed { record, .. }
14742 | NodeResponse::SessionRecordResumed { record, .. } => {
14743 record.context_id = None;
14744 record.context = None;
14745 record.exported_context = None;
14746 }
14747 _ => {}
14748 }
14749}
14750
14751fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14752 let contains_open_record = match reply.result.as_ref() {
14753 Ok(NodeResponse::SessionRecordUpdated { record })
14754 | Ok(NodeResponse::ProviderSessionIndexed { record })
14755 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14756 !provider_id_is_legacy(&record.provider)
14757 }
14758 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14759 !provider_id_is_legacy(&selection.route.provider)
14760 || !provider_id_is_legacy(&record.provider)
14761 }
14762 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14763 !provider_id_is_legacy(&receipt.provider)
14764 }
14765 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14766 !provider_id_is_legacy(&receipt.spawn.provider)
14767 }
14768 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14769 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14770 !provider_id_is_legacy(&route.provider)
14771 }
14772 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14773 !provider_id_is_legacy(&selection.route.provider)
14774 }
14775 _ => false,
14776 };
14777 if contains_open_record {
14778 reply.result = Err(failure(
14779 NodeFailureCode::UnsupportedCapability,
14780 "open provider IDs were not negotiated",
14781 ));
14782 return;
14783 }
14784 let Ok(response) = reply.result.as_mut() else {
14785 return;
14786 };
14787 match response {
14788 NodeResponse::Snapshot { snapshot, .. } => {
14789 project_snapshot_legacy_provider_ids(snapshot);
14790 }
14791 NodeResponse::Resync { snapshot, events, .. } => {
14792 project_snapshot_legacy_provider_ids(snapshot);
14793 *events = std::mem::take(events)
14794 .into_iter()
14795 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14796 .collect();
14797 }
14798 NodeResponse::WorkspaceRegistered { workspace }
14799 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14800 | NodeResponse::WorktreeCreated { workspace, .. } => {
14801 workspace
14802 .sessions
14803 .retain(|session| provider_id_is_legacy(&session.agent_id));
14804 }
14805 NodeResponse::Armed { .. }
14806 | NodeResponse::Spawned { .. }
14807 | NodeResponse::Activated { .. }
14808 | NodeResponse::Aborted { .. }
14809 | NodeResponse::ReplyChunkAccepted { .. }
14810 | NodeResponse::CallRejected { .. }
14811 | NodeResponse::DeliveryStageBegun { .. }
14812 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14813 | NodeResponse::DeliveryCommitted { .. }
14814 | NodeResponse::DeliveryStageAborted { .. }
14815 | NodeResponse::WorkspaceInspected { .. }
14816 | NodeResponse::HostDirectoriesBrowsed { .. }
14817 | NodeResponse::WorkspaceFileRead { .. }
14818 | NodeResponse::WorkspaceFileWritten { .. }
14819 | NodeResponse::WorkspaceFileCreated { .. }
14820 | NodeResponse::WorkspaceDirectoryCreated { .. }
14821 | NodeResponse::GitHistoryRead { .. }
14822 | NodeResponse::GitDiffRead { .. }
14823 | NodeResponse::Controller { .. }
14824 | NodeResponse::SpawnAccepted { .. }
14825 | NodeResponse::SpawnSpecAccepted { .. }
14826 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14827 | NodeResponse::ManagedWorktreeCleanup { .. }
14828 | NodeResponse::NativeSessionsCataloged { .. }
14829 | NodeResponse::NativeSessionsPaged { .. }
14830 | NodeResponse::NativeSessionPreviewed { .. }
14831 | NodeResponse::SessionRecordPreviewed { .. }
14832 | NodeResponse::HistoryDiscovered { .. }
14833 | NodeResponse::HistoryLoaded { .. }
14834 | NodeResponse::ContextPackForSessionRecordExported { .. }
14835 | NodeResponse::ContextPackExported { .. }
14836 | NodeResponse::ContextPackForgotten { .. }
14837 | NodeResponse::DurableContextPackResolved { .. }
14838 | NodeResponse::ContextPackBytesRead { .. }
14839 | NodeResponse::SessionRecordUpdated { .. }
14840 | NodeResponse::ProviderSessionIndexed { .. }
14841 | NodeResponse::NativeSessionIndexed { .. }
14842 | NodeResponse::SessionRecordResumed { .. }
14843 | NodeResponse::SessionRecordForgotten { .. }
14844 | NodeResponse::WorkspaceUnregistered { .. }
14845 | NodeResponse::WorktreeRemoved { .. }
14846 | NodeResponse::Accepted
14847 | NodeResponse::ShuttingDown => {}
14848 }
14849}
14850
14851fn project_event_legacy_provider_ids(
14852 shared: &NodeShared,
14853 mut envelope: NodeEventEnvelope,
14854) -> Option<NodeEventEnvelope> {
14855 let include = match &mut envelope.event {
14856 NodeEvent::HarnessMcpReadCall { .. } => true,
14857 NodeEvent::Control { address, .. }
14858 | NodeEvent::AgentStream { address, .. }
14859 | NodeEvent::TerminalFrame { address, .. } => shared
14860 .validate_address(address)
14861 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14862 NodeEvent::WorkspaceAdded { workspace } => {
14863 workspace
14864 .sessions
14865 .retain(|session| provider_id_is_legacy(&session.agent_id));
14866 true
14867 }
14868 NodeEvent::SessionRecordUpserted { record } => {
14869 provider_id_is_legacy(&record.provider)
14870 }
14871 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14872 NodeEvent::SessionRecordRemoved { .. } => shared
14873 .history
14874 .lock()
14875 .unwrap_or_else(|poisoned| poisoned.into_inner())
14876 .removed_record_providers
14877 .get(&envelope.sequence)
14878 .is_some_and(provider_id_is_legacy),
14879 NodeEvent::ControllerChanged { .. }
14880 | NodeEvent::ManagedWorktreeUpserted { .. }
14881 | NodeEvent::ManagedWorktreeRemoved { .. }
14882 | NodeEvent::WorkspaceRemoved { .. }
14883 | NodeEvent::ResyncRequired { .. } => true,
14884 };
14885 include.then_some(envelope)
14886}
14887
14888fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14889 let Ok(response) = reply.result.as_mut() else {
14890 return;
14891 };
14892 match response {
14893 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14894 snapshot.provider_runtime_statuses.clear();
14895 }
14896 NodeResponse::Armed { .. }
14897 | NodeResponse::Spawned { .. }
14898 | NodeResponse::Activated { .. }
14899 | NodeResponse::Aborted { .. }
14900 | NodeResponse::ReplyChunkAccepted { .. }
14901 | NodeResponse::CallRejected { .. }
14902 | NodeResponse::DeliveryStageBegun { .. }
14903 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14904 | NodeResponse::DeliveryCommitted { .. }
14905 | NodeResponse::DeliveryStageAborted { .. }
14906 | NodeResponse::WorkspaceInspected { .. }
14907 | NodeResponse::HostDirectoriesBrowsed { .. }
14908 | NodeResponse::WorkspaceFileRead { .. }
14909 | NodeResponse::WorkspaceFileWritten { .. }
14910 | NodeResponse::WorkspaceFileCreated { .. }
14911 | NodeResponse::WorkspaceDirectoryCreated { .. }
14912 | NodeResponse::GitHistoryRead { .. }
14913 | NodeResponse::GitDiffRead { .. }
14914 | NodeResponse::Controller { .. }
14915 | NodeResponse::SpawnAccepted { .. }
14916 | NodeResponse::SpawnSpecAccepted { .. }
14917 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14918 | NodeResponse::ManagedWorktreeCleanup { .. }
14919 | NodeResponse::NativeSessionsCataloged { .. }
14920 | NodeResponse::NativeSessionsPaged { .. }
14921 | NodeResponse::NativeSessionPreviewed { .. }
14922 | NodeResponse::SessionRecordPreviewed { .. }
14923 | NodeResponse::HistoryDiscovered { .. }
14924 | NodeResponse::HistoryLoaded { .. }
14925 | NodeResponse::ContextPackForSessionRecordExported { .. }
14926 | NodeResponse::ContextPackExported { .. }
14927 | NodeResponse::ContextPackForgotten { .. }
14928 | NodeResponse::DurableContextPackResolved { .. }
14929 | NodeResponse::ContextPackBytesRead { .. }
14930 | NodeResponse::SessionRecordUpdated { .. }
14931 | NodeResponse::ProviderSessionIndexed { .. }
14932 | NodeResponse::NativeSessionIndexed { .. }
14933 | NodeResponse::SessionRecordResumed { .. }
14934 | NodeResponse::SessionRecordForgotten { .. }
14935 | NodeResponse::WorkspaceRegistered { .. }
14936 | NodeResponse::StandaloneWorkspaceCreated { .. }
14937 | NodeResponse::WorkspaceUnregistered { .. }
14938 | NodeResponse::WorktreeCreated { .. }
14939 | NodeResponse::WorktreeRemoved { .. }
14940 | NodeResponse::Accepted
14941 | NodeResponse::ShuttingDown => {}
14942 }
14943}
14944
14945struct ControllerReleaseGuard {
14946 shared: Arc<NodeShared>,
14947 connection_id: u64,
14948}
14949
14950impl Drop for ControllerReleaseGuard {
14951 fn drop(&mut self) {
14952 self.shared.release_controller(self.connection_id);
14953 }
14954}
14955
14956struct AbortTaskOnDrop(AbortHandle);
14957
14958impl Drop for AbortTaskOnDrop {
14959 fn drop(&mut self) {
14960 self.0.abort();
14961 }
14962}
14963
14964fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14969 std::env::var(name)
14970 .ok()
14971 .and_then(|value| value.parse::<u64>().ok())
14972 .filter(|value| *value > 0)
14973 .unwrap_or(default)
14974 .min(max)
14975}
14976
14977fn workspace_inspection_time_budget_ms() -> u64 {
14978 env_config_bounded(
14979 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14980 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14981 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14982 )
14983}
14984
14985fn workspace_inspection_entry_cap() -> usize {
14986 env_config_bounded(
14987 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14988 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14989 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14990 ) as usize
14991}
14992
14993struct WorkspaceWalkBudget {
14999 deadline: Instant,
15000 entry_cap: usize,
15001 entries_visited: u64,
15002 time_budget_exceeded: bool,
15003 entry_cap_exceeded: bool,
15004}
15005
15006impl WorkspaceWalkBudget {
15007 fn new(deadline: Instant, entry_cap: usize) -> Self {
15008 Self {
15009 deadline,
15010 entry_cap,
15011 entries_visited: 0,
15012 time_budget_exceeded: false,
15013 entry_cap_exceeded: false,
15014 }
15015 }
15016
15017 fn is_exhausted(&self) -> bool {
15018 self.time_budget_exceeded || self.entry_cap_exceeded
15019 }
15020
15021 fn record_visit(&mut self) -> bool {
15024 self.entries_visited += 1;
15025 if self.entries_visited as usize >= self.entry_cap {
15026 self.entry_cap_exceeded = true;
15027 }
15028 if Instant::now() >= self.deadline {
15029 self.time_budget_exceeded = true;
15030 }
15031 self.is_exhausted()
15032 }
15033}
15034
15035fn collect_workspace_entries(
15036 root: &Path,
15037 deadline: Instant,
15038 entry_cap: usize,
15039) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15040 let mut entries = Vec::new();
15041 let mut truncated = false;
15042 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15043 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15044 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15048 (entries, truncated, budget)
15049}
15050
15051fn workspace_inspection_truncation(
15055 walk_budget: &WorkspaceWalkBudget,
15056 git_time_budget_exceeded: bool,
15057 elapsed: Duration,
15058) -> Option<WorkspaceInspectionTruncationV1> {
15059 if !walk_budget.time_budget_exceeded
15060 && !walk_budget.entry_cap_exceeded
15061 && !git_time_budget_exceeded
15062 {
15063 return None;
15064 }
15065 Some(WorkspaceInspectionTruncationV1 {
15066 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15067 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15068 git_time_budget_exceeded,
15069 entries_visited: walk_budget.entries_visited,
15070 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15071 })
15072}
15073
15074fn walk_workspace_directory(
15075 directory: &Path,
15076 relative_directory: Option<&RepositoryPath>,
15077 depth: usize,
15078 entries: &mut Vec<WorkspaceEntry>,
15079 truncated: &mut bool,
15080 budget: &mut WorkspaceWalkBudget,
15081) {
15082 if budget.is_exhausted() {
15083 *truncated = true;
15084 return;
15085 }
15086 let read_dir = match std::fs::read_dir(directory) {
15087 Ok(read_dir) => read_dir,
15088 Err(_) => {
15089 *truncated = true;
15090 return;
15091 }
15092 };
15093 let mut children = Vec::new();
15094 for child in read_dir {
15095 match child {
15096 Ok(child) => children.push(child),
15097 Err(_) => *truncated = true,
15098 }
15099 }
15100 children.sort_by_key(|child| child.file_name());
15101 for child in children {
15102 if budget.record_visit() {
15103 *truncated = true;
15104 return;
15105 }
15106 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15107 *truncated = true;
15108 return;
15109 }
15110 let file_type = match child.file_type() {
15111 Ok(file_type) => file_type,
15112 Err(_) => {
15113 *truncated = true;
15114 continue;
15115 }
15116 };
15117 if file_type.is_symlink() {
15118 continue;
15119 }
15120 let name = match child.file_name().into_string() {
15121 Ok(name) => name,
15122 Err(_) => {
15123 *truncated = true;
15124 continue;
15125 }
15126 };
15127 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15128 continue;
15129 }
15130 let relative_path = match relative_directory {
15131 None => windows_repository_path(name),
15132 Some(parent) => parent
15133 .as_utf8()
15134 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15135 };
15136 let Some(relative_path) = relative_path else {
15137 *truncated = true;
15138 continue;
15139 };
15140 let kind = if file_type.is_dir() {
15141 WorkspaceEntryKind::Directory
15142 } else if file_type.is_file() {
15143 WorkspaceEntryKind::File
15144 } else {
15145 continue;
15146 };
15147 entries.push(WorkspaceEntry {
15148 relative_path: relative_path.clone(),
15149 kind,
15150 });
15151 if file_type.is_dir() {
15152 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15153 walk_workspace_directory(
15154 &child.path(),
15155 Some(&relative_path),
15156 depth + 1,
15157 entries,
15158 truncated,
15159 budget,
15160 );
15161 } else {
15162 *truncated = true;
15163 }
15164 }
15165 }
15166}
15167
15168fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15169 if value.contains('\\') {
15170 return None;
15171 }
15172 RepositoryPath::utf8(value).ok()
15173}
15174
15175fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15176 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15177}
15178
15179fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15188 if name.eq_ignore_ascii_case(".git")
15189 || name.eq_ignore_ascii_case("target")
15190 || name.eq_ignore_ascii_case("node_modules")
15191 {
15192 return true;
15193 }
15194 path.join("CACHEDIR.TAG").is_file()
15195}
15196
15197fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15198 matches!(
15199 status,
15200 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15201 )
15202}
15203
15204#[derive(Clone, Debug, Eq, PartialEq)]
15205enum ContextPackRepositoryHead {
15206 NotRepository,
15207 Unborn,
15208 Commit(String),
15209}
15210
15211#[derive(Clone, Debug, Eq, PartialEq)]
15212struct ContextPackRepositoryObservation {
15213 head: ContextPackRepositoryHead,
15214 git: GitSnapshot,
15215 files: Vec<ContextPackRepositoryFileSource>,
15216}
15217
15218fn stable_context_pack_repository(
15219 first: ContextPackRepositoryObservation,
15220 second: ContextPackRepositoryObservation,
15221) -> Result<ContextPackRepository, NodeFailure> {
15222 if first != second {
15223 return Err(failure(
15224 NodeFailureCode::ContextPackMaterializationFailed,
15225 "context repository changed during bounded capture",
15226 ));
15227 }
15228 Ok(ContextPackRepository::from_sources(
15229 &second.git,
15230 second.files,
15231 ))
15232}
15233
15234fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15241 if Instant::now() < deadline {
15242 return false;
15243 }
15244 append_git_diagnostic(
15245 snapshot,
15246 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15247 );
15248 true
15249}
15250
15251async fn run_git_bounded_with_deadline(
15260 root: &str,
15261 arguments: &[&str],
15262 output_limit: usize,
15263 deadline: Instant,
15264) -> io::Result<GitCommandOutput> {
15265 let remaining = deadline.checked_duration_since(Instant::now());
15266 let timeout_ms = remaining
15267 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15268 .unwrap_or(0)
15269 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15270 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15271}
15272
15273async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15280 let mut snapshot = GitSnapshot {
15281 is_repository: false,
15282 branch: None,
15283 status: Vec::new(),
15284 recent_commits: Vec::new(),
15285 worktrees: Vec::new(),
15286 managed_worktree: None,
15287 truncated: false,
15288 diagnostic: None,
15289 };
15290 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15291 return (snapshot, true);
15292 }
15293 let repository = match run_git_bounded_with_deadline(
15294 root,
15295 &["rev-parse", "--is-inside-work-tree"],
15296 4 * 1_024,
15297 deadline,
15298 )
15299 .await
15300 {
15301 Ok(output) => output,
15302 Err(error) => {
15303 append_git_diagnostic(
15304 &mut snapshot,
15305 &format!("git inspection unavailable: {error}"),
15306 );
15307 return (snapshot, false);
15308 }
15309 };
15310 snapshot.truncated |= repository.truncated;
15311 if repository.timed_out {
15312 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15313 return (snapshot, false);
15314 }
15315 if !repository.success
15316 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15317 {
15318 let stderr = String::from_utf8_lossy(&repository.stderr);
15319 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15320 append_git_diagnostic(&mut snapshot, stderr.trim());
15321 }
15322 return (snapshot, false);
15323 }
15324 snapshot.is_repository = true;
15325
15326 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15327 return (snapshot, true);
15328 }
15329 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15330 Ok(output) => {
15331 snapshot.truncated |= output.truncated;
15332 if output.timed_out {
15333 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15334 } else if output.success {
15335 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15336 if !branch.is_empty() {
15337 snapshot.branch = Some(truncate_git_field(
15338 &branch,
15339 MAX_REPOSITORY_PATH_BYTES,
15340 &mut snapshot.truncated,
15341 ));
15342 }
15343 }
15344 }
15345 Err(error) => append_git_diagnostic(
15346 &mut snapshot,
15347 &format!("git branch query failed: {error}"),
15348 ),
15349 }
15350 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15351 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15352 snapshot.truncated |= output.truncated;
15353 if output.success && !output.timed_out {
15354 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15355 if !head.is_empty() {
15356 snapshot.branch = Some(format!("detached:{head}"));
15357 }
15358 }
15359 }
15360 }
15361
15362 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15363 return (snapshot, true);
15364 }
15365 match run_git_bounded_with_deadline(
15366 root,
15367 &[
15368 "status",
15369 "--porcelain=v1",
15370 "-z",
15371 "--renames",
15372 "--untracked-files=normal",
15373 "--",
15374 ".",
15375 ],
15376 GIT_OUTPUT_MAX_BYTES,
15377 deadline,
15378 )
15379 .await
15380 {
15381 Ok(output) => {
15382 snapshot.truncated |= output.truncated;
15383 if output.timed_out {
15384 append_git_diagnostic(&mut snapshot, "git status timed out");
15385 } else if output.success {
15386 parse_git_status(&output.stdout, &mut snapshot);
15387 } else {
15388 append_git_diagnostic(
15389 &mut snapshot,
15390 String::from_utf8_lossy(&output.stderr).trim(),
15391 );
15392 }
15393 }
15394 Err(error) => append_git_diagnostic(
15395 &mut snapshot,
15396 &format!("git status failed: {error}"),
15397 ),
15398 }
15399
15400 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15401 return (snapshot, true);
15402 }
15403 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15404 match run_git_bounded_with_deadline(
15405 root,
15406 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15407 16 * 1_024,
15408 deadline,
15409 )
15410 .await
15411 {
15412 Ok(output) => {
15413 snapshot.truncated |= output.truncated;
15414 if output.timed_out {
15415 append_git_diagnostic(&mut snapshot, "git log timed out");
15416 } else if output.success {
15417 parse_git_commits(&output.stdout, &mut snapshot);
15418 } else {
15419 let stderr = String::from_utf8_lossy(&output.stderr);
15420 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15421 append_git_diagnostic(&mut snapshot, stderr.trim());
15422 }
15423 }
15424 }
15425 Err(error) => append_git_diagnostic(
15426 &mut snapshot,
15427 &format!("git log failed: {error}"),
15428 ),
15429 }
15430
15431 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15432 return (snapshot, true);
15433 }
15434 match list_git_worktrees_with_deadline(root, deadline).await {
15435 Ok(worktrees) => {
15436 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15437 }
15438 Err(error) => append_git_diagnostic(
15439 &mut snapshot,
15440 &format!("git worktree list failed: {}", error.message),
15441 ),
15442 }
15443 (snapshot, false)
15444}
15445
15446fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15447 let mut remaining = output;
15448 while !remaining.is_empty() {
15449 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15450 snapshot.truncated = true;
15451 break;
15452 }
15453 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15454 snapshot.truncated = true;
15455 break;
15456 };
15457 let record = &remaining[..record_end];
15458 remaining = &remaining[record_end + 1..];
15459 let has_previous_path = record
15460 .get(..2)
15461 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15462 let previous_path_bytes = if has_previous_path {
15463 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15464 snapshot.truncated = true;
15465 break;
15466 };
15467 let previous = &remaining[..previous_end];
15468 remaining = &remaining[previous_end + 1..];
15469 Some(previous)
15470 } else {
15471 None
15472 };
15473 if record.len() < 4
15474 || record[2] != b' '
15475 || !is_git_status_code(record[0])
15476 || !is_git_status_code(record[1])
15477 {
15478 snapshot.truncated = true;
15479 continue;
15480 }
15481 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15482 snapshot.truncated = true;
15483 continue;
15484 };
15485 let previous_path = match previous_path_bytes {
15486 Some(previous) => {
15487 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15488 snapshot.truncated = true;
15489 continue;
15490 };
15491 Some(previous)
15492 }
15493 None => None,
15494 };
15495 snapshot.status.push(GitStatusEntry {
15496 index_status: (record[0] as char).to_string(),
15497 worktree_status: (record[1] as char).to_string(),
15498 path,
15499 previous_path,
15500 });
15501 }
15502}
15503
15504fn is_git_status_code(byte: u8) -> bool {
15505 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15506}
15507
15508fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15509 for line in String::from_utf8_lossy(output).lines() {
15510 let Some((id, summary)) = line.split_once('\u{1f}') else {
15511 continue;
15512 };
15513 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15514 snapshot.truncated = true;
15515 break;
15516 }
15517 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15518 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15519 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15520 }
15521}
15522
15523fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15524 if value.len() <= max_bytes {
15525 return value.to_owned();
15526 }
15527 *truncated = true;
15528 let mut boundary = max_bytes;
15529 while boundary > 0 && !value.is_char_boundary(boundary) {
15530 boundary -= 1;
15531 }
15532 value[..boundary].to_owned()
15533}
15534
15535fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15536 if message.is_empty() {
15537 return;
15538 }
15539 let normalized = message
15540 .chars()
15541 .map(|character| if character.is_control() { ' ' } else { character })
15542 .collect::<String>();
15543 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15544 if !diagnostic.is_empty() {
15545 diagnostic.push_str("; ");
15546 }
15547 diagnostic.push_str(normalized.trim());
15548 snapshot.diagnostic = Some(truncate_git_field(
15549 &diagnostic,
15550 GIT_DIAGNOSTIC_MAX_BYTES,
15551 &mut snapshot.truncated,
15552 ));
15553}
15554
15555async fn run_git_bounded(
15556 root: &str,
15557 arguments: &[&str],
15558 output_limit: usize,
15559) -> io::Result<GitCommandOutput> {
15560 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15561}
15562
15563async fn read_git_history_bounded(
15564 root: &str,
15565 path: Option<&RepositoryPath>,
15566 before: Option<&GitObjectId>,
15567 limit: u16,
15568) -> Result<GitHistoryPage, NodeFailure> {
15569 let path = path
15570 .map(|path| {
15571 path.as_utf8().ok_or_else(|| {
15572 failure(
15573 NodeFailureCode::InvalidRepositoryPath,
15574 "git history path must use UTF-8",
15575 )
15576 })
15577 })
15578 .transpose()?
15579 .unwrap_or(".");
15580 let count = (usize::from(limit) + 1).to_string();
15581 let start = before
15582 .map(|revision| format!("{}^", revision.as_str()))
15583 .unwrap_or_else(|| "HEAD".to_owned());
15584 let arguments = [
15585 "--no-pager",
15586 "log",
15587 "--no-decorate",
15588 "--date=iso-strict",
15589 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15590 "-n",
15591 count.as_str(),
15592 start.as_str(),
15593 "--",
15594 path,
15595 ];
15596 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15597 .await
15598 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15599 if output.timed_out {
15600 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15601 }
15602 if !output.success {
15603 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15604 }
15605 let mut commits = parse_git_history(&output.stdout);
15606 let has_more = commits.len() > usize::from(limit) || output.truncated;
15607 commits.truncate(usize::from(limit));
15608 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15609 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15610}
15611
15612fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15613 String::from_utf8_lossy(output)
15614 .split('\u{1e}')
15615 .filter_map(|record| {
15616 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15617 if fields.len() != 9 {
15618 return None;
15619 }
15620 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15621 let parents = if fields[1].is_empty() {
15622 Vec::new()
15623 } else {
15624 fields[1]
15625 .split(' ')
15626 .map(|value| GitObjectId::new(value.to_owned()))
15627 .collect::<Result<Vec<_>, _>>()
15628 .ok()?
15629 };
15630 Some(GitCommitDetails {
15631 id,
15632 parents,
15633 subject: bounded_git_text(fields[2], 512),
15634 author_name: bounded_git_text(fields[3], 256),
15635 author_email: bounded_git_text(fields[4], 320),
15636 authored_at: bounded_git_text(fields[5], 64),
15637 committer_name: bounded_git_text(fields[6], 256),
15638 committer_email: bounded_git_text(fields[7], 320),
15639 committed_at: bounded_git_text(fields[8], 64),
15640 signature_status: GitSignatureStatus::NoSignature,
15641 signer: None,
15642 })
15643 })
15644 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15645 .collect()
15646}
15647
15648fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15649 let normalized = value
15650 .chars()
15651 .map(|character| if character.is_control() { ' ' } else { character })
15652 .collect::<String>();
15653 if normalized.len() <= max_bytes {
15654 return normalized;
15655 }
15656 let mut boundary = max_bytes;
15657 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15658 boundary -= 1;
15659 }
15660 normalized[..boundary].to_owned()
15661}
15662
15663async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15664 let path = request.path.as_ref().map(|path| {
15665 path.as_utf8()
15666 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15667 }).transpose()?;
15668 let mut arguments = match &request.mode {
15669 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15670 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15671 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15672 };
15673 if let GitDiffMode::Commit { revision } = &request.mode {
15674 arguments.push(revision.as_str());
15675 }
15676 arguments.push("--");
15677 if let Some(path) = path {
15678 arguments.push(path);
15679 }
15680 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15681 .await
15682 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15683 if output.timed_out {
15684 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15685 }
15686 if !output.success {
15687 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15688 }
15689 Ok(GitDiff {
15690 mode: request.mode,
15691 path: request.path,
15692 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15693 truncated: output.truncated,
15694 })
15695}
15696
15697async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15698 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15699 ResponseEnvelope { request_id: envelope.request_id, result }
15700}
15701
15702async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15703 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15704 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15705 }
15706 let read_only = matches!(
15707 &request,
15708 NodeRequest::Snapshot
15709 | NodeRequest::Resync { .. }
15710 | NodeRequest::BrowseHostDirectories { .. }
15711 | NodeRequest::InspectWorkspace { .. }
15712 | NodeRequest::ReadWorkspaceFile { .. }
15713 | NodeRequest::ReadGitHistory { .. }
15714 | NodeRequest::ReadGitDiff { .. }
15715 | NodeRequest::CatalogNativeSessions { .. }
15716 | NodeRequest::PageNativeSessions { .. }
15717 | NodeRequest::PreviewNativeSession { .. }
15718 | NodeRequest::PreviewSessionRecord { .. }
15719 );
15720 let _mutation_guard = if read_only {
15721 None
15722 } else {
15723 let guard = shared.mutation_gate.lock().await;
15724 if shared.shutdown.load(Ordering::Acquire) {
15725 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15726 }
15727 Some(guard)
15728 };
15729 match request {
15730 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15731 event_sequence: shared.current_sequence(),
15732 controller: shared.controller_state(),
15733 snapshot: shared.snapshot(),
15734 }),
15735 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15736 NodeRequest::ArmHarnessMcpReservation {
15737 reservation_id,
15738 activation_digest,
15739 spawn_spec,
15740 launch,
15741 expires_at_unix_ms,
15742 } => {
15743 shared.require_controller(connection_id, role)?;
15744 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15745 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15746 })?;
15747 let expires_at_unix_ms = registry
15748 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15749 .await
15750 .map_err(harness_mcp_failure)?;
15751 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15752 }
15753 NodeRequest::SpawnSpecWithHarnessMcp {
15754 reservation_id,
15755 activation_digest,
15756 spec,
15757 deadline_unix_ms,
15758 } => {
15759 shared.require_controller(connection_id, role)?;
15760 let receipt = shared
15761 .spawn_from_spec_with_harness_mcp(
15762 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15763 )
15764 .await?;
15765 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15766 }
15767 NodeRequest::ActivateHarnessMcpReservation {
15768 reservation_id,
15769 activation_digest,
15770 record_id,
15771 session,
15772 } => {
15773 shared.require_controller(connection_id, role)?;
15774 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15775 current.instance_id == session.session.instance_id
15776 && current.generation == session.session.generation
15777 }).and_then(|current| current.process_id).ok_or_else(|| {
15778 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15779 })?;
15780 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15781 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15782 })?.activate(
15783 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15784 ).map_err(harness_mcp_failure)?;
15785 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15786 }
15787 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15788 shared.require_controller(connection_id, role)?;
15789 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15790 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15791 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15792 if let Some(instance_id) = instance_id {
15793 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15794 }
15795 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15796 }
15797 NodeRequest::PutHarnessMcpReplyChunk {
15798 reservation_id, activation_digest, record_id, session, call_id,
15799 offset, final_chunk, chunk_hex,
15800 } => {
15801 shared.require_controller(connection_id, role)?;
15802 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15803 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15804 })?.put_reply_chunk(
15805 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15806 offset, final_chunk, &chunk_hex,
15807 ).map_err(harness_mcp_failure)?;
15808 Ok(NodeResponse::ReplyChunkAccepted {
15809 reservation_id, activation_digest, record_id, session, call_id,
15810 next_offset, completed,
15811 })
15812 }
15813 NodeRequest::RejectHarnessMcpCall {
15814 reservation_id, activation_digest, record_id, session, call_id, reason,
15815 } => {
15816 shared.require_controller(connection_id, role)?;
15817 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15818 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15819 })?.reject_call(
15820 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15821 ).map_err(harness_mcp_failure)?;
15822 Ok(NodeResponse::CallRejected {
15823 reservation_id, activation_digest, record_id, session, call_id,
15824 })
15825 }
15826 NodeRequest::BeginDeliveryStage { manifest } => {
15827 shared.require_controller(connection_id, role)?;
15828 shared.begin_delivery_stage(manifest)
15829 }
15830 NodeRequest::PutDeliveryBlobChunk {
15831 stage_id,
15832 blob_digest,
15833 offset,
15834 chunk_hex,
15835 } => {
15836 shared.require_controller(connection_id, role)?;
15837 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15838 }
15839 NodeRequest::CommitDeliveryStage { stage_id } => {
15840 shared.require_controller(connection_id, role)?;
15841 let receipt = shared.commit_delivery_stage(stage_id)?;
15842 Ok(NodeResponse::DeliveryCommitted { receipt })
15843 }
15844 NodeRequest::AbortDeliveryStage { stage_id } => {
15845 shared.require_controller(connection_id, role)?;
15846 shared.abort_delivery_stage(&stage_id)?;
15847 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15848 }
15849 NodeRequest::BrowseHostDirectories { directory, after } => {
15850 let listing = shared.browse_host_directories(directory, after).await?;
15851 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15852 }
15853 NodeRequest::InspectWorkspace { workspace_id } => {
15854 let inspection = shared.inspect_workspace(workspace_id).await?;
15855 Ok(NodeResponse::WorkspaceInspected { inspection })
15856 }
15857 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15858 let file = shared.read_workspace_file(workspace_id, path).await?;
15859 Ok(NodeResponse::WorkspaceFileRead { file })
15860 }
15861 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15862 shared.require_controller(connection_id, role)?;
15863 let file = shared
15864 .write_workspace_file(workspace_id, path, expected_revision, text)
15865 .await?;
15866 Ok(NodeResponse::WorkspaceFileWritten { file })
15867 }
15868 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15869 shared.require_controller(connection_id, role)?;
15870 let file = shared.create_workspace_file(workspace_id, path).await?;
15871 Ok(NodeResponse::WorkspaceFileCreated { file })
15872 }
15873 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15874 shared.require_controller(connection_id, role)?;
15875 let entry = shared
15876 .create_workspace_directory(workspace_id.clone(), path)
15877 .await?;
15878 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15879 }
15880 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15881 if role != ClientRole::Operator {
15882 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15883 }
15884 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15885 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15886 }
15887 let page = shared
15888 .read_git_history(workspace_id.clone(), path, before, limit)
15889 .await?;
15890 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15891 }
15892 NodeRequest::ReadGitDiff { workspace_id, request } => {
15893 if role != ClientRole::Operator {
15894 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15895 }
15896 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15897 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15898 }
15899 NodeRequest::CatalogNativeSessions { route, limit } => {
15900 if role != ClientRole::Operator {
15901 return Err(failure(
15902 NodeFailureCode::ObserverReadOnly,
15903 "native session catalog requires an operator connection",
15904 ));
15905 }
15906 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15907 return Err(failure(
15908 NodeFailureCode::InvalidRequest,
15909 "native session catalog limit is invalid",
15910 ));
15911 }
15912 let (entries, summary) = shared
15913 .catalog_native_sessions(route.clone(), limit)
15914 .await?;
15915 Ok(NodeResponse::NativeSessionsCataloged {
15916 route,
15917 entries,
15918 summary: Some(summary),
15919 })
15920 }
15921 NodeRequest::PageNativeSessions {
15922 route,
15923 window,
15924 catalog_revision,
15925 recent_cutoff_unix_ms,
15926 after_selection_id,
15927 limit,
15928 } => {
15929 if role != ClientRole::Operator {
15930 return Err(failure(
15931 NodeFailureCode::ObserverReadOnly,
15932 "native session catalog paging requires an operator connection",
15933 ));
15934 }
15935 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15936 || after_selection_id
15937 .as_deref()
15938 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15939 {
15940 return Err(failure(
15941 NodeFailureCode::InvalidRequest,
15942 "native session catalog page request is invalid",
15943 ));
15944 }
15945 let page = shared
15946 .page_native_sessions(
15947 route.clone(),
15948 window,
15949 catalog_revision,
15950 recent_cutoff_unix_ms,
15951 after_selection_id,
15952 limit,
15953 )
15954 .await?;
15955 Ok(NodeResponse::NativeSessionsPaged {
15956 route,
15957 page,
15958 })
15959 }
15960 NodeRequest::PreviewNativeSession {
15961 selection,
15962 message_limit,
15963 } => {
15964 if role != ClientRole::Operator {
15965 return Err(failure(
15966 NodeFailureCode::ObserverReadOnly,
15967 "native session preview requires an operator connection",
15968 ));
15969 }
15970 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15971 .contains(&message_limit)
15972 || selection.validate().is_err()
15973 {
15974 return Err(failure(
15975 NodeFailureCode::InvalidRequest,
15976 "native session preview request is invalid",
15977 ));
15978 }
15979 let preview = shared
15980 .preview_native_session(selection.clone(), message_limit)
15981 .await?;
15982 Ok(NodeResponse::NativeSessionPreviewed {
15983 selection,
15984 preview,
15985 })
15986 }
15987 NodeRequest::IndexNativeSession {
15988 selection,
15989 display_name,
15990 } => {
15991 shared.require_controller(connection_id, role)?;
15992 let record = shared
15993 .index_native_session(selection.clone(), display_name)
15994 .await?;
15995 Ok(NodeResponse::NativeSessionIndexed { selection, record })
15996 }
15997 NodeRequest::PreviewSessionRecord {
15998 record_id,
15999 message_limit,
16000 } => {
16001 if role != ClientRole::Operator {
16002 return Err(failure(
16003 NodeFailureCode::ObserverReadOnly,
16004 "native session preview requires an operator connection",
16005 ));
16006 }
16007 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16008 .contains(&message_limit)
16009 {
16010 return Err(failure(
16011 NodeFailureCode::InvalidRequest,
16012 "native session preview request is invalid",
16013 ));
16014 }
16015 let record = shared.record(&record_id)?;
16016 let working_directory = shared.workspace_root(&record.workspace_id)?;
16017 if !platform::roots_equal(
16018 &working_directory,
16019 windows_path_text(&record.canonical_root),
16020 ) {
16021 return Err(failure(
16022 NodeFailureCode::SessionRecordConflict,
16023 "managed session workspace root changed; refusing to preview another directory",
16024 ));
16025 }
16026 let identity = record.provider_session.clone().ok_or_else(|| {
16027 failure(
16028 NodeFailureCode::BackendOperationFailed,
16029 "native session preview is unavailable for this session record",
16030 )
16031 })?;
16032 if identity.key != ProviderSessionKey::SessionId {
16033 return Err(failure(
16034 NodeFailureCode::BackendOperationFailed,
16035 "native session preview is unavailable for this session identity",
16036 ));
16037 }
16038 let preview = shared
16039 .preview_native_session_by_session_id(
16040 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16041 )
16042 .await?;
16043 if preview.session_id != identity.id {
16044 return Err(failure(
16045 NodeFailureCode::SessionRecordConflict,
16046 "native session preview identity changed while loading",
16047 ));
16048 }
16049 shared.revalidate_session_record_preview(&record, &identity)?;
16050 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16051 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16052 record_id: record_id.clone(),
16053 summary: SessionHistorySummaryV1::from(&preview),
16054 });
16055 Ok(NodeResponse::SessionRecordPreviewed {
16056 record_id,
16057 preview,
16058 })
16059 }
16060 NodeRequest::AcquireController { lease_ms } => {
16061 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16062 Ok(NodeResponse::Controller { controller: Some(controller) })
16063 }
16064 NodeRequest::ReleaseController => {
16065 shared.release_controller(connection_id);
16066 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16067 }
16068 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16069 shared.require_controller(connection_id, role)?;
16070 let root = require_windows_path(root)?;
16071 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16072 let workspace = shared.register_workspace(workspace_id, root).await?;
16073 Ok(NodeResponse::WorkspaceRegistered { workspace })
16074 }
16075 NodeRequest::CreateStandaloneWorkspace {
16076 workspace_id,
16077 root,
16078 initial_branch,
16079 } => {
16080 shared.require_controller(connection_id, role)?;
16081 let root = require_windows_path(root)?;
16082 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16083 let workspace = shared
16084 .create_standalone_workspace(workspace_id, root, initial_branch)
16085 .await?;
16086 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16087 }
16088 NodeRequest::UnregisterWorkspace { workspace_id } => {
16089 shared.require_controller(connection_id, role)?;
16090 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16091 shared.unregister_workspace(&workspace_id)?;
16092 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16093 }
16094 NodeRequest::CreateWorktree {
16095 source_workspace_id,
16096 workspace_id,
16097 target_root,
16098 branch,
16099 base,
16100 } => {
16101 shared.require_controller(connection_id, role)?;
16102 let target_root = require_windows_path(target_root)?;
16103 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16104 let (worktree, workspace) = shared
16105 .create_worktree(
16106 source_workspace_id,
16107 workspace_id,
16108 target_root,
16109 branch,
16110 base,
16111 )
16112 .await?;
16113 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16114 }
16115 NodeRequest::RemoveWorktree {
16116 source_workspace_id,
16117 target_root,
16118 } => {
16119 shared.require_controller(connection_id, role)?;
16120 let native_target_root = require_windows_path(target_root.clone())?;
16121 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16122 let workspace_id = shared
16123 .remove_worktree(source_workspace_id, native_target_root)
16124 .await?;
16125 Ok(NodeResponse::WorktreeRemoved {
16126 target_root,
16127 workspace_id,
16128 })
16129 }
16130 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16131 shared.require_controller(connection_id, role)?;
16132 if !terminal_size.is_valid() {
16133 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16134 }
16135 if let Some(prompt) = initial_prompt.as_deref() {
16136 validate_node_text("spawn initial prompt", prompt)?;
16137 }
16138 match shared
16139 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16140 .await
16141 {
16142 Ok(session) => Ok(NodeResponse::SpawnAccepted { session }),
16143 Err(err) => {
16144 tracing::warn!(
16145 %provider,
16146 %workspace_id,
16147 ?mode,
16148 code = ?err.code,
16149 message = %err.message,
16150 "session spawn refused"
16151 );
16152 Err(err)
16153 }
16154 }
16155 }
16156 NodeRequest::SpawnSpec { spec } => {
16157 shared.require_controller(connection_id, role)?;
16158 let receipt = shared.spawn_from_spec(spec).await?;
16159 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16160 }
16161 NodeRequest::SpawnManagedWorktree { request } => {
16162 shared.require_controller(connection_id, role)?;
16163 let receipt = shared.spawn_managed_worktree(request).await?;
16164 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16165 }
16166 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16167 shared.require_controller(connection_id, role)?;
16168 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16169 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16170 }
16171 NodeRequest::CleanupManagedWorktree { lease_id } => {
16172 shared.require_controller(connection_id, role)?;
16173 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16174 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16175 }
16176 NodeRequest::DiscoverHistory { session, limit } => {
16177 controlled_session(shared, connection_id, role, &session)?;
16178 let candidates = shared.discover_history(&session, limit).await?;
16179 Ok(NodeResponse::HistoryDiscovered {
16180 session,
16181 candidates,
16182 })
16183 }
16184 NodeRequest::LoadHistory {
16185 session,
16186 candidate_id,
16187 } => {
16188 controlled_session(shared, connection_id, role, &session)?;
16189 let loaded = shared
16190 .load_history(&session, candidate_id)
16191 .await?;
16192 Ok(NodeResponse::HistoryLoaded {
16193 session,
16194 session_id: loaded.session_id,
16195 message_count: loaded.message_count,
16196 completed_turn_count: loaded.completed_turn_count,
16197 })
16198 }
16199 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16200 shared.require_controller(connection_id, role)?;
16201 let context = shared
16202 .export_context_pack_for_session_record(&record_id, &session, false)
16203 .await?;
16204 Ok(NodeResponse::ContextPackForSessionRecordExported {
16205 record_id,
16206 session,
16207 context,
16208 })
16209 }
16210 NodeRequest::ExportContextPack { session } => {
16211 controlled_session(shared, connection_id, role, &session)?;
16212 let context = shared.export_context_pack(&session).await?;
16213 Ok(NodeResponse::ContextPackExported { context })
16214 }
16215 NodeRequest::ForgetContextPack { context_id } => {
16216 shared.require_controller(connection_id, role)?;
16217 shared.forget_context_pack(&context_id)?;
16218 Ok(NodeResponse::ContextPackForgotten { context_id })
16219 }
16220 NodeRequest::ResolveDurableContextPack { context_id } => {
16221 let context = shared
16225 .context_catalog
16226 .read()
16227 .unwrap_or_else(|poisoned| poisoned.into_inner())
16228 .get(&context_id)
16229 .map(|pack| pack.receipt().clone())
16230 .ok_or_else(|| {
16231 failure(
16232 NodeFailureCode::UnknownContextPack,
16233 "context pack is not present",
16234 )
16235 })?;
16236 Ok(NodeResponse::DurableContextPackResolved { context })
16237 }
16238 NodeRequest::ReadContextPack { digest } => {
16239 let (id, byte_len, bytes) = shared
16244 .context_catalog
16245 .read()
16246 .unwrap_or_else(|poisoned| poisoned.into_inner())
16247 .get_by_digest(&digest)
16248 .map(|pack| {
16249 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16250 })
16251 .ok_or_else(|| {
16252 failure(
16253 NodeFailureCode::UnknownContextPack,
16254 &format!("context pack {digest} not on this node"),
16255 )
16256 })?;
16257 tracing::debug!(
16258 digest = digest.as_str(),
16259 byte_len,
16260 "read context pack bytes",
16261 );
16262 Ok(NodeResponse::ContextPackBytesRead {
16263 pack: ContextPackBytesRead {
16264 digest,
16265 id,
16266 byte_len,
16267 bytes,
16268 },
16269 })
16270 }
16271 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16272 let provider = controlled_session(shared, connection_id, role, &session)?;
16273 if let Some(prompt) = initial_prompt.as_deref() {
16274 validate_node_text("resume initial prompt", prompt)?;
16275 }
16276 let runtime_requirement = if initial_prompt.is_some() {
16277 ProviderRuntimeRequirement::ResumeWithPrompt
16278 } else {
16279 ProviderRuntimeRequirement::Resume
16280 };
16281 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16282 let runtime_policy = shared
16283 .admit_provider_runtime(&provider, runtime_requirement)
16284 .await?;
16285 let working_directory = shared.bound_workspace_root(&session)?;
16286 let request = ResumeLaunchRequest {
16287 working_directory,
16288 terminal_size,
16289 initial_prompt,
16290 };
16291 request
16292 .validate()
16293 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16294 let command = shared.prepare_command(ControlCommand::Resume {
16295 instance_id: session.session.instance_id,
16296 target: ResumeTarget::CurrentProvider,
16297 runtime_policy,
16298 request,
16299 });
16300 shared.arm_resume(&session, command.id, runtime_policy)?;
16301 let dispatch = shared.dispatch_envelope(command);
16302 if let Err(error) = dispatch {
16303 shared.clear_armed_resume(&session);
16304 return Err(error);
16305 }
16306 Ok(NodeResponse::Accepted)
16307 }
16308 NodeRequest::RenameSessionRecord {
16309 record_id,
16310 display_name,
16311 } => {
16312 shared.require_controller(connection_id, role)?;
16313 let record = shared.rename_session_record(&record_id, display_name)?;
16314 Ok(NodeResponse::SessionRecordUpdated { record })
16315 }
16316 NodeRequest::SetSessionTask {
16317 record_id,
16318 expected_revision,
16319 target,
16320 } => {
16321 shared.require_controller(connection_id, role)?;
16322 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16323 Ok(NodeResponse::SessionRecordUpdated { record })
16324 }
16325 NodeRequest::IndexProviderSession {
16326 workspace_id,
16327 provider,
16328 identity,
16329 display_name,
16330 } => {
16331 shared.require_controller(connection_id, role)?;
16332 let record = shared.index_provider_session(
16333 workspace_id,
16334 provider,
16335 identity,
16336 display_name,
16337 )?;
16338 Ok(NodeResponse::ProviderSessionIndexed { record })
16339 }
16340 NodeRequest::ResumeSessionRecord {
16341 record_id,
16342 terminal_size,
16343 initial_prompt,
16344 } => {
16345 shared.require_controller(connection_id, role)?;
16346 if let Some(prompt) = initial_prompt.as_deref() {
16347 validate_node_text("managed resume initial prompt", prompt)?;
16348 }
16349 let (record, session) = shared
16350 .resume_session_record(
16351 &record_id,
16352 terminal_size,
16353 initial_prompt,
16354 )
16355 .await?;
16356 Ok(NodeResponse::SessionRecordResumed { record, session })
16357 }
16358 NodeRequest::ForgetSessionRecord { record_id } => {
16359 shared.require_controller(connection_id, role)?;
16360 shared.forget_session_record(&record_id).await?;
16361 Ok(NodeResponse::SessionRecordForgotten { record_id })
16362 }
16363 NodeRequest::Prompt { session, text } => {
16364 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16365 validate_node_text("prompt", &text)?;
16366 shared.require_session_runtime_policy(
16367 &session,
16368 ProviderRuntimeRequirement::SemanticPrompt,
16369 )?;
16370 let framing = prompt_framing(&agent_id);
16371 shared
16372 .dispatch_input_bounded(
16373 &session,
16374 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16375 )
16376 .await?;
16377 Ok(NodeResponse::Accepted)
16378 }
16379 NodeRequest::Paste { session, text } => {
16380 controlled_session(shared, connection_id, role, &session)?;
16381 validate_node_text("paste", &text)?;
16382 shared.require_session_runtime_policy(
16383 &session,
16384 ProviderRuntimeRequirement::SemanticPrompt,
16385 )?;
16386 shared
16387 .dispatch_input_bounded(
16388 &session,
16389 InputAction::InsertDraft(PromptPayload {
16390 text,
16391 framing: PromptFraming::BracketedPaste,
16392 }),
16393 )
16394 .await?;
16395 Ok(NodeResponse::Accepted)
16396 }
16397 NodeRequest::Input { session, text } => {
16398 controlled_session(shared, connection_id, role, &session)?;
16399 validate_node_text("terminal input", &text)?;
16400 shared
16401 .dispatch_input_bounded(
16402 &session,
16403 InputAction::TerminalText(TerminalText { text }),
16404 )
16405 .await?;
16406 Ok(NodeResponse::Accepted)
16407 }
16408 NodeRequest::TerminalBytes { session, bytes } => {
16409 controlled_session(shared, connection_id, role, &session)?;
16410 let action = terminal_bytes_action(bytes)?;
16411 shared
16412 .dispatch_input_bounded(&session, action)
16413 .await?;
16414 Ok(NodeResponse::Accepted)
16415 }
16416 NodeRequest::TerminalControl { session, control } => {
16417 controlled_session(shared, connection_id, role, &session)?;
16418 shared
16419 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16420 .await?;
16421 Ok(NodeResponse::Accepted)
16422 }
16423 NodeRequest::Resize { session, size } => {
16424 controlled_session(shared, connection_id, role, &session)?;
16425 if !size.is_valid() {
16426 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16427 }
16428 shared.dispatch_resize_bounded(&session, size).await?;
16429 Ok(NodeResponse::Accepted)
16430 }
16431 NodeRequest::Interrupt { session } => {
16432 controlled_session(shared, connection_id, role, &session)?;
16433 shared
16434 .dispatch_input_bounded(
16435 &session,
16436 InputAction::TerminalControl(TerminalControl::Interrupt),
16437 )
16438 .await?;
16439 Ok(NodeResponse::Accepted)
16440 }
16441 NodeRequest::Stop { session, force } => {
16442 controlled_session(shared, connection_id, role, &session)?;
16443 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16444 Ok(NodeResponse::Accepted)
16445 }
16446 NodeRequest::Remove { session } => {
16447 controlled_session(shared, connection_id, role, &session)?;
16448 shared.remove_session(&session).await?;
16449 Ok(NodeResponse::Accepted)
16450 }
16451 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16452 shared.require_controller(connection_id, role)?;
16453 let snapshot = shared.require_acp_session(&session)?;
16454 let interaction = snapshot
16455 .provider
16456 .interactions
16457 .iter()
16458 .find(|interaction| {
16459 interaction_correlation(
16460 session.session.instance_id,
16461 session.session.generation,
16462 interaction.id.0,
16463 ) == correlation_id
16464 })
16465 .ok_or_else(|| failure(
16466 NodeFailureCode::InvalidRequest,
16467 "unknown interaction correlation",
16468 ))?;
16469 if interaction.status != ProviderInteractionStatus::Pending {
16470 return Err(failure(
16471 NodeFailureCode::BackendOperationFailed,
16472 "interaction has already been resolved",
16473 ));
16474 }
16475 response
16476 .validate_for(interaction.interaction_kind)
16477 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16478 let interaction_id = interaction.id;
16479 let response_kind = response.kind();
16480 let response_outcome = response.outcome();
16481 shared.dispatch(ControlCommand::ResolveInteraction {
16482 instance_id: session.session.instance_id,
16483 generation: session.session.generation,
16484 interaction_id,
16485 response,
16486 })?;
16487 tracing::info!(
16493 node_id = %shared.node_id,
16494 session = ?session.session,
16495 correlation_id = %correlation_id,
16496 interaction_id = interaction_id.0,
16497 response_kind = ?response_kind,
16498 outcome = ?response_outcome,
16499 "interaction resolved",
16500 );
16501 Ok(NodeResponse::Accepted)
16502 }
16503 NodeRequest::SetSessionMode { session, mode_id } => {
16504 shared.require_controller(connection_id, role)?;
16505 shared.require_acp_session(&session)?;
16506 shared.dispatch(ControlCommand::SetSessionMode {
16507 instance_id: session.session.instance_id,
16508 mode_id,
16509 })?;
16510 Ok(NodeResponse::Accepted)
16511 }
16512 NodeRequest::SetSessionConfigOption {
16513 session,
16514 option_id,
16515 value_json,
16516 } => {
16517 shared.require_controller(connection_id, role)?;
16518 shared.require_acp_session(&session)?;
16519 shared.dispatch(ControlCommand::SetSessionConfigOption {
16520 instance_id: session.session.instance_id,
16521 option_id,
16522 value_json,
16523 })?;
16524 Ok(NodeResponse::Accepted)
16525 }
16526 NodeRequest::SetSessionModel { session, model_id } => {
16527 shared.require_controller(connection_id, role)?;
16528 shared.require_acp_session(&session)?;
16529 shared.dispatch(ControlCommand::SetSessionModel {
16530 instance_id: session.session.instance_id,
16531 model_id,
16532 })?;
16533 Ok(NodeResponse::Accepted)
16534 }
16535 NodeRequest::Shutdown => {
16536 shared.require_controller(connection_id, role)?;
16537 shared.begin_shutdown_locked().await?;
16538 Ok(NodeResponse::ShuttingDown)
16539 }
16540 }
16541}
16542
16543fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16544 shared.require_controller(connection_id, role)?;
16545 shared.validate_address(session)
16546}
16547
16548fn select_history_candidate(
16554 candidates: &[HistoryCandidateSummary],
16555 provider_session_id: Option<&str>,
16556) -> Result<String, NodeFailure> {
16557 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16558 Some(id) => candidate.session_id_hint == id,
16559 None => true,
16560 });
16561 let candidate = matches.next().ok_or_else(|| {
16562 failure(
16563 NodeFailureCode::SessionRecordConflict,
16564 if provider_session_id.is_some() {
16565 "no history candidate matches the managed provider session"
16566 } else {
16567 "no history candidate exists for the managed session"
16568 },
16569 )
16570 })?;
16571 if matches.next().is_some() {
16572 return Err(failure(
16573 NodeFailureCode::SessionRecordConflict,
16574 if provider_session_id.is_some() {
16575 "history identity is ambiguous for the managed provider session"
16576 } else {
16577 "history is ambiguous without a verified provider session identity"
16578 },
16579 ));
16580 }
16581 Ok(candidate.id.clone())
16582}
16583
16584fn resolved_spawn_profile_summary(
16585 profile: &SpawnProfileDefaults,
16586 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16587) -> Option<SpawnProfileSummary> {
16588 let environment_profile = match profile.environment_profile_id.as_ref() {
16589 None => None,
16590 Some(profile_id) => {
16591 let binding = environment_profiles.get(profile_id)?;
16592 if binding.provider != profile.provider
16593 || binding.native_profile_id(profile.mode).is_none()
16594 {
16595 return None;
16596 }
16597 Some(ResolvedEnvironmentProfileReceipt {
16598 profile_id: binding.id.clone(),
16599 profile_revision: binding.revision.clone(),
16600 network_allowlist: None,
16601 browser_profile_id: None,
16602 })
16603 }
16604 };
16605 Some(SpawnProfileSummary {
16606 id: profile.profile_id.clone(),
16607 revision: profile.revision.clone(),
16608 environment_profile,
16609 })
16610}
16611
16612fn session_record_export_target_matches(
16631 expected: &ManagedSessionRecord,
16632 current: &ManagedSessionRecord,
16633 session: &SessionAddress,
16634 allow_clean_detachment: bool,
16635) -> bool {
16636 if current == expected && current.active_session.as_ref() == Some(session) {
16637 return current.state == ManagedSessionState::Live;
16638 }
16639 allow_clean_detachment
16640 && current.state == ManagedSessionState::Dormant
16641 && current.active_session.is_none()
16642 && current.record_id == expected.record_id
16643 && current.display_name == expected.display_name
16644 && current.provider == expected.provider
16645 && current.mode == expected.mode
16646 && current.workspace_id == expected.workspace_id
16647 && current.canonical_root == expected.canonical_root
16648 && current.provider_session == expected.provider_session
16649 && current.environment_profile == expected.environment_profile
16650 && current.bundle == expected.bundle
16651 && current.context_id == expected.context_id
16652 && current.context == expected.context
16653 && current.exported_context == expected.exported_context
16654 && current.task_binding == expected.task_binding
16655 && current.created_at_unix_ms == expected.created_at_unix_ms
16656 && current.last_error == expected.last_error
16657}
16658
16659fn session_record_context_export_binding_is_exact(
16660 expected: &ManagedSessionRecord,
16661 current: &ManagedSessionRecord,
16662 identity: Option<&ProviderSessionIdentity>,
16663 session: &SessionAddress,
16664 binding: &SessionBinding,
16665 provider: &AgentId,
16666 current_root: &str,
16667 allow_clean_detachment: bool,
16668) -> bool {
16669 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16670 && current.provider_session.as_ref() == identity
16671 && current.workspace_id == session.workspace_id
16672 && provider == ¤t.provider
16673 && binding.workspace_id == session.workspace_id
16674 && binding.generation == session.session.generation
16675 && binding.record_id.as_ref() == Some(¤t.record_id)
16676 && platform::roots_equal(
16677 current_root,
16678 windows_path_text(¤t.canonical_root),
16679 )
16680}
16681
16682fn session_record_context_export_source_is_usable(
16683 record: &ManagedSessionRecord,
16684 status: &SessionStatus,
16685 allow_clean_detachment: bool,
16686) -> bool {
16687 (record.state == ManagedSessionState::Live
16688 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16689 && context_pack_source_status_is_usable(status)
16690}
16691
16692fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16693 PromptFraming::BracketedPaste
16694}
16695
16696fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16697 if text.len() > MAX_NODE_TEXT_BYTES {
16698 return Err(failure(
16699 NodeFailureCode::InvalidRequest,
16700 &format!(
16701 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16702 text.len(),
16703 ),
16704 ));
16705 }
16706 Ok(())
16707}
16708
16709fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16710 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16711 return Err(failure(
16712 NodeFailureCode::InvalidRequest,
16713 &format!(
16714 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16715 bytes.len(),
16716 ),
16717 ));
16718 }
16719 Ok(())
16720}
16721
16722fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16723 validate_terminal_bytes(&bytes)?;
16724 Ok(InputAction::TerminalBytes(bytes))
16725}
16726
16727fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16728 if root.is_empty()
16729 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16730 || root.chars().any(char::is_control)
16731 {
16732 return Err(failure(
16733 NodeFailureCode::InvalidWorkspaceRoot,
16734 &format!(
16735 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16736 ),
16737 ));
16738 }
16739 Ok(())
16740}
16741
16742fn canonical_standalone_workspace_candidate(
16743 workspace_id: &WorkspaceId,
16744 root: &str,
16745) -> Result<String, NodeServerError> {
16746 let path = Path::new(root);
16747 match std::fs::symlink_metadata(path) {
16748 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16749 .map(|workspace| workspace.canonical_root),
16750 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16751 if !path.is_absolute() {
16752 return Err(NodeServerError::InvalidWorkspaceRoot {
16753 workspace_id: workspace_id.clone(),
16754 path: root.to_owned(),
16755 message: "path is not absolute".to_owned(),
16756 });
16757 }
16758 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16759 workspace_id: workspace_id.clone(),
16760 path: root.to_owned(),
16761 message: "path has no standalone directory name".to_owned(),
16762 })?;
16763 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16764 workspace_id: workspace_id.clone(),
16765 path: root.to_owned(),
16766 message: "path has no parent directory".to_owned(),
16767 })?;
16768 let canonical = std::fs::canonicalize(parent)
16769 .map(|parent| parent.join(name))
16770 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16771 workspace_id: workspace_id.clone(),
16772 path: root.to_owned(),
16773 message: error.to_string(),
16774 })?;
16775 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16776 NodeServerError::InvalidWorkspaceRoot {
16777 workspace_id: workspace_id.clone(),
16778 path: path.to_string_lossy().into_owned(),
16779 message: "canonical path is not valid Unicode".to_owned(),
16780 }
16781 })?;
16782 let canonical_root = platform::normalize_canonical_root(canonical_root);
16783 validate_workspace_root(workspace_id, &canonical_root)?;
16784 Ok(canonical_root)
16785 }
16786 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16787 workspace_id: workspace_id.clone(),
16788 path: root.to_owned(),
16789 message: error.to_string(),
16790 }),
16791 }
16792}
16793
16794fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16795 let Some(revision) = revision else {
16796 return Ok(());
16797 };
16798 if revision.is_empty()
16799 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16800 || revision.chars().any(char::is_control)
16801 {
16802 return Err(failure(
16803 NodeFailureCode::InvalidRequest,
16804 &format!(
16805 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16806 ),
16807 ));
16808 }
16809 Ok(())
16810}
16811
16812fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16813 let code = match error.kind {
16814 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16815 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16816 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16817 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16818 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16819 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16820 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16821 };
16822 failure(code, &error.message)
16823}
16824
16825fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16826 let code = match error.kind {
16827 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16828 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16829 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16830 NodeFailureCode::BackendOperationFailed
16831 }
16832 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16833 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16834 }
16835 };
16836 failure(code, error.message)
16837}
16838
16839fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16840 let (code, message) = match error.kind {
16841 GitWorktreeErrorKind::Invalid => (
16842 NodeFailureCode::InvalidRequest,
16843 "managed Git input is invalid",
16844 ),
16845 GitWorktreeErrorKind::NotRepository => (
16846 NodeFailureCode::NotGitRepository,
16847 "managed source is not a Git repository",
16848 ),
16849 GitWorktreeErrorKind::Conflict => (
16850 NodeFailureCode::WorktreeConflict,
16851 "managed Git identity conflicts with existing repository state",
16852 ),
16853 GitWorktreeErrorKind::Protected => (
16854 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16855 "managed worktree ownership validation failed",
16856 ),
16857 GitWorktreeErrorKind::Dirty => (
16858 NodeFailureCode::WorktreeDirty,
16859 "managed worktree contains uncommitted changes",
16860 ),
16861 GitWorktreeErrorKind::Locked => (
16862 NodeFailureCode::WorktreeLocked,
16863 "managed worktree is locked",
16864 ),
16865 GitWorktreeErrorKind::Failed => (
16866 NodeFailureCode::BackendOperationFailed,
16867 "managed Git operation failed",
16868 ),
16869 };
16870 NodeFailure { code, message: message.to_owned() }
16871}
16872
16873fn managed_spawn_receipt(
16874 resolved: &ResolvedSpawnSpec,
16875 incarnation_id: NodeIncarnationId,
16876 session: SessionAddress,
16877 lease: ManagedWorktreeLeaseSnapshot,
16878 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16879 bundle: Option<ResolvedBundleReceipt>,
16880 context: Option<ResolvedContextPackReceipt>,
16881) -> ManagedWorktreeSpawnReceipt {
16882 let mut managed = resolved.clone();
16883 managed.target.worktree_id = Some(lease.workspace_id.clone());
16884 ManagedWorktreeSpawnReceipt {
16885 spawn: managed.receipt_with_materialization(
16886 incarnation_id,
16887 session,
16888 environment_profile,
16889 bundle,
16890 context,
16891 ),
16892 lease,
16893 }
16894}
16895
16896fn validate_selected_worktree(
16897 source_root: &str,
16898 selected_root: &str,
16899 worktrees: &[NativeGitWorktreeSnapshot],
16900) -> Result<(), NodeFailure> {
16901 if !worktrees
16902 .iter()
16903 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16904 {
16905 return Err(failure(
16906 NodeFailureCode::WorktreeProtected,
16907 "source workspace is not an authoritative non-bare Git worktree root",
16908 ));
16909 }
16910 if !worktrees.iter().any(|worktree| {
16911 worktree_paths_equal(&worktree.path, selected_root)
16912 && !worktree.is_main
16913 && !worktree.is_bare
16914 }) {
16915 return Err(failure(
16916 NodeFailureCode::WorktreeProtected,
16917 "selected workspace is not an eligible Git worktree in the source repository",
16918 ));
16919 }
16920 Ok(())
16921}
16922
16923fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16924 let code = match error.kind() {
16925 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16926 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16927 NodeFailureCode::RepositoryPathUnsafe
16928 }
16929 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16930 WorkspaceFileReadErrorKind::NotRegularFile => {
16931 NodeFailureCode::RepositoryFileNotRegular
16932 }
16933 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16934 NodeFailureCode::RepositoryFileReadFailed
16935 }
16936 WorkspaceFileReadErrorKind::RevisionConflict => {
16937 NodeFailureCode::RepositoryFileRevisionConflict
16938 }
16939 WorkspaceFileReadErrorKind::AlreadyExists
16940 | WorkspaceFileReadErrorKind::ParentNotFound
16941 | WorkspaceFileReadErrorKind::ParentNotDirectory
16942 | WorkspaceFileReadErrorKind::Canceled => {
16943 NodeFailureCode::RepositoryFileReadFailed
16944 }
16945 };
16946 failure(code, "repository file read failed")
16947}
16948
16949fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16950 let code = match error.kind() {
16951 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16952 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16953 NodeFailureCode::RepositoryPathUnsafe
16954 }
16955 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16956 WorkspaceFileReadErrorKind::NotRegularFile => {
16957 NodeFailureCode::RepositoryFileNotRegular
16958 }
16959 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16960 NodeFailureCode::RepositoryFileWriteFailed
16961 }
16962 WorkspaceFileReadErrorKind::RevisionConflict => {
16963 NodeFailureCode::RepositoryFileRevisionConflict
16964 }
16965 WorkspaceFileReadErrorKind::AlreadyExists
16966 | WorkspaceFileReadErrorKind::ParentNotFound
16967 | WorkspaceFileReadErrorKind::ParentNotDirectory
16968 | WorkspaceFileReadErrorKind::Canceled => {
16969 NodeFailureCode::RepositoryFileWriteFailed
16970 }
16971 };
16972 failure(code, "repository file write failed")
16973}
16974
16975async fn settle_workspace_entry_create<T>(
16976 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16977 commit_state: Arc<AtomicU8>,
16978 deadline: Duration,
16979 timeout_message: &'static str,
16980 task_failure_message: &'static str,
16981) -> Result<T, NodeFailure> {
16982 let joined = match timeout(deadline, &mut task).await {
16983 Ok(joined) => joined,
16984 Err(_) => {
16985 if commit_state
16986 .compare_exchange(
16987 WORKSPACE_ENTRY_CREATE_PENDING,
16988 WORKSPACE_ENTRY_CREATE_CANCELED,
16989 Ordering::AcqRel,
16990 Ordering::Acquire,
16991 )
16992 .is_ok()
16993 {
16994 return Err(failure(
16995 NodeFailureCode::RepositoryEntryCreateTimedOut,
16996 timeout_message,
16997 ));
16998 }
16999 debug_assert_eq!(
17000 commit_state.load(Ordering::Acquire),
17001 WORKSPACE_ENTRY_CREATE_COMMITTING,
17002 );
17003 task.await
17004 }
17005 };
17006 joined
17007 .map_err(|_| {
17008 failure(
17009 NodeFailureCode::RepositoryEntryCreateFailed,
17010 task_failure_message,
17011 )
17012 })?
17013 .map_err(workspace_entry_create_failure)
17014}
17015
17016fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17017 let code = match error.kind() {
17018 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17019 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17020 NodeFailureCode::RepositoryPathUnsafe
17021 }
17022 WorkspaceFileReadErrorKind::AlreadyExists => {
17023 NodeFailureCode::RepositoryEntryAlreadyExists
17024 }
17025 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17026 NodeFailureCode::RepositoryParentNotFound
17027 }
17028 WorkspaceFileReadErrorKind::ParentNotDirectory
17029 | WorkspaceFileReadErrorKind::NotRegularFile => {
17030 NodeFailureCode::RepositoryParentNotDirectory
17031 }
17032 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17033 NodeFailureCode::RepositoryEntryCreateFailed
17034 }
17035 WorkspaceFileReadErrorKind::RevisionConflict => {
17036 NodeFailureCode::RepositoryEntryCreateFailed
17037 }
17038 WorkspaceFileReadErrorKind::Canceled => {
17039 NodeFailureCode::RepositoryEntryCreateTimedOut
17040 }
17041 };
17042 failure(code, "repository entry create failed")
17043}
17044
17045fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17046 let value = digest(&SHA256, bytes)
17047 .as_ref()
17048 .iter()
17049 .map(|byte| format!("{byte:02x}"))
17050 .collect();
17051 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17052}
17053
17054fn managed_spawn_request_digest_v2(
17055 request: &ManagedWorktreeSpawnRequestV2,
17056) -> Result<String, NodeFailure> {
17057 let encoded = serde_json::to_vec(request).map_err(|_| {
17058 failure(
17059 NodeFailureCode::InvalidRequest,
17060 "managed worktree V2 request could not be canonicalized",
17061 )
17062 })?;
17063 let mut value = String::with_capacity(71);
17064 value.push_str("sha256:");
17065 for byte in digest(&SHA256, &encoded).as_ref() {
17066 use std::fmt::Write as _;
17067 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17068 }
17069 Ok(value)
17070}
17071
17072fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17073 let code = match error {
17074 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17075 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17076 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17077 NativeSessionPreviewError::UnsupportedProvider
17078 | NativeSessionPreviewError::PreviewUnavailable
17079 | NativeSessionPreviewError::SessionNotFound
17080 | NativeSessionPreviewError::AmbiguousSession => {
17081 NodeFailureCode::BackendOperationFailed
17082 }
17083 };
17084 failure(code, "native session preview failed")
17085}
17086
17087fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17088 let code = match error.kind() {
17089 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17090 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17091 };
17092 failure(code, "host directory browse failed")
17093}
17094
17095fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17096 let code = match error {
17097 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17098 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17099 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17100 NodeFailureCode::DeliveryStageConflict
17101 }
17102 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17103 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17104 NodeFailureCode::DeliveryChunkOutOfOrder
17105 }
17106 DeliveryStoreError::BlobDigestMismatch => {
17107 NodeFailureCode::DeliveryBlobDigestMismatch
17108 }
17109 DeliveryStoreError::BundleDigestMismatch => {
17110 NodeFailureCode::DeliveryBundleDigestMismatch
17111 }
17112 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17113 DeliveryStoreError::Unavailable
17114 | DeliveryStoreError::Corrupt
17115 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17116 };
17117 failure(code, "delivery operation failed")
17118}
17119
17120fn validate_workspace_root(
17121 workspace_id: &WorkspaceId,
17122 canonical_root: &str,
17123) -> Result<(), NodeServerError> {
17124 #[cfg(windows)]
17125 if canonical_root.starts_with(r"\\") {
17126 return Err(NodeServerError::InvalidWorkspaceRoot {
17127 workspace_id: workspace_id.clone(),
17128 path: canonical_root.to_owned(),
17129 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17130 });
17131 }
17132 if !platform::workspace_root_supported(canonical_root)
17133 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17134 {
17135 return Err(NodeServerError::InvalidWorkspaceRoot {
17136 workspace_id: workspace_id.clone(),
17137 path: canonical_root.to_owned(),
17138 message: "canonical path must be absolute, bounded, and free of control characters"
17139 .to_owned(),
17140 });
17141 }
17142 Ok(())
17143}
17144
17145fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17146 NodeFailure {
17147 code,
17148 message: node_failure_category(code).to_owned(),
17149 }
17150}
17151
17152fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17153 let code = match error {
17154 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17155 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17156 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17157 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17158 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17159 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17160 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17161 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17162 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17163 };
17164 failure(code, "harness MCP proxy operation failed")
17165}
17166
17167static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17171
17172fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17179 let trace = launch.trace.as_ref()?;
17180 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17181 if trace_dir.is_empty() {
17182 return None;
17183 }
17184 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17185 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17186 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17187}
17188
17189fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17190 let remaining = deadline.saturating_duration_since(Instant::now());
17191 if remaining.is_zero() {
17192 Err(failure(
17193 NodeFailureCode::SpawnDeadlineExceeded,
17194 "spawn deadline elapsed",
17195 ))
17196 } else {
17197 Ok(remaining)
17198 }
17199}
17200
17201fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17202 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17203 match deadline {
17204 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17205 None => Ok(bounded),
17206 }
17207}
17208
17209fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17210 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17211 failure(
17212 NodeFailureCode::SpawnDeadlineExceeded,
17213 "spawn dispatch exceeded the spawn deadline",
17214 )
17215 } else {
17216 error
17217 }
17218}
17219
17220fn spawn_runtime_capabilities(
17221 capabilities: &SpawnRequiredCapabilities,
17222) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17223 capabilities
17224 .iter()
17225 .map(|capability| match capability.as_str() {
17226 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17227 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17228 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17229 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17230 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17231 }
17232 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17233 _ => Err(failure(
17234 NodeFailureCode::UnsupportedSpawnCapability,
17235 "spawn required capability is unknown",
17236 )),
17237 })
17238 .collect()
17239}
17240
17241fn persistence_failure(_error: io::Error) -> NodeFailure {
17242 NodeFailure {
17243 code: NodeFailureCode::BackendOperationFailed,
17244 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17245 }
17246}
17247
17248fn node_failure_category(code: NodeFailureCode) -> &'static str {
17249 match code {
17250 NodeFailureCode::InvalidRequest => "invalid-request",
17251 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17252 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17253 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17254 NodeFailureCode::ReservationConflict => "reservation-conflict",
17255 NodeFailureCode::ReservationExpired => "reservation-expired",
17256 NodeFailureCode::BindingMismatch => "binding-mismatch",
17257 NodeFailureCode::NotActivated => "not-activated",
17258 NodeFailureCode::CallNotFound => "call-not-found",
17259 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17260 NodeFailureCode::ResponseTooLarge => "response-too-large",
17261 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17262 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17263 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17264 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17265 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17266 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17267 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17268 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17269 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17270 NodeFailureCode::Unauthorized => "unauthorized",
17271 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17272 NodeFailureCode::ControllerBusy => "controller-busy",
17273 NodeFailureCode::ControllerRequired => "controller-required",
17274 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17275 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17276 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17277 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17278 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17279 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17280 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17281 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17282 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17283 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17284 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17285 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17286 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17287 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17288 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17289 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17290 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17291 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17292 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17293 NodeFailureCode::GitReadFailed => "git-read-failed",
17294 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17295 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17296 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17297 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17298 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17299 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17300 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17301 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17302 NodeFailureCode::TurnInFlight => "turn-in-flight",
17303 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17304 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17305 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17306 "unsupported-network-allowlist-mapping"
17307 }
17308 NodeFailureCode::BrowserStationProbeUnavailable => {
17309 "browser-station-probe-unavailable"
17310 }
17311 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17312 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17313 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17314 "environment-profile-binding-mismatch"
17315 }
17316 NodeFailureCode::UnknownBundle => "unknown-bundle",
17317 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17318 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17319 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17320 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17321 NodeFailureCode::ContextPackMaterializationFailed => {
17322 "context-pack-materialization-failed"
17323 }
17324 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17325 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17326 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17327 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17328 NodeFailureCode::LastWorkspace => "last-workspace",
17329 NodeFailureCode::NotGitRepository => "not-git-repository",
17330 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17331 NodeFailureCode::WorktreeProtected => "worktree-protected",
17332 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17333 NodeFailureCode::WorktreeLocked => "worktree-locked",
17334 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17335 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17336 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17337 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17338 "managed-worktree-profile-revision-mismatch"
17339 }
17340 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17341 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17342 "standalone-workspace-recovery-required"
17343 }
17344 NodeFailureCode::UnknownSession => "unknown-session",
17345 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17346 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17347 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17348 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17349 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17350 NodeFailureCode::WorkspaceRegistrationRequired => {
17351 "workspace-registration-required"
17352 }
17353 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17354 NodeFailureCode::StaleGeneration => "stale-generation",
17355 NodeFailureCode::BackendBusy => "backend-busy",
17356 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17357 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17358 NodeFailureCode::ShuttingDown => "shutting-down",
17359 }
17360}
17361
17362fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17363 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17364}
17365
17366fn durable_state_load_error(error: io::Error) -> NodeServerError {
17367 let category = match session_registry::state_load_refusal(&error) {
17368 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17369 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17370 }
17371 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17372 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17373 }
17374 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17375 DURABLE_STATE_CONFLICT_ERROR
17376 }
17377 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17378 };
17379 durable_state_server_error(error, category)
17380}
17381
17382fn unix_time_ms() -> u64 {
17383 std::time::SystemTime::now()
17384 .duration_since(std::time::UNIX_EPOCH)
17385 .unwrap_or_default()
17386 .as_millis()
17387 .min(u64::MAX as u128) as u64
17388}
17389
17390fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17391 if record.provider_session.is_some() {
17392 ManagedSessionState::Dormant
17393 } else {
17394 ManagedSessionState::Unavailable
17395 }
17396}
17397
17398fn active_record_state(
17399 identity_admitted: bool,
17400 provider_session_is_known: bool,
17401) -> ManagedSessionState {
17402 if identity_admitted && !provider_session_is_known {
17403 ManagedSessionState::IdentityPending
17404 } else {
17405 ManagedSessionState::Live
17406 }
17407}
17408
17409fn managed_worktree_record_holders(
17410 records: &[ManagedSessionRecord],
17411) -> Vec<(SessionRecordId, WorkspaceId)> {
17412 records
17413 .iter()
17414 .filter(|record| record.provider_session.is_some())
17415 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17416 .collect()
17417}
17418
17419fn managed_spawn_record_ownership(
17420 binding: &SessionBinding,
17421 effective_runtime_policy: ProviderRuntimePolicy,
17422) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17423 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17424 if effective_runtime_policy.provider_session_identity {
17425 (binding.record_id.clone(), None)
17426 } else {
17427 (None, binding.record_id.clone())
17428 }
17429}
17430
17431#[derive(Debug, Error)]
17432pub enum NodeServerError {
17433 #[error("node endpoint must be a bounded absolute local transport path")]
17434 InvalidEndpoint,
17435 #[error("node access token must contain 1..=4096 bytes")]
17436 InvalidAccessToken,
17437 #[error("node HTTP observer must listen on a loopback address: {0}")]
17438 InvalidApiListen(std::net::SocketAddr),
17439 #[error("node browser bridge must listen on a loopback address: {0}")]
17440 InvalidBridgeListen(std::net::SocketAddr),
17441 #[error("node browser bridge token must contain 1..=4096 bytes")]
17442 InvalidBridgeToken,
17443 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17444 BridgeUnderlayRequiresBridgeListen,
17445 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17446 BridgeUnderlayRequiresBridgeToken,
17447 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17448 InvalidBridgeUnderlayToken,
17449 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17450 InvalidBridgeUnderlayKey,
17451 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17452 InvalidCallHome(std::net::SocketAddr),
17453 #[error("node could not reach its call-home relay: {0}")]
17454 CallHomeTransport(#[source] std::io::Error),
17455 #[error("node durable state path must be an absolute file path: {0}")]
17456 InvalidStatePath(String),
17457 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17458 InvalidHarnessMcpHelper,
17459 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17460 InvalidNetworkAllowlistCatalogPath(String),
17461 #[error("network allowlist catalog could not be read: {0}")]
17462 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17463 #[error("network allowlist catalog line {line}: {message}")]
17464 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17465 #[error("network allowlist catalog: {0}")]
17466 InvalidNetworkAllowlistCatalog(String),
17467 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17468 NetworkAllowlistCatalogCapacity { max: usize },
17469 #[error("the local state directory is unavailable; supply an explicit state path")]
17470 LocalStateDirectoryUnavailable,
17471 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17472 LocalRuntimeDirectoryUnavailable,
17473 #[error("node durable state failed: {0}")]
17474 DurableState(io::Error),
17475 #[error("node requires at least one configured workspace")]
17476 NoWorkspaces,
17477 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17478 InvalidWorkspaceRoot {
17479 workspace_id: WorkspaceId,
17480 path: String,
17481 message: String,
17482 },
17483 #[error("duplicate workspace ID: {0}")]
17484 DuplicateWorkspaceId(WorkspaceId),
17485 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17486 DuplicateWorkspaceRoot {
17487 first: WorkspaceId,
17488 second: WorkspaceId,
17489 root: String,
17490 },
17491 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17492 ManagedWorktreeProfileRequired(WorkspaceId),
17493 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17494 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17495 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17496 DuplicateManagedWorktreeProfile {
17497 workspace_id: WorkspaceId,
17498 profile_id: WorktreeProfileId,
17499 },
17500 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17501 ManagedWorktreeProfileCapacity {
17502 workspace_id: WorkspaceId,
17503 max: usize,
17504 },
17505 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17506 InvalidManagedWorktreeProfile {
17507 workspace_id: WorkspaceId,
17508 profile_id: WorktreeProfileId,
17509 message: String,
17510 },
17511 #[error("duplicate node environment profile: {0}")]
17512 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17513 #[error("node environment profile provider is unavailable: {0}")]
17514 EnvironmentProfileProviderUnavailable(AgentId),
17515 #[error("node environment profile capacity is {max}")]
17516 EnvironmentProfileCapacity { max: usize },
17517 #[error("node environment profiles reuse native profile ID '{0}'")]
17518 DuplicateNativeEnvironmentProfile(String),
17519 #[error("node native environment profile is invalid: {0}")]
17520 NativeEnvironmentProfile(String),
17521 #[error("node session-environment materialization root must be absolute")]
17522 InvalidSessionEnvironmentRoot,
17523 #[error("node session-environment materializer is unavailable")]
17524 SessionEnvironmentMaterializerRequired,
17525 #[error("node session-environment materializer failed to initialize")]
17526 SessionEnvironmentMaterializer,
17527 #[error("node bundle catalog is invalid: {0}")]
17528 BundleCatalog(String),
17529 #[error("node delivery store failed to initialize: {0}")]
17530 DeliveryStore(String),
17531 #[error("node context pack store failed to initialize")]
17532 ContextPackStore,
17533 #[error("active agent registry failed: {0}")]
17534 Registry(String),
17535 #[error("node provider contract manifest is invalid: {0}")]
17536 ProviderContractManifest(String),
17537 #[error("named pipe I/O failed: {0}")]
17538 Io(#[from] io::Error),
17539 #[error("node HTTP observer failed: {0}")]
17540 HttpApi(io::Error),
17541 #[error("node browser bridge failed: {0}")]
17542 BridgeApi(io::Error),
17543 #[error(transparent)]
17544 Frame(#[from] FrameError),
17545 #[error("node handshake failed: {0}")]
17546 Handshake(String),
17547 #[error("node authentication frame was not received before the bounded deadline")]
17548 AuthenticationTimedOut,
17549 #[error("node authentication primitive failed: {0}")]
17550 Authentication(String),
17551 #[error("node incarnation identity generation failed: {0}")]
17552 IncarnationIdentity(String),
17553 #[error("node pre-authentication limiter is closed")]
17554 PreauthClosed,
17555 #[error("node authenticated connection limit was reached")]
17556 AuthenticatedConnectionLimit,
17557 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17558 SignalStreamClosed,
17559 #[error("node stop-all dispatch failed: {0:?}")]
17560 ShutdownDispatch(NodeFailure),
17561 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17562 ShutdownTimedOut { active_native_sessions: usize },
17563}
17564
17565impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17566 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17567 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17568 match error {
17569 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17570 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17571 E::InvalidEntry { line, message } => {
17572 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17573 }
17574 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17575 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17576 }
17577 }
17578}
17579
17580#[cfg(test)]
17581mod agent_stream_blocked_tests {
17582 use super::*;
17583 use gate4agent_types::{
17584 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17585 ProviderSource,
17586 };
17587
17588 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17589 ProviderSource {
17590 family,
17591 binding: AdapterBinding::new(
17592 AdapterId::new(adapter).unwrap(),
17593 "fixture/v1",
17594 AdapterVerification::SyntheticFixture,
17595 )
17596 .unwrap(),
17597 }
17598 }
17599
17600 fn provider_control_event_at(
17601 family: AdapterFamily,
17602 adapter: &str,
17603 source_sequence: u64,
17604 provider_event: ProviderEvent,
17605 ) -> ControlEvent {
17606 ControlEvent {
17607 sequence: 41,
17608 command_id: None,
17609 instance_id: AgentInstanceId(7),
17610 generation: SessionGeneration(3),
17611 event: ControlEventKind::ProviderEvent {
17612 sequence: 8,
17613 source: provider_source(family, adapter),
17614 source_sequence,
17615 event: provider_event,
17616 },
17617 }
17618 }
17619
17620 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17621 provider_control_event_at(family, "fixture", 9, provider_event)
17622 }
17623
17624 fn denied_host_request(
17625 by: ProviderHostDecisionAuthority,
17626 reason: Option<String>,
17627 ) -> ControlEvent {
17628 provider_control_event_at(
17629 AdapterFamily::Acp,
17630 "claude-code",
17631 2,
17632 ProviderEvent::HostRequestObserved {
17633 method: "terminal/create".to_owned(),
17634 params_json: String::new(),
17635 decision: ProviderHostRequestDecision::Denied { by },
17636 outcome: ProviderHostRequestOutcome::Executed,
17637 reason,
17638 },
17639 )
17640 }
17641
17642 fn blocked(
17643 event: &ControlEvent,
17644 ) -> (
17645 Option<String>,
17646 String,
17647 BlockAuthorityV1,
17648 Option<String>,
17649 String,
17650 Option<String>,
17651 ) {
17652 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17653 chunk.validate().expect("minted chunk must validate");
17654 let AgentStreamChunkKindV1::Blocked {
17655 correlation_id,
17656 tool_class,
17657 authority,
17658 reason_kind,
17659 reason,
17660 help,
17661 } = chunk.kind
17662 else {
17663 panic!("expected AgentStreamChunkKindV1::Blocked");
17664 };
17665 (correlation_id, tool_class, authority, reason_kind, reason, help)
17666 }
17667
17668 #[test]
17672 fn the_enabled_provider_matrix_is_exact() {
17673 let registry = active_registry().unwrap();
17674 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17675 assert_eq!(
17676 providers
17677 .iter()
17678 .map(|contract| contract.provider.as_str())
17679 .collect::<Vec<_>>(),
17680 vec!["claude", "codex", "grok", "kimi"],
17681 );
17682 assert!(adapters.iter().all(|contract| {
17683 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17684 }));
17685 }
17686
17687 #[test]
17690 fn gate_denied_host_request_mints_a_blocked_chunk() {
17691 let gate_text =
17692 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17693 let event = denied_host_request(
17694 ProviderHostDecisionAuthority::Gate,
17695 Some(gate_text.to_owned()),
17696 );
17697 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17698 assert_eq!(chunk.source_sequence, 2);
17699 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17700 assert_eq!(authority, BlockAuthorityV1::HostGate);
17701 assert_eq!(reason, gate_text);
17702 assert_eq!(correlation_id, None);
17703 assert_eq!(reason_kind, None);
17704 assert_eq!(help, None);
17705 assert_eq!(tool_class, "Write");
17706 }
17707
17708 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.";
17709
17710 #[test]
17716 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17717 let event = provider_control_event(
17718 AdapterFamily::Acp,
17719 ProviderEvent::ToolCompleted {
17720 id: "toolu_9".to_owned(),
17721 output: CLASSIFIER_SAMPLE.to_owned(),
17722 is_error: true,
17723 duration_ms: None,
17724 agent_id: None,
17725 non_execution_kind: Some("permission-rule".to_owned()),
17726 },
17727 );
17728 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17729 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17730 assert_eq!(reason, "Blocked by classifier");
17731 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17732 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17733 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17734 }
17735
17736 #[test]
17741 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17742 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.";
17743 let event = provider_control_event(
17744 AdapterFamily::Acp,
17745 ProviderEvent::SessionEnded {
17746 result: "JSON-RPC error -32603: Internal error".to_owned(),
17747 cost_usd: None,
17748 is_error: true,
17749 stop_reason: Some(ProviderStopReason::ProviderError {
17750 code: -32603,
17751 message: message.to_owned(),
17752 vendor_code: Some("usageLimitExceeded".to_owned()),
17753 }),
17754 },
17755 );
17756 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17757 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17758 assert_eq!(reason, message);
17759 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17760 assert_eq!(tool_class, "Session");
17761 }
17762
17763 #[test]
17766 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17767 let event = provider_control_event(
17768 AdapterFamily::Acp,
17769 ProviderEvent::SessionEnded {
17770 result: "refusal".to_owned(),
17771 cost_usd: None,
17772 is_error: true,
17773 stop_reason: Some(ProviderStopReason::Refusal),
17774 },
17775 );
17776 let (_, _, authority, _, _, _) = blocked(&event);
17777 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17778 }
17779
17780 #[test]
17784 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17785 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17786 let event = provider_control_event(
17787 AdapterFamily::Acp,
17788 ProviderEvent::ToolCompleted {
17789 id: "toolu_1".to_owned(),
17790 output: "command exited with status 1".to_owned(),
17791 is_error: true,
17792 duration_ms: Some(12),
17793 agent_id: None,
17794 non_execution_kind,
17795 },
17796 );
17797 assert!(agent_stream_chunk(&event).is_none());
17798 }
17799 let narrative = provider_control_event(
17800 AdapterFamily::Acp,
17801 ProviderEvent::ToolCompleted {
17802 id: "toolu_2".to_owned(),
17803 output: "the harness blocked the write, approval needed".to_owned(),
17804 is_error: false,
17805 duration_ms: Some(3),
17806 agent_id: None,
17807 non_execution_kind: None,
17808 },
17809 );
17810 assert!(agent_stream_chunk(&narrative).is_none());
17811 }
17812
17813 #[test]
17817 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17818 for (decision, outcome) in [
17819 (
17820 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17821 ProviderHostRequestOutcome::Executed,
17822 ),
17823 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17824 (
17825 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17826 ProviderHostRequestOutcome::Failed {
17827 error: "terminal/create spawn failed: os error 3".to_owned(),
17828 },
17829 ),
17830 ] {
17831 let event = provider_control_event_at(
17832 AdapterFamily::Acp,
17833 "claude-code",
17834 3,
17835 ProviderEvent::HostRequestObserved {
17836 method: "fs/read_text_file".to_owned(),
17837 params_json: String::new(),
17838 decision: decision.clone(),
17839 outcome,
17840 reason: None,
17841 },
17842 );
17843 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17844 }
17845 }
17846
17847 #[test]
17851 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17852 for (by, expected_authority, expected_reason) in [
17853 (
17854 ProviderHostDecisionAuthority::Operator,
17855 BlockAuthorityV1::Operator,
17856 "declined by the operator",
17857 ),
17858 (
17859 ProviderHostDecisionAuthority::DeadlinePolicy,
17860 BlockAuthorityV1::HostDeadline,
17861 "denied by host policy after the operator deadline expired",
17862 ),
17863 ] {
17864 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17865 assert_eq!(authority, expected_authority);
17866 assert_eq!(reason, expected_reason);
17867 }
17868 }
17869
17870 #[test]
17874 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17875 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17876 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17877 ProviderHostDecisionAuthority::Policy,
17878 Some(oversized),
17879 ));
17880 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17881 }
17882}
17883
17884#[cfg(test)]
17885mod standalone_capability_platform_tests {
17886 use super::*;
17887
17888 #[test]
17889 fn standalone_workspace_capability_requires_windows_identity_guards() {
17890 let capabilities = baseline_capabilities().unwrap();
17891 let advertised = capabilities.iter().any(|capability| {
17892 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17893 });
17894 assert_eq!(advertised, cfg!(windows));
17895
17896 let request = NodeRequest::CreateStandaloneWorkspace {
17897 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17898 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17899 initial_branch: None,
17900 };
17901 assert_eq!(
17902 request_uses_unnegotiated_capability(&request, &capabilities),
17903 !cfg!(windows),
17904 );
17905 }
17906}
17907
17908
17909#[cfg(test)]
17910mod station_knobs_resolve_tests {
17911 use super::*;
17912 use gate4agent_catalog::EnvMutation;
17913 use gate4agent_runtime_native::{
17914 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17915 NativeLaunchProfileId,
17916 };
17917 use std::ffi::OsString;
17918 use std::sync::Arc;
17919
17920 struct EmptyEnvironmentResolver;
17921
17922 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17923 fn resolve_child_environment(
17924 &self,
17925 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17926 Ok(vec![EnvMutation {
17927 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17928 value: None,
17929 }])
17930 }
17931 }
17932
17933 fn agent(value: &str) -> AgentId {
17934 AgentId::new(value).unwrap()
17935 }
17936
17937 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17938 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17939 let profile_revision =
17940 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17941 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17942 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17943 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17944 provider: agent("claude"),
17945 mode: SessionMode::Pty,
17946 terminal_size: gate4agent_types::TerminalSize {
17947 rows: 24,
17948 columns: 80,
17949 },
17950 prompt: None,
17951 bundle_id: None,
17952 context_id: None,
17953 environment_profile_id: Some(profile_id.clone()),
17954 };
17955 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17956 let catalog = active_registry().unwrap();
17957 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17958 let control = runtime.native_launch_profile_control();
17959 let node_profile = NodeEnvironmentProfile::new(
17960 profile_id.clone(),
17961 profile_revision,
17962 agent("claude"),
17963 [NativeLaunchProfile::new(
17964 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17965 agent("claude"),
17966 TransportKind::Pty,
17967 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17968 Arc::new(EmptyEnvironmentResolver),
17969 )
17970 .unwrap()],
17971 )
17972 .unwrap();
17973 let (binding, native_profiles, _materialization) = node_profile.into_parts();
17974 for native_profile in native_profiles {
17975 runtime.upsert_native_launch_profile(native_profile).unwrap();
17976 }
17977 let workspace = WorkspaceConfig::new(
17978 WorkspaceId::new("primary").unwrap(),
17979 std::env::current_dir().unwrap(),
17980 )
17981 .unwrap();
17982 let shared = NodeShared::new_with_incarnation(
17983 handle,
17984 "fixture-token".to_owned(),
17985 NodeId::new("node-station-knobs-test").unwrap(),
17986 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17987 vec![workspace],
17988 vec![agent("claude")],
17989 ProviderRuntimeStatuses::default(),
17990 None,
17991 Vec::new(),
17992 Vec::new(),
17993 spawn_profiles,
17994 Some(control),
17995 None,
17996 None,
17997 None,
17998 Vec::new(),
17999 Vec::new(),
18000 Vec::new(),
18001 Vec::new(),
18002 None,
18003 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18004 );
18005 shared
18006 .environment_profiles
18007 .write()
18008 .unwrap_or_else(|poisoned| poisoned.into_inner())
18009 .insert(binding.id.clone(), binding);
18010 (shared, spawn_defaults)
18011 }
18012
18013 #[test]
18016 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18017 let (shared, defaults) = station_knobs_fixture();
18018 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18019 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18020 let spec = crate::protocol::SpawnSpec {
18021 target: crate::protocol::SpawnTarget {
18022 node_id: shared.node_id.clone(),
18023 workspace_id: WorkspaceId::new("primary").unwrap(),
18024 worktree_id: None,
18025 },
18026 profile_id: defaults.profile_id.clone(),
18027 expected_profile_revision: defaults.revision.clone(),
18028 overrides: crate::protocol::SpawnOverrides {
18029 network_allowlist: Some(unknown),
18030 browser_profile_id: Some(browser),
18031 ..crate::protocol::SpawnOverrides::default()
18032 },
18033 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18034 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18035 "station-knobs-unknown-allowlist",
18036 )
18037 .unwrap(),
18038 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18039 };
18040 let resolved = spec.resolve(&defaults).unwrap();
18041 let failure = shared
18042 .resolve_environment_profile(&resolved)
18043 .expect_err("unknown allowlist must refuse");
18044 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18045 }
18046
18047 #[test]
18053 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18054 let (shared, defaults) = station_knobs_fixture();
18055 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18056 #[cfg(not(feature = "dig2-station-probe"))]
18057 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18058 shared
18059 .network_allowlist_catalog
18060 .write()
18061 .unwrap_or_else(|poisoned| poisoned.into_inner())
18062 .insert_id(allowlist.clone())
18063 .unwrap();
18064 let spec = crate::protocol::SpawnSpec {
18065 target: crate::protocol::SpawnTarget {
18066 node_id: shared.node_id.clone(),
18067 workspace_id: WorkspaceId::new("primary").unwrap(),
18068 worktree_id: None,
18069 },
18070 profile_id: defaults.profile_id.clone(),
18071 expected_profile_revision: defaults.revision.clone(),
18072 overrides: crate::protocol::SpawnOverrides {
18073 network_allowlist: Some(allowlist.clone()),
18074 #[cfg(not(feature = "dig2-station-probe"))]
18075 browser_profile_id: Some(browser.clone()),
18076 ..crate::protocol::SpawnOverrides::default()
18077 },
18078 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18079 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18080 .unwrap(),
18081 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18082 };
18083 let resolved = spec.resolve(&defaults).unwrap();
18084 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18085 #[cfg(not(feature = "dig2-station-probe"))]
18086 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18087 let receipt = shared
18088 .resolve_environment_profile(&resolved)
18089 .expect("resolve ok")
18090 .expect("env profile present");
18091 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18092 #[cfg(not(feature = "dig2-station-probe"))]
18093 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18094 #[cfg(feature = "dig2-station-probe")]
18095 assert_eq!(receipt.browser_profile_id, None);
18096 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18097
18098 let omitted = crate::protocol::SpawnSpec {
18099 overrides: crate::protocol::SpawnOverrides::default(),
18100 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18101 .unwrap(),
18102 ..spec
18103 };
18104 let resolved_omit = omitted.resolve(&defaults).unwrap();
18105 let bare = shared
18106 .resolve_environment_profile(&resolved_omit)
18107 .expect("resolve ok")
18108 .expect("env profile present");
18109 assert_eq!(bare.network_allowlist, None);
18110 assert_eq!(bare.browser_profile_id, None);
18111 }
18112
18113 #[cfg(feature = "dig2-station-probe")]
18118 #[test]
18119 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18120 let (shared, defaults) = station_knobs_fixture();
18121 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18122 let spec = crate::protocol::SpawnSpec {
18123 target: crate::protocol::SpawnTarget {
18124 node_id: shared.node_id.clone(),
18125 workspace_id: WorkspaceId::new("primary").unwrap(),
18126 worktree_id: None,
18127 },
18128 profile_id: defaults.profile_id.clone(),
18129 expected_profile_revision: defaults.revision.clone(),
18130 overrides: crate::protocol::SpawnOverrides {
18131 browser_profile_id: Some(browser),
18132 ..crate::protocol::SpawnOverrides::default()
18133 },
18134 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18135 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18136 "station-knobs-probe-refuse",
18137 )
18138 .unwrap(),
18139 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18140 };
18141 let resolved = spec.resolve(&defaults).unwrap();
18142 let failure = shared
18143 .resolve_environment_profile(&resolved)
18144 .expect_err("browser_profile with failing probe must refuse");
18145 #[cfg(windows)]
18146 {
18147 assert!(
18148 matches!(
18149 failure.code,
18150 NodeFailureCode::BrowserStationUnreachable
18151 | NodeFailureCode::BrowserStationProbeUnavailable
18152 ),
18153 "unexpected code {:?}",
18154 failure.code
18155 );
18156 }
18157 #[cfg(not(windows))]
18158 {
18159 assert_eq!(
18160 failure.code,
18161 NodeFailureCode::BrowserStationProbeUnavailable
18162 );
18163 }
18164 }
18165
18166 #[cfg(feature = "dig2-station-probe")]
18168 #[test]
18169 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18170 let (shared, defaults) = station_knobs_fixture();
18171 let spec = crate::protocol::SpawnSpec {
18172 target: crate::protocol::SpawnTarget {
18173 node_id: shared.node_id.clone(),
18174 workspace_id: WorkspaceId::new("primary").unwrap(),
18175 worktree_id: None,
18176 },
18177 profile_id: defaults.profile_id.clone(),
18178 expected_profile_revision: defaults.revision.clone(),
18179 overrides: crate::protocol::SpawnOverrides::default(),
18180 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18181 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18182 "station-knobs-probe-omit",
18183 )
18184 .unwrap(),
18185 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18186 };
18187 let resolved = spec.resolve(&defaults).unwrap();
18188 let receipt = shared
18189 .resolve_environment_profile(&resolved)
18190 .expect("omit browser_profile must not probe")
18191 .expect("env profile present");
18192 assert_eq!(receipt.browser_profile_id, None);
18193 }
18194
18195 #[cfg(feature = "dig2-station-probe")]
18199 #[test]
18200 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18201 let (shared, _defaults) = station_knobs_fixture();
18202 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18203 let workspace_id = WorkspaceId::new("primary").unwrap();
18204 let address_a = SessionAddress {
18205 workspace_id: workspace_id.clone(),
18206 session: SessionKey {
18207 instance_id: AgentInstanceId(101),
18208 generation: SessionGeneration(1),
18209 },
18210 };
18211 let address_b = SessionAddress {
18212 workspace_id: workspace_id.clone(),
18213 session: SessionKey {
18214 instance_id: AgentInstanceId(102),
18215 generation: SessionGeneration(1),
18216 },
18217 };
18218 let guard_a = crate::dig2_station_lease::acquire_lease(
18219 &shared.browser_station_leases,
18220 profile.clone(),
18221 address_a.session.instance_id,
18222 1,
18223 )
18224 .expect("first lease");
18225 let busy = crate::dig2_station_lease::acquire_lease(
18226 &shared.browser_station_leases,
18227 profile.clone(),
18228 address_b.session.instance_id,
18229 2,
18230 )
18231 .expect_err("second holder must refuse");
18232 assert!(matches!(
18233 busy,
18234 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18235 ));
18236 shared.bind_session_with_policy(
18238 &address_a,
18239 ProviderRuntimePolicy::raw_pty(),
18240 None,
18241 );
18242 guard_a.retain();
18243 assert!(shared.remove_binding(&address_a).is_some());
18244 crate::dig2_station_lease::acquire_lease(
18245 &shared.browser_station_leases,
18246 profile,
18247 address_b.session.instance_id,
18248 3,
18249 )
18250 .expect("reacquire after clear")
18251 .retain();
18252 assert!(
18253 crate::dig2_station_lease::release_lease_for_holder(
18254 &shared.browser_station_leases,
18255 address_b.session.instance_id,
18256 )
18257 );
18258 }
18259
18260 #[cfg(not(feature = "dig2-station-probe"))]
18263 #[test]
18264 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18265 let (shared, defaults) = station_knobs_fixture();
18266 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18267 let spec = crate::protocol::SpawnSpec {
18268 target: crate::protocol::SpawnTarget {
18269 node_id: shared.node_id.clone(),
18270 workspace_id: WorkspaceId::new("primary").unwrap(),
18271 worktree_id: None,
18272 },
18273 profile_id: defaults.profile_id.clone(),
18274 expected_profile_revision: defaults.revision.clone(),
18275 overrides: crate::protocol::SpawnOverrides {
18276 browser_profile_id: Some(browser.clone()),
18277 ..crate::protocol::SpawnOverrides::default()
18278 },
18279 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18280 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18281 .unwrap(),
18282 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18283 };
18284 let resolved = spec.resolve(&defaults).unwrap();
18285 let receipt = shared
18286 .resolve_environment_profile(&resolved)
18287 .expect("stub resolve")
18288 .expect("env profile");
18289 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18290 }
18291
18292
18293 #[test]
18296 fn network_allowlist_catalog_loads_from_temp_file() {
18297 let unique = std::time::SystemTime::now()
18298 .duration_since(std::time::UNIX_EPOCH)
18299 .unwrap()
18300 .as_nanos();
18301 let path = std::env::temp_dir().join(format!(
18302 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18303 std::process::id(),
18304 ));
18305 std::fs::write(
18306 &path,
18307 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18308 )
18309 .unwrap();
18310 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18311 .expect("temp catalog file must load");
18312 let _ = std::fs::remove_file(&path);
18313 assert_eq!(loaded.len(), 2);
18314 let (shared, defaults) = station_knobs_fixture();
18315 {
18316 let mut catalog = shared
18317 .network_allowlist_catalog
18318 .write()
18319 .unwrap_or_else(|poisoned| poisoned.into_inner());
18320 *catalog = loaded;
18321 }
18322 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18323 let spec = crate::protocol::SpawnSpec {
18324 target: crate::protocol::SpawnTarget {
18325 node_id: shared.node_id.clone(),
18326 workspace_id: WorkspaceId::new("primary").unwrap(),
18327 worktree_id: None,
18328 },
18329 profile_id: defaults.profile_id.clone(),
18330 expected_profile_revision: defaults.revision.clone(),
18331 overrides: crate::protocol::SpawnOverrides {
18332 network_allowlist: Some(allowlist.clone()),
18333 ..crate::protocol::SpawnOverrides::default()
18334 },
18335 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18336 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18337 "station-knobs-catalog-file",
18338 )
18339 .unwrap(),
18340 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18341 };
18342 let resolved = spec.resolve(&defaults).unwrap();
18343 let receipt = shared
18344 .resolve_environment_profile(&resolved)
18345 .expect("registered via file load")
18346 .expect("env profile present");
18347 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18348 }
18349
18350 #[test]
18353 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18354 let (shared, _defaults) = station_knobs_fixture();
18355 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18356 shared
18357 .network_allowlist_catalog
18358 .write()
18359 .unwrap_or_else(|poisoned| poisoned.into_inner())
18360 .insert_id(allowlist.clone())
18361 .unwrap();
18362 let snapshot = shared.snapshot();
18363 let inventory = snapshot
18364 .launch_inventory
18365 .expect("launch inventory present");
18366 let ids = inventory
18367 .network_allowlists
18368 .expect("non-empty catalog surfaces ids");
18369 assert_eq!(ids, vec![allowlist]);
18370 }
18371
18372 #[test]
18376 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18377 let (shared, defaults) = station_knobs_fixture();
18378 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18379 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18380 id: allowlist.clone(),
18381 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18382 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18383 peer: "127.0.0.1:443".to_owned(),
18384 }],
18385 provider_native: Some(
18386 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18387 codex_network_access: Some(true),
18388 },
18389 ),
18390 };
18391 shared
18392 .network_allowlist_catalog
18393 .write()
18394 .unwrap_or_else(|poisoned| poisoned.into_inner())
18395 .insert(entry)
18396 .unwrap();
18397 let spec = crate::protocol::SpawnSpec {
18398 target: crate::protocol::SpawnTarget {
18399 node_id: shared.node_id.clone(),
18400 workspace_id: WorkspaceId::new("primary").unwrap(),
18401 worktree_id: None,
18402 },
18403 profile_id: defaults.profile_id.clone(),
18404 expected_profile_revision: defaults.revision.clone(),
18405 overrides: crate::protocol::SpawnOverrides {
18406 network_allowlist: Some(allowlist),
18407 ..crate::protocol::SpawnOverrides::default()
18408 },
18409 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18410 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18411 "station-knobs-unsupported-mapping",
18412 )
18413 .unwrap(),
18414 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18415 };
18416 let resolved = spec.resolve(&defaults).unwrap();
18417 assert_eq!(resolved.provider.as_str(), "claude");
18418 let failure = shared
18419 .resolve_environment_profile(&resolved)
18420 .expect_err("claude + codex_network_access must refuse");
18421 assert_eq!(
18422 failure.code,
18423 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18424 );
18425 }
18426
18427 #[test]
18431 fn resolve_codex_network_access_overlay_moderate_only() {
18432 let (shared, defaults) = station_knobs_fixture();
18433 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18434 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18435 id: allowlist.clone(),
18436 permits: Vec::new(),
18437 provider_native: Some(
18438 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18439 codex_network_access: Some(true),
18440 },
18441 ),
18442 };
18443 shared
18444 .network_allowlist_catalog
18445 .write()
18446 .unwrap_or_else(|poisoned| poisoned.into_inner())
18447 .insert(entry)
18448 .unwrap();
18449
18450 let overlay = shared
18451 .resolve_codex_network_access_overlay_args(
18452 &agent("codex"),
18453 SessionMode::Pty,
18454 ApprovalLevel::Moderate,
18455 Some(&allowlist),
18456 )
18457 .expect("Moderate Codex maps network_access");
18458 assert_eq!(
18459 overlay,
18460 vec![
18461 std::ffi::OsString::from("-c"),
18462 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18463 ]
18464 );
18465
18466 let acp = shared
18467 .resolve_codex_network_access_overlay_args(
18468 &agent("codex"),
18469 SessionMode::Acp,
18470 ApprovalLevel::Moderate,
18471 Some(&allowlist),
18472 )
18473 .unwrap();
18474 assert_eq!(acp, overlay);
18475
18476 let inline = shared
18477 .resolve_codex_network_access_overlay_args(
18478 &agent("codex"),
18479 SessionMode::Inline,
18480 ApprovalLevel::Moderate,
18481 Some(&allowlist),
18482 )
18483 .unwrap_err();
18484 assert_eq!(
18485 inline.code,
18486 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18487 );
18488
18489 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18490 let spec = crate::protocol::SpawnSpec {
18491 target: crate::protocol::SpawnTarget {
18492 node_id: shared.node_id.clone(),
18493 workspace_id: WorkspaceId::new("primary").unwrap(),
18494 worktree_id: None,
18495 },
18496 profile_id: defaults.profile_id.clone(),
18497 expected_profile_revision: defaults.revision.clone(),
18498 overrides: crate::protocol::SpawnOverrides {
18499 provider: crate::protocol::SpawnOverride::Set {
18500 value: agent("codex"),
18501 },
18502 approval_level: Some(level),
18503 network_allowlist: Some(allowlist.clone()),
18504 ..crate::protocol::SpawnOverrides::default()
18505 },
18506 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18507 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18508 "codex-net-refuse-{}",
18509 match level {
18510 ApprovalLevel::ReadOnly => "ro",
18511 ApprovalLevel::FullAuto => "fa",
18512 _ => "other",
18513 }
18514 ))
18515 .unwrap(),
18516 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18517 };
18518 let resolved = spec.resolve(&defaults).unwrap();
18519 let failure = shared
18520 .resolve_environment_profile(&resolved)
18521 .expect_err("RO/FullAuto must refuse");
18522 assert_eq!(
18523 failure.code,
18524 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18525 );
18526 }
18527 }
18528
18529 #[test]
18533 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18534 let (shared, _defaults) = station_knobs_fixture();
18535 let allowlist =
18536 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18537 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18538 id: allowlist.clone(),
18539 permits: vec![
18540 crate::network_allowlist_catalog::NetworkPermitSketch {
18541 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18542 peer: "github.com:443".to_owned(),
18543 },
18544 crate::network_allowlist_catalog::NetworkPermitSketch {
18545 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18546 peer: "*.npmjs.org".to_owned(),
18547 },
18548 ],
18549 provider_native: None,
18550 };
18551 shared
18552 .network_allowlist_catalog
18553 .write()
18554 .unwrap_or_else(|poisoned| poisoned.into_inner())
18555 .insert(entry)
18556 .unwrap();
18557
18558 let unique = std::time::SystemTime::now()
18559 .duration_since(std::time::UNIX_EPOCH)
18560 .unwrap()
18561 .as_nanos();
18562 let provider_home = std::env::temp_dir().join(format!(
18563 "gate4agent-claude-provider-home-{}-{unique}",
18564 std::process::id(),
18565 ));
18566 std::fs::create_dir_all(&provider_home).unwrap();
18567
18568 let overlay = shared
18569 .resolve_network_allowlist_overlay_args(
18570 &agent("claude"),
18571 SessionMode::Pty,
18572 ApprovalLevel::Moderate,
18573 Some(&allowlist),
18574 Some(provider_home.as_path()),
18575 )
18576 .expect("Claude PTY settings overlay on unix");
18577 let settings_path = provider_home.join(
18578 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18579 );
18580 assert_eq!(
18581 overlay,
18582 vec![
18583 std::ffi::OsString::from("--settings"),
18584 std::ffi::OsString::from(settings_path.as_os_str()),
18585 ]
18586 );
18587 let body = std::fs::read_to_string(&settings_path).unwrap();
18588 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18589 assert_eq!(value["sandbox"]["enabled"], true);
18590 assert_eq!(
18591 value["sandbox"]["network"]["allowedDomains"],
18592 serde_json::json!(["github.com", "*.npmjs.org"])
18593 );
18594 assert!(settings_path.starts_with(&provider_home));
18595
18596 let acp = shared
18597 .resolve_network_allowlist_overlay_args(
18598 &agent("claude"),
18599 SessionMode::Acp,
18600 ApprovalLevel::Moderate,
18601 Some(&allowlist),
18602 Some(provider_home.as_path()),
18603 )
18604 .expect_err("Claude ACP must refuse settings overlay");
18605 assert_eq!(
18606 acp.code,
18607 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18608 );
18609
18610 let no_home = shared
18611 .resolve_network_allowlist_overlay_args(
18612 &agent("claude"),
18613 SessionMode::Pty,
18614 ApprovalLevel::Moderate,
18615 Some(&allowlist),
18616 None,
18617 )
18618 .expect_err("Claude overlay requires ProviderHome");
18619 assert_eq!(
18620 no_home.code,
18621 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18622 );
18623
18624 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18625 shared
18626 .network_allowlist_catalog
18627 .write()
18628 .unwrap_or_else(|poisoned| poisoned.into_inner())
18629 .insert_id(bare.clone())
18630 .unwrap();
18631 let empty = shared
18632 .resolve_network_allowlist_overlay_args(
18633 &agent("claude"),
18634 SessionMode::Pty,
18635 ApprovalLevel::Moderate,
18636 Some(&bare),
18637 Some(provider_home.as_path()),
18638 )
18639 .unwrap();
18640 assert!(empty.is_empty());
18641
18642 let _ = std::fs::remove_dir_all(&provider_home);
18643 }
18644
18645 #[test]
18647 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18648 let (shared, _defaults) = station_knobs_fixture();
18649 let allowlist =
18650 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18651 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18652 id: allowlist.clone(),
18653 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18654 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18655 peer: "example.com:443".to_owned(),
18656 }],
18657 provider_native: None,
18658 };
18659 shared
18660 .network_allowlist_catalog
18661 .write()
18662 .unwrap_or_else(|poisoned| poisoned.into_inner())
18663 .insert(entry)
18664 .unwrap();
18665 for provider in ["kimi", "grok"] {
18666 let overlay = shared
18667 .resolve_network_allowlist_overlay_args(
18668 &agent(provider),
18669 SessionMode::Pty,
18670 ApprovalLevel::Moderate,
18671 Some(&allowlist),
18672 None,
18673 )
18674 .unwrap();
18675 assert!(
18676 overlay.is_empty(),
18677 "{provider} must not invent network argv from permits"
18678 );
18679 }
18680 }
18681
18682 #[test]
18684 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18685 let unique = std::time::SystemTime::now()
18686 .duration_since(std::time::UNIX_EPOCH)
18687 .unwrap()
18688 .as_nanos();
18689 let path = std::env::temp_dir().join(format!(
18690 "gate4agent-node-allowlist-json-{}-{unique}.json",
18691 std::process::id(),
18692 ));
18693 std::fs::write(
18694 &path,
18695 r#"{
18696 "schema_version": 2,
18697 "entries": [
18698 {
18699 "id": "egress-default",
18700 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18701 },
18702 { "id": "lab-egress" }
18703 ]
18704 }"#,
18705 )
18706 .unwrap();
18707 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18708 .expect("json v2 catalog must load");
18709 let _ = std::fs::remove_file(&path);
18710 assert_eq!(loaded.len(), 2);
18711 let entry = loaded
18712 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18713 .unwrap();
18714 assert_eq!(entry.permits.len(), 1);
18715 let (shared, defaults) = station_knobs_fixture();
18716 {
18717 let mut catalog = shared
18718 .network_allowlist_catalog
18719 .write()
18720 .unwrap_or_else(|poisoned| poisoned.into_inner());
18721 *catalog = loaded;
18722 }
18723 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18724 let spec = crate::protocol::SpawnSpec {
18725 target: crate::protocol::SpawnTarget {
18726 node_id: shared.node_id.clone(),
18727 workspace_id: WorkspaceId::new("primary").unwrap(),
18728 worktree_id: None,
18729 },
18730 profile_id: defaults.profile_id.clone(),
18731 expected_profile_revision: defaults.revision.clone(),
18732 overrides: crate::protocol::SpawnOverrides {
18733 network_allowlist: Some(allowlist.clone()),
18734 ..crate::protocol::SpawnOverrides::default()
18735 },
18736 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18737 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18738 "station-knobs-catalog-json",
18739 )
18740 .unwrap(),
18741 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18742 };
18743 let resolved = spec.resolve(&defaults).unwrap();
18744 let receipt = shared
18745 .resolve_environment_profile(&resolved)
18746 .expect("permits-only entry ok for claude")
18747 .expect("env profile present");
18748 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18749 let inventory = shared
18750 .snapshot()
18751 .launch_inventory
18752 .expect("launch inventory present");
18753 let ids = inventory.network_allowlists.expect("ids surface");
18754 assert_eq!(
18755 ids,
18756 vec![
18757 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18758 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18759 ]
18760 );
18761 }
18762
18763
18764}
18765
18766
18767#[cfg(test)]
18768mod workspace_environment_collision_tests {
18769 use super::*;
18770 use crate::session_environment::{
18771 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18772 };
18773 use gate4agent_catalog::EnvMutation;
18774 use gate4agent_runtime_native::{
18775 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18776 NativeLaunchProfileId,
18777 };
18778 use std::ffi::OsString;
18779 use std::sync::Arc;
18780
18781 struct EmptyEnvironmentResolver;
18782
18783 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18784 fn resolve_child_environment(
18785 &self,
18786 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18787 Ok(vec![EnvMutation {
18788 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18789 value: None,
18790 }])
18791 }
18792 }
18793
18794 fn agent(value: &str) -> AgentId {
18795 AgentId::new(value).unwrap()
18796 }
18797
18798 fn claude_config_dir_collision_fixture() -> (
18800 NodeShared,
18801 ResolvedEnvironmentProfileReceipt,
18802 ResolvedEnvironmentProfileReceipt,
18803 ) {
18804 let catalog = active_registry().unwrap();
18805 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18806 let control = runtime.native_launch_profile_control();
18807 let workspace = WorkspaceConfig::new(
18808 WorkspaceId::new("primary").unwrap(),
18809 std::env::current_dir().unwrap(),
18810 )
18811 .unwrap();
18812 let shared = NodeShared::new_with_incarnation(
18813 handle,
18814 "fixture-token".to_owned(),
18815 NodeId::new("node-env-collision-test").unwrap(),
18816 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18817 vec![workspace],
18818 vec![agent("claude")],
18819 ProviderRuntimeStatuses::default(),
18820 None,
18821 Vec::new(),
18822 Vec::new(),
18823 SpawnProfileRegistry::default(),
18824 Some(control),
18825 None,
18826 None,
18827 None,
18828 Vec::new(),
18829 Vec::new(),
18830 Vec::new(),
18831 Vec::new(),
18832 None,
18833 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18834 );
18835
18836 let mut receipts = Vec::new();
18837 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18838 let profile_id =
18839 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18840 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18841 "claude-config-dir-{suffix}-r1"
18842 ))
18843 .unwrap();
18844 let materialization = NodeSessionMaterializationProfile::new(
18845 Vec::new(),
18846 vec![NodeSessionPathBinding::new(
18847 "CLAUDE_CONFIG_DIR",
18848 NodeSessionPathClass::ProviderHome,
18849 )
18850 .unwrap()],
18851 Vec::new(),
18852 )
18853 .unwrap();
18854 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18855 profile_id.clone(),
18856 profile_revision.clone(),
18857 agent("claude"),
18858 [NativeLaunchProfile::new(
18859 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18860 .unwrap(),
18861 agent("claude"),
18862 TransportKind::Pty,
18863 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18864 Arc::new(EmptyEnvironmentResolver),
18865 )
18866 .unwrap()],
18867 Some(materialization),
18868 )
18869 .unwrap();
18870 let (binding, native_profiles, materialization) = node_profile.into_parts();
18871 for native_profile in native_profiles {
18872 runtime.upsert_native_launch_profile(native_profile).unwrap();
18873 }
18874 shared
18875 .environment_profiles
18876 .write()
18877 .unwrap_or_else(|poisoned| poisoned.into_inner())
18878 .insert(binding.id.clone(), binding);
18879 shared
18880 .environment_materialization_profiles
18881 .write()
18882 .unwrap_or_else(|poisoned| poisoned.into_inner())
18883 .insert(profile_id.clone(), materialization.unwrap());
18884 receipts.push(ResolvedEnvironmentProfileReceipt {
18885 profile_id,
18886 profile_revision,
18887 network_allowlist: None,
18888 browser_profile_id: None,
18889 });
18890 }
18891 let profile_b = receipts.pop().unwrap();
18892 let profile_a = receipts.pop().unwrap();
18893 (shared, profile_a, profile_b)
18894 }
18895
18896 #[test]
18897 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18898 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18899 let primary = WorkspaceId::new("primary").unwrap();
18900 let worktree = WorkspaceId::new("worktree-b").unwrap();
18901 let address = SessionAddress {
18902 workspace_id: primary.clone(),
18903 session: SessionKey {
18904 instance_id: AgentInstanceId(41),
18905 generation: SessionGeneration(1),
18906 },
18907 };
18908 shared.bind_session_with_policy(
18909 &address,
18910 ProviderRuntimePolicy::raw_pty(),
18911 Some(profile_a.clone()),
18912 );
18913
18914 let refused = shared
18915 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18916 .unwrap_err();
18917 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18918
18919 let refused_none = shared
18921 .ensure_workspace_environment_profile_compatible(&primary, None)
18922 .unwrap_err();
18923 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18924
18925 shared
18927 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18928 .unwrap();
18929
18930 shared
18932 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18933 .unwrap();
18934 }
18935
18936 #[tokio::test]
18937 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18938 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18939 let primary = WorkspaceId::new("primary").unwrap();
18940 let address = SessionAddress {
18941 workspace_id: primary.clone(),
18942 session: SessionKey {
18943 instance_id: AgentInstanceId(41),
18944 generation: SessionGeneration(1),
18945 },
18946 };
18947 shared.bind_session_with_policy(
18948 &address,
18949 ProviderRuntimePolicy::raw_pty(),
18950 Some(profile_a),
18951 );
18952
18953 let failure = shared
18954 .spawn_session_with_deadline(
18955 primary,
18956 agent("claude"),
18957 SessionMode::Pty,
18958 gate4agent_types::TerminalSize {
18959 rows: 24,
18960 columns: 80,
18961 },
18962 None,
18963 Some(profile_b),
18964 None,
18965 None,
18966 None,
18967 None,
18968 SpawnRecordPolicy::ProviderIdentityOnly,
18969 Some(Instant::now() + Duration::from_secs(30)),
18970 &[],
18971 None,
18972 ApprovalLevel::default(),
18973 None,
18974 None,
18975 )
18976 .await
18977 .unwrap_err();
18978 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18979 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18981 assert!(shared.handle.snapshot().sessions.is_empty());
18982 }
18983}
18984
18985#[cfg(test)]
18986mod spawn_characterization_tests {
18987 use super::*;
18988 use crate::session_environment::{
18989 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18990 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18991 NodeSessionPathClass,
18992 };
18993 use gate4agent_catalog::EnvMutation;
18994 use gate4agent_runtime_native::{
18995 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18996 NativeLaunchProfile, NativeLaunchProfileId,
18997 };
18998 use std::ffi::OsString;
18999 use std::sync::Arc;
19000
19001 struct EmptyEnvironmentResolver;
19002
19003 struct FixtureSecretResolver {
19004 deny: Arc<AtomicBool>,
19005 }
19006
19007 impl NodeSecretResolver for FixtureSecretResolver {
19008 fn resolve(
19009 &self,
19010 _reference: &NodeSecretReference,
19011 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19012 if self.deny.load(Ordering::Acquire) {
19013 Err(NodeSecretResolveError::Denied)
19014 } else {
19015 NodeSecretValue::text("fixture-secret")
19016 .map_err(|_| NodeSecretResolveError::Unavailable)
19017 }
19018 }
19019 }
19020
19021 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19022 fn resolve_child_environment(
19023 &self,
19024 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19025 Ok(vec![EnvMutation {
19026 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19027 value: None,
19028 }])
19029 }
19030 }
19031
19032 fn agent(value: &str) -> AgentId {
19033 AgentId::new(value).unwrap()
19034 }
19035
19036 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19037 let unique = std::time::SystemTime::now()
19038 .duration_since(std::time::UNIX_EPOCH)
19039 .unwrap()
19040 .as_nanos();
19041 std::env::temp_dir().join(format!(
19042 "gate4agent-node-{label}-{}-{unique}",
19043 std::process::id(),
19044 ))
19045 }
19046
19047 fn materialization_test_shared(
19048 deny_secret: bool,
19049 ) -> (
19050 NodeShared,
19051 NativeRuntime,
19052 ResolvedEnvironmentProfileReceipt,
19053 PathBuf,
19054 Arc<AtomicBool>,
19055 ) {
19056 let workspace = WorkspaceConfig::new(
19057 WorkspaceId::new("primary").unwrap(),
19058 std::env::current_dir().unwrap(),
19059 )
19060 .unwrap();
19061 materialization_test_shared_with_workspace(deny_secret, workspace)
19062 }
19063
19064 fn materialization_test_shared_with_workspace(
19065 deny_secret: bool,
19066 workspace: WorkspaceConfig,
19067 ) -> (
19068 NodeShared,
19069 NativeRuntime,
19070 ResolvedEnvironmentProfileReceipt,
19071 PathBuf,
19072 Arc<AtomicBool>,
19073 ) {
19074 let root = temporary_workspace_root(if deny_secret {
19075 "materialization-denied"
19076 } else {
19077 "materialization-ready"
19078 });
19079 std::fs::create_dir_all(&root).unwrap();
19080 let materialization_root = root.join("session-environments");
19081 let deny = Arc::new(AtomicBool::new(deny_secret));
19082 let resolver = Arc::new(FixtureSecretResolver {
19083 deny: Arc::clone(&deny),
19084 });
19085 let materializer = SessionEnvironmentMaterializer::new(
19086 materialization_root,
19087 resolver,
19088 )
19089 .unwrap();
19090 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19091 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19092 "materialized-claude-r1",
19093 )
19094 .unwrap();
19095 let materialization = NodeSessionMaterializationProfile::new(
19096 vec![NodeSessionEnvironmentMutation::SetSecret {
19097 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19098 reference: NodeSecretReference::new("fixture-token").unwrap(),
19099 }],
19100 vec![NodeSessionPathBinding::new(
19101 "GATE4AGENT_PROVIDER_HOME",
19102 NodeSessionPathClass::ProviderHome,
19103 )
19104 .unwrap()],
19105 vec![NodeSessionFile::secret(
19106 NodeSessionPathClass::Config,
19107 "auth/token",
19108 NodeSecretReference::new("fixture-token").unwrap(),
19109 )
19110 .unwrap()],
19111 )
19112 .unwrap();
19113 let catalog = active_registry().unwrap();
19114 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19115 let control = runtime.native_launch_profile_control();
19116 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19117 profile_id.clone(),
19118 profile_revision.clone(),
19119 agent("claude"),
19120 [NativeLaunchProfile::new(
19121 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19122 agent("claude"),
19123 TransportKind::Pty,
19124 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19125 Arc::new(EmptyEnvironmentResolver),
19126 )
19127 .unwrap()],
19128 Some(materialization),
19129 )
19130 .unwrap();
19131 let (binding, native_profiles, materialization) = node_profile.into_parts();
19132 for native_profile in native_profiles {
19133 runtime.upsert_native_launch_profile(native_profile).unwrap();
19134 }
19135 let shared = NodeShared::new_with_incarnation(
19136 handle,
19137 "fixture-token".to_owned(),
19138 NodeId::new("node-materialization-test").unwrap(),
19139 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19140 vec![workspace],
19141 vec![agent("claude")],
19142 ProviderRuntimeStatuses::default(),
19143 None,
19144 Vec::new(),
19145 Vec::new(),
19146 SpawnProfileRegistry::default(),
19147 Some(control),
19148 None,
19149 Some(materializer),
19150 None,
19151 Vec::new(),
19152 Vec::new(),
19153 Vec::new(),
19154 Vec::new(),
19155 None,
19156 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19157 );
19158 shared
19159 .environment_profiles
19160 .write()
19161 .unwrap_or_else(|poisoned| poisoned.into_inner())
19162 .insert(binding.id.clone(), binding);
19163 shared
19164 .environment_materialization_profiles
19165 .write()
19166 .unwrap_or_else(|poisoned| poisoned.into_inner())
19167 .insert(profile_id.clone(), materialization.unwrap());
19168 (
19169 shared,
19170 runtime,
19171 ResolvedEnvironmentProfileReceipt {
19172 profile_id,
19173 profile_revision,
19174 network_allowlist: None,
19175 browser_profile_id: None,
19176 },
19177 root,
19178 deny,
19179 )
19180 }
19181
19182
19183 async fn characterization_spawn(
19186 shared: &NodeShared,
19187 mode: SessionMode,
19188 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19189 required: &[ProviderRuntimeCapability],
19190 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19191 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19192 shared
19193 .spawn_session_with_deadline(
19194 WorkspaceId::new("primary").unwrap(),
19195 agent("claude"),
19196 mode,
19197 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19198 None,
19199 environment_profile,
19200 None,
19201 None,
19202 None,
19203 Some(ProviderRuntimePolicy::raw_pty()),
19204 SpawnRecordPolicy::ProviderIdentityOnly,
19205 Some(Instant::now() + Duration::from_secs(30)),
19206 required,
19207 harness_mcp,
19208 ApprovalLevel::default(),
19209 None,
19210 None,
19211 )
19212 .await
19213 }
19214
19215 fn assert_no_session_state(shared: &NodeShared) {
19216 assert!(shared.handle.snapshot().sessions.is_empty());
19217 assert!(shared
19218 .session_bindings
19219 .lock()
19220 .unwrap_or_else(|poisoned| poisoned.into_inner())
19221 .is_empty());
19222 assert!(shared
19223 .materializations
19224 .lock()
19225 .unwrap_or_else(|poisoned| poisoned.into_inner())
19226 .is_empty());
19227 }
19228
19229 #[tokio::test]
19230 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19231 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19232 let error = characterization_spawn(
19233 &shared,
19234 SessionMode::Pty,
19235 None,
19236 &[ProviderRuntimeCapability::SemanticReadiness],
19237 None,
19238 )
19239 .await
19240 .unwrap_err();
19241 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19242 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19243 assert_no_session_state(&shared);
19244 drop(shared);
19245 drop(runtime);
19246 std::fs::remove_dir_all(root).unwrap();
19247 }
19248
19249 #[tokio::test]
19250 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19251 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19252 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19253 .await
19254 .unwrap_err();
19255 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19256 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19257 assert_no_session_state(&shared);
19258 drop(shared);
19259 drop(runtime);
19260 std::fs::remove_dir_all(root).unwrap();
19261 }
19262
19263 #[test]
19264 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19265 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19266 let address = SessionAddress {
19267 workspace_id: WorkspaceId::new("primary").unwrap(),
19268 session: SessionKey {
19269 instance_id: AgentInstanceId(77),
19270 generation: SessionGeneration(1),
19271 },
19272 };
19273 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19274 let removed = shared.remove_binding(&address).expect("binding removed");
19275 assert!(removed.environment_profile.is_some());
19276 assert!(shared.remove_binding(&address).is_none());
19277 assert_no_session_state(&shared);
19278 drop(shared);
19279 drop(runtime);
19280 std::fs::remove_dir_all(root).unwrap();
19281 }
19282
19283 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19284 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19285 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19286 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19287 .expect("review current test executable as fixture helper");
19288 let registry = HarnessMcpProxyRegistry::new(helper);
19289 let spec = crate::protocol::SpawnSpec {
19290 target: crate::protocol::SpawnTarget {
19291 node_id: NodeId::new("node-characterization").unwrap(),
19292 workspace_id: WorkspaceId::new("primary").unwrap(),
19293 worktree_id: None,
19294 },
19295 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19296 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19297 overrides: crate::protocol::SpawnOverrides::default(),
19298 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19299 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19300 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19301 };
19302 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19303 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19304 let launch = HarnessMcpLaunchV1 {
19305 server_name: "fixture-harness".to_owned(),
19306 args: vec!["--session-proxy".to_owned()],
19307 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19308 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19309 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19310 trace: None,
19311 scrub_env: Vec::new(),
19312 };
19313 let expires = unix_time_ms().saturating_add(30_000);
19314 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19315 registry
19316 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19317 .unwrap()
19318 }
19319
19320 #[tokio::test]
19321 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19322 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19323 let prepared = characterization_prepared_harness("claude").await;
19324 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19325 .await
19326 .unwrap_err();
19327 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19329 let mismatched = characterization_prepared_harness("codex").await;
19330 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19331 .await
19332 .unwrap_err();
19333 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19334 assert_no_session_state(&shared);
19335 drop(shared);
19336 drop(runtime);
19337 std::fs::remove_dir_all(root).unwrap();
19338 }
19339
19340 #[tokio::test]
19341 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19342 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19343 shared.native_launch_profile_control = None;
19344 let prepared = characterization_prepared_harness("claude").await;
19345 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19346 .await
19347 .unwrap_err();
19348 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19349 assert_no_session_state(&shared);
19350 drop(shared);
19351 drop(runtime);
19352 std::fs::remove_dir_all(root).unwrap();
19353 }
19354}
19355
19356#[cfg(all(test, windows))]
19357mod tests {
19358 use super::*;
19359 use gate4agent_types::ProviderActivity;
19360 use crate::session_environment::{
19361 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19362 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19363 NodeSessionPathClass,
19364 };
19365 use gate4agent_catalog::EnvMutation;
19366 use gate4agent_runtime_native::{
19367 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19368 NativeLaunchProfile, NativeLaunchProfileId,
19369 };
19370 use std::ffi::OsString;
19371 use std::sync::Arc;
19372
19373 struct EmptyEnvironmentResolver;
19374
19375 struct FixtureSecretResolver {
19376 deny: Arc<AtomicBool>,
19377 }
19378
19379 impl NodeSecretResolver for FixtureSecretResolver {
19380 fn resolve(
19381 &self,
19382 _reference: &NodeSecretReference,
19383 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19384 if self.deny.load(Ordering::Acquire) {
19385 Err(NodeSecretResolveError::Denied)
19386 } else {
19387 NodeSecretValue::text("fixture-secret")
19388 .map_err(|_| NodeSecretResolveError::Unavailable)
19389 }
19390 }
19391 }
19392
19393 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19394 fn resolve_child_environment(
19395 &self,
19396 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19397 Ok(vec![EnvMutation {
19398 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19399 value: None,
19400 }])
19401 }
19402 }
19403
19404 fn agent(value: &str) -> AgentId {
19405 AgentId::new(value).unwrap()
19406 }
19407
19408 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19409 let catalog = active_registry().unwrap();
19410 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19411 let workspace = WorkspaceConfig::new(
19412 WorkspaceId::new("primary").unwrap(),
19413 std::env::current_dir().unwrap(),
19414 )
19415 .unwrap()
19416 .with_worktree_service_mode(mode);
19417 NodeShared::new(
19418 handle,
19419 "fixture-token".to_owned(),
19420 NodeId::new("node-terminal-test").unwrap(),
19421 vec![workspace],
19422 vec![agent("claude")],
19423 )
19424 }
19425
19426 fn terminal_test_shared() -> NodeShared {
19427 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19428 }
19429
19430 #[cfg(feature = "fixture")]
19431 #[test]
19432 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19433 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19434 assert_eq!(probe.latest_code(), None);
19435
19436 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19437 assert_eq!(
19438 probe.latest_code(),
19439 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19440 );
19441
19442 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19443 assert_eq!(
19444 probe.latest_code(),
19445 Some(NodeFailureCode::SpawnDeadlineExceeded),
19446 );
19447
19448 probe.record(None);
19449 assert_eq!(probe.latest_code(), None);
19450 }
19451
19452 #[test]
19453 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19454 let source = temporary_workspace_root("launch-inventory-source");
19455 let allocation = temporary_workspace_root("launch-inventory-allocation");
19456 std::fs::create_dir_all(&source).unwrap();
19457 std::fs::create_dir_all(&allocation).unwrap();
19458 let workspace_id = WorkspaceId::new("primary").unwrap();
19459 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19460 .unwrap()
19461 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19462 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19463 workspace = workspace
19464 .with_managed_worktree_profile(
19465 ManagedWorktreeProfile::new(
19466 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19467 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19468 &allocation,
19469 "gate4agent",
19470 "HEAD",
19471 ManagedWorktreeRetention::RemoveWhenReleased,
19472 )
19473 .unwrap(),
19474 )
19475 .unwrap();
19476 }
19477 let overflow = workspace.clone().with_managed_worktree_profile(
19478 ManagedWorktreeProfile::new(
19479 WorktreeProfileId::new("overflow").unwrap(),
19480 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19481 &allocation,
19482 "gate4agent",
19483 "HEAD",
19484 ManagedWorktreeRetention::Retain,
19485 )
19486 .unwrap(),
19487 );
19488 assert!(matches!(
19489 overflow,
19490 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19491 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19492 ..
19493 }),
19494 ));
19495
19496 let catalog = active_registry().unwrap();
19497 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19498 let shared = NodeShared::new(
19499 handle,
19500 "fixture-token".to_owned(),
19501 NodeId::new("node-launch-inventory").unwrap(),
19502 vec![workspace],
19503 vec![agent("claude")],
19504 );
19505 let snapshot = shared.snapshot();
19506 let launch = snapshot.launch_inventory.as_ref().unwrap();
19507 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19508 assert!(launch.bundles.as_ref().unwrap().is_empty());
19509 let profiles = snapshot.workspaces[0]
19510 .managed_worktree_profiles
19511 .as_ref()
19512 .unwrap();
19513 assert_eq!(
19514 snapshot.workspaces[0].worktree_service_mode,
19515 Some(WorktreeServiceMode::Managed),
19516 );
19517 assert_eq!(
19518 profiles.profiles.len(),
19519 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19520 );
19521 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19522 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19523 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19524 }
19525 let legacy = snapshot_for_wire(
19526 &shared,
19527 true,
19528 true,
19529 false,
19530 false,
19531 true,
19532 false,
19533 true,
19534 false,
19535 true,
19536 );
19537 assert!(legacy.launch_inventory.is_none());
19538 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19539
19540 drop(shared);
19541 std::fs::remove_dir_all(source).unwrap();
19542 std::fs::remove_dir_all(allocation).unwrap();
19543 }
19544
19545 #[test]
19546 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19547 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19548 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19549 }));
19550 let source = gate4agent_types::ProviderSource {
19551 family: AdapterFamily::PtySemantic,
19552 binding: AdapterBinding::new(
19553 gate4agent_types::AdapterId::new("claude").unwrap(),
19554 "fixture/v1",
19555 gate4agent_types::AdapterVerification::SyntheticFixture,
19556 )
19557 .unwrap(),
19558 };
19559 let provider = ProviderSnapshot {
19560 sequence: 17,
19561 session: Some(ProviderSessionIdentity {
19562 key: ProviderSessionKey::SessionId,
19563 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19564 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19565 }),
19566 model: Some("PRIVATE_MODEL".to_owned()),
19567 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19568 completed_turns: 3,
19569 usage: gate4agent_types::TokenUsage {
19570 input_tokens: 10,
19571 output_tokens: 20,
19572 cache_read_tokens: 30,
19573 cache_write_tokens: 40,
19574 reasoning_tokens: 50,
19575 context_window: Some(200_000),
19576 },
19577 lead_activity: ProviderActivity::WaitingForInput,
19578 activity: ProviderActivity::WaitingForInput,
19579 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19580 active_tools: (0..9)
19581 .map(|index| gate4agent_types::ActiveProviderTool {
19582 id: format!("private-tool-id-{index}"),
19583 name: if index == 0 {
19584 format!(" Read\u{0000}{}", "x".repeat(80))
19585 } else {
19586 format!("Tool-{index}")
19587 },
19588 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19589 })
19590 .collect(),
19591 interactions: vec![gate4agent_types::ProviderInteraction {
19592 id: gate4agent_types::ProviderInteractionId(17),
19593 source: source.clone(),
19594 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19595 interaction_kind: ProviderInteractionKind::Approval,
19596 tool_name: " Approve\u{0007}Tool ".to_owned(),
19597 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19598 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19599 resume_lead_activity: Some(ProviderActivity::Working),
19600 status: ProviderInteractionStatus::Pending,
19601 }],
19602 subagents: vec![gate4agent_types::ProviderSubagent {
19603 source: source.clone(),
19604 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19605 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19606 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19607 }],
19608 sources: vec![gate4agent_types::ProviderSourceCursor {
19609 source,
19610 sequence: 17,
19611 gap_count: 2,
19612 stale: true,
19613 }],
19614 last_event: Some(ProviderEvent::Error {
19615 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19616 }),
19617 gap_count: 2,
19618 stale: true,
19619 };
19620 let address = terminal_address(3);
19621 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19622 assert_eq!(entry.address, address);
19623 assert_eq!(entry.progress.provider_sequence, 17);
19624 assert_eq!(entry.progress.active_tool_count, 9);
19625 assert_eq!(
19626 entry.progress.active_tool_labels.len(),
19627 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19628 );
19629 assert_eq!(entry.progress.subagent_count, 1);
19630 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19631 assert!(entry.progress.truncated);
19632 let encoded = serde_json::to_string(&entry).unwrap();
19633 for forbidden in [
19634 "PRIVATE_PROVIDER_SESSION",
19635 "PRIVATE_PROVIDER_PATH",
19636 "PRIVATE_MODEL",
19637 "PRIVATE_TOOL_INVENTORY",
19638 "PRIVATE_CURRENT_PROMPT",
19639 "PRIVATE_TOOL_INPUT_JSON",
19640 "PRIVATE_PROVIDER_REQUEST",
19641 "PRIVATE_INTERACTION_PROMPT",
19642 "PRIVATE_INTERACTION_AGENT",
19643 "PRIVATE_SUBAGENT_ID",
19644 "PRIVATE_SUBAGENT_TYPE",
19645 "PRIVATE_SUBAGENT_DESCRIPTION",
19646 "PRIVATE_PROVIDER_ERROR",
19647 "input_json",
19648 "current_prompt",
19649 "session_id",
19650 ] {
19651 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19652 }
19653
19654 let snapshot = NodeSnapshot {
19655 node_id: NodeId::new("node-progress-test").unwrap(),
19656 enabled_providers: vec![agent("claude")],
19657 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19658 workspaces: Vec::new(),
19659 session_records: Vec::new(),
19660 managed_worktrees: Vec::new(),
19661 launch_inventory: None,
19662 agent_progress: vec![entry],
19663 };
19664 let mut legacy_reply = ResponseEnvelope {
19665 request_id: 1,
19666 result: Ok(NodeResponse::Snapshot {
19667 event_sequence: 0,
19668 controller: None,
19669 snapshot: snapshot.clone(),
19670 }),
19671 };
19672 project_response_without_agent_progress(&mut legacy_reply);
19673 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19674 panic!("agent progress projection changed response kind");
19675 };
19676 assert!(legacy.agent_progress.is_empty());
19677 assert_eq!(snapshot.agent_progress.len(), 1);
19678 }
19679
19680 fn terminal_address(generation: u64) -> SessionAddress {
19681 SessionAddress {
19682 workspace_id: WorkspaceId::new("primary").unwrap(),
19683 session: SessionKey {
19684 instance_id: AgentInstanceId(41),
19685 generation: SessionGeneration(generation),
19686 },
19687 }
19688 }
19689
19690 fn spawn_spec_fixture() -> SpawnSpec {
19691 SpawnSpec {
19692 target: crate::protocol::SpawnTarget {
19693 node_id: NodeId::new("node-terminal-test").unwrap(),
19694 workspace_id: WorkspaceId::new("primary").unwrap(),
19695 worktree_id: None,
19696 },
19697 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19698 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19699 .unwrap(),
19700 overrides: crate::protocol::SpawnOverrides::default(),
19701 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19702 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19703 required_capabilities: SpawnRequiredCapabilities::default(),
19704 }
19705 }
19706
19707 fn environment_profile_test_shared(
19708 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19709 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19710 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19711 "local-claude-r1",
19712 )
19713 .unwrap();
19714 let spawn_profiles = SpawnProfileRegistry::new([
19715 crate::protocol::SpawnProfileDefaults {
19716 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19717 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19718 provider: agent("claude"),
19719 mode: SessionMode::Pty,
19720 terminal_size: gate4agent_types::TerminalSize {
19721 rows: 24,
19722 columns: 80,
19723 },
19724 prompt: None,
19725 bundle_id: None,
19726 context_id: None,
19727 environment_profile_id: Some(profile_id.clone()),
19728 },
19729 ])
19730 .unwrap();
19731 let catalog = active_registry().unwrap();
19732 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19733 let control = runtime.native_launch_profile_control();
19734 let node_profile = NodeEnvironmentProfile::new(
19735 profile_id.clone(),
19736 profile_revision.clone(),
19737 agent("claude"),
19738 [NativeLaunchProfile::new(
19739 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19740 agent("claude"),
19741 TransportKind::Pty,
19742 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19743 Arc::new(EmptyEnvironmentResolver),
19744 )
19745 .unwrap()],
19746 )
19747 .unwrap();
19748 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19749 for native_profile in native_profiles {
19750 runtime.upsert_native_launch_profile(native_profile).unwrap();
19751 }
19752 let workspace = WorkspaceConfig::new(
19753 WorkspaceId::new("primary").unwrap(),
19754 std::env::current_dir().unwrap(),
19755 )
19756 .unwrap();
19757 let shared = NodeShared::new_with_incarnation(
19758 handle,
19759 "fixture-token".to_owned(),
19760 NodeId::new("node-terminal-test").unwrap(),
19761 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19762 vec![workspace],
19763 vec![agent("claude")],
19764 ProviderRuntimeStatuses::default(),
19765 None,
19766 Vec::new(),
19767 Vec::new(),
19768 spawn_profiles,
19769 Some(control),
19770 None,
19771 None,
19772 None,
19773 Vec::new(),
19774 Vec::new(),
19775 Vec::new(),
19776 Vec::new(),
19777 None,
19778 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19779 );
19780 shared
19781 .environment_profiles
19782 .write()
19783 .unwrap_or_else(|poisoned| poisoned.into_inner())
19784 .insert(binding.id.clone(), binding);
19785 (
19786 shared,
19787 runtime,
19788 ResolvedEnvironmentProfileReceipt {
19789 profile_id,
19790 profile_revision,
19791 network_allowlist: None,
19792 browser_profile_id: None,
19793 },
19794 )
19795 }
19796
19797 fn materialization_test_shared(
19798 deny_secret: bool,
19799 ) -> (
19800 NodeShared,
19801 NativeRuntime,
19802 ResolvedEnvironmentProfileReceipt,
19803 PathBuf,
19804 Arc<AtomicBool>,
19805 ) {
19806 let workspace = WorkspaceConfig::new(
19807 WorkspaceId::new("primary").unwrap(),
19808 std::env::current_dir().unwrap(),
19809 )
19810 .unwrap();
19811 materialization_test_shared_with_workspace(deny_secret, workspace)
19812 }
19813
19814 fn materialization_test_shared_with_workspace(
19815 deny_secret: bool,
19816 workspace: WorkspaceConfig,
19817 ) -> (
19818 NodeShared,
19819 NativeRuntime,
19820 ResolvedEnvironmentProfileReceipt,
19821 PathBuf,
19822 Arc<AtomicBool>,
19823 ) {
19824 let root = temporary_workspace_root(if deny_secret {
19825 "materialization-denied"
19826 } else {
19827 "materialization-ready"
19828 });
19829 std::fs::create_dir_all(&root).unwrap();
19830 let materialization_root = root.join("session-environments");
19831 let deny = Arc::new(AtomicBool::new(deny_secret));
19832 let resolver = Arc::new(FixtureSecretResolver {
19833 deny: Arc::clone(&deny),
19834 });
19835 let materializer = SessionEnvironmentMaterializer::new(
19836 materialization_root,
19837 resolver,
19838 )
19839 .unwrap();
19840 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19841 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19842 "materialized-claude-r1",
19843 )
19844 .unwrap();
19845 let materialization = NodeSessionMaterializationProfile::new(
19846 vec![NodeSessionEnvironmentMutation::SetSecret {
19847 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19848 reference: NodeSecretReference::new("fixture-token").unwrap(),
19849 }],
19850 vec![NodeSessionPathBinding::new(
19851 "GATE4AGENT_PROVIDER_HOME",
19852 NodeSessionPathClass::ProviderHome,
19853 )
19854 .unwrap()],
19855 vec![NodeSessionFile::secret(
19856 NodeSessionPathClass::Config,
19857 "auth/token",
19858 NodeSecretReference::new("fixture-token").unwrap(),
19859 )
19860 .unwrap()],
19861 )
19862 .unwrap();
19863 let catalog = active_registry().unwrap();
19864 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19865 let control = runtime.native_launch_profile_control();
19866 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19867 profile_id.clone(),
19868 profile_revision.clone(),
19869 agent("claude"),
19870 [NativeLaunchProfile::new(
19871 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19872 agent("claude"),
19873 TransportKind::Pty,
19874 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19875 Arc::new(EmptyEnvironmentResolver),
19876 )
19877 .unwrap()],
19878 Some(materialization),
19879 )
19880 .unwrap();
19881 let (binding, native_profiles, materialization) = node_profile.into_parts();
19882 for native_profile in native_profiles {
19883 runtime.upsert_native_launch_profile(native_profile).unwrap();
19884 }
19885 let shared = NodeShared::new_with_incarnation(
19886 handle,
19887 "fixture-token".to_owned(),
19888 NodeId::new("node-materialization-test").unwrap(),
19889 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19890 vec![workspace],
19891 vec![agent("claude")],
19892 ProviderRuntimeStatuses::default(),
19893 None,
19894 Vec::new(),
19895 Vec::new(),
19896 SpawnProfileRegistry::default(),
19897 Some(control),
19898 None,
19899 Some(materializer),
19900 None,
19901 Vec::new(),
19902 Vec::new(),
19903 Vec::new(),
19904 Vec::new(),
19905 None,
19906 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19907 );
19908 shared
19909 .environment_profiles
19910 .write()
19911 .unwrap_or_else(|poisoned| poisoned.into_inner())
19912 .insert(binding.id.clone(), binding);
19913 shared
19914 .environment_materialization_profiles
19915 .write()
19916 .unwrap_or_else(|poisoned| poisoned.into_inner())
19917 .insert(profile_id.clone(), materialization.unwrap());
19918 (
19919 shared,
19920 runtime,
19921 ResolvedEnvironmentProfileReceipt {
19922 profile_id,
19923 profile_revision,
19924 network_allowlist: None,
19925 browser_profile_id: None,
19926 },
19927 root,
19928 deny,
19929 )
19930 }
19931
19932 #[cfg(feature = "fixture")]
19933 #[derive(Clone, Copy)]
19934 enum CodexHomeProfile {
19935 Missing,
19936 WrongClass,
19937 Exact,
19938 }
19939
19940 #[cfg(feature = "fixture")]
19941 fn codex_bundle_test_shared(
19942 home_profile: CodexHomeProfile,
19943 ) -> (
19944 NodeShared,
19945 NativeRuntime,
19946 ResolvedEnvironmentProfileReceipt,
19947 NodeBundle,
19948 PathBuf,
19949 ) {
19950 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19951 const CLAUDE_MANIFEST: &[u8] =
19952 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19953 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19954 const DIGEST: &str =
19955 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19956
19957 let (shared, mut runtime, _claude_receipt, root, _deny) =
19958 materialization_test_shared(false);
19959 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19960 CodexHomeProfile::Missing => "codex-home-missing",
19961 CodexHomeProfile::WrongClass => "codex-home-wrong",
19962 CodexHomeProfile::Exact => "codex-home-exact",
19963 })
19964 .unwrap();
19965 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19966 "codex-home-r1",
19967 )
19968 .unwrap();
19969 let materialization = match home_profile {
19970 CodexHomeProfile::Missing => None,
19971 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19972 Some(NodeSessionMaterializationProfile::new(
19973 Vec::new(),
19974 vec![NodeSessionPathBinding::new(
19975 "CODEX_HOME",
19976 if matches!(home_profile, CodexHomeProfile::Exact) {
19977 NodeSessionPathClass::ProviderHome
19978 } else {
19979 NodeSessionPathClass::Config
19980 },
19981 )
19982 .unwrap()],
19983 Vec::new(),
19984 )
19985 .unwrap())
19986 }
19987 };
19988 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19989 profile_id.clone(),
19990 profile_revision.clone(),
19991 agent("codex"),
19992 [NativeLaunchProfile::new(
19993 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19994 agent("codex"),
19995 TransportKind::Pty,
19996 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19997 Arc::new(EmptyEnvironmentResolver),
19998 )
19999 .unwrap()],
20000 materialization,
20001 )
20002 .unwrap();
20003 let (binding, native_profiles, materialization) = node_profile.into_parts();
20004 for native_profile in native_profiles {
20005 runtime.upsert_native_launch_profile(native_profile).unwrap();
20006 }
20007 shared
20008 .environment_profiles
20009 .write()
20010 .unwrap_or_else(|poisoned| poisoned.into_inner())
20011 .insert(binding.id.clone(), binding);
20012 if let Some(materialization) = materialization {
20013 shared
20014 .environment_materialization_profiles
20015 .write()
20016 .unwrap_or_else(|poisoned| poisoned.into_inner())
20017 .insert(profile_id.clone(), materialization);
20018 }
20019
20020 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20021 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20022 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20023 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20024 std::fs::write(
20025 bundle_source.join(".claude-plugin/plugin.json"),
20026 CLAUDE_MANIFEST,
20027 )
20028 .unwrap();
20029 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20030 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20031 let bundle = NodeBundle::new(
20032 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20033 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20034 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20035 &bundle_source,
20036 )
20037 .unwrap();
20038 *shared
20039 .bundle_catalog
20040 .write()
20041 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20042 BundleCatalog::new([bundle.clone()]).unwrap();
20043 (
20044 shared,
20045 runtime,
20046 ResolvedEnvironmentProfileReceipt {
20047 profile_id,
20048 profile_revision,
20049 network_allowlist: None,
20050 browser_profile_id: None,
20051 },
20052 bundle,
20053 root,
20054 )
20055 }
20056
20057 #[cfg(feature = "fixture")]
20058 #[test]
20059 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20060 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20061 let (shared, runtime, receipt, bundle, root) =
20062 codex_bundle_test_shared(home_profile);
20063 let error = shared
20064 .bundle_layout(
20065 &agent("codex"),
20066 SessionMode::Pty,
20067 Some(&receipt),
20068 &bundle,
20069 )
20070 .unwrap_err();
20071 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20072 assert!(shared
20073 .materializations
20074 .lock()
20075 .unwrap_or_else(|poisoned| poisoned.into_inner())
20076 .is_empty());
20077 let materialization_root = root.join("session-environments");
20078 assert!(!std::fs::read_dir(&materialization_root)
20079 .unwrap()
20080 .filter_map(Result::ok)
20081 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20082 drop(shared);
20083 drop(runtime);
20084 let _ = std::fs::remove_dir_all(root);
20085 }
20086 }
20087
20088 #[cfg(feature = "fixture")]
20089 #[test]
20090 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20091 let (shared, runtime, receipt, bundle, root) =
20092 codex_bundle_test_shared(CodexHomeProfile::Exact);
20093 let address = SessionAddress {
20094 workspace_id: WorkspaceId::new("primary").unwrap(),
20095 session: SessionKey {
20096 instance_id: AgentInstanceId(310),
20097 generation: SessionGeneration(1),
20098 },
20099 };
20100 let (overlay, guard) = shared
20101 .prepare_session_materialization(
20102 &address,
20103 &agent("codex"),
20104 SessionMode::Pty,
20105 Some(&receipt),
20106 Some(&bundle.receipt()),
20107 None,
20108 None,
20109 )
20110 .unwrap()
20111 .unwrap();
20112 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20113 let ownership = shared
20114 .materializations
20115 .lock()
20116 .unwrap_or_else(|poisoned| poisoned.into_inner())
20117 .get(guard.id().unwrap())
20118 .unwrap()
20119 .clone();
20120 let (profile, _, _) = shared
20121 .materialization_profile(
20122 &agent("codex"),
20123 SessionMode::Pty,
20124 Some(&receipt),
20125 Some(&bundle.receipt()),
20126 None,
20127 )
20128 .unwrap()
20129 .unwrap();
20130 let environment = shared
20131 .session_environment_materializer
20132 .as_ref()
20133 .unwrap()
20134 .resolve_environment(&ownership, &profile)
20135 .unwrap();
20136 assert!(environment.iter().any(|mutation| {
20137 mutation.key == OsString::from("CODEX_HOME")
20138 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20139 }));
20140 assert_eq!(
20141 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20142 .unwrap(),
20143 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20144 );
20145 assert!(std::fs::read_dir(ownership.bundle_root())
20146 .unwrap()
20147 .next()
20148 .is_none());
20149 drop(guard);
20150 drop(shared);
20151 drop(runtime);
20152 let _ = std::fs::remove_dir_all(root);
20153 }
20154
20155 #[cfg(feature = "fixture")]
20156 #[test]
20157 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20158 let (shared, runtime, receipt, bundle, root) =
20159 codex_bundle_test_shared(CodexHomeProfile::Exact);
20160 let bundle_receipt = bundle.receipt();
20161 let address = SessionAddress {
20162 workspace_id: WorkspaceId::new("primary").unwrap(),
20163 session: SessionKey {
20164 instance_id: AgentInstanceId(311),
20165 generation: SessionGeneration(1),
20166 },
20167 };
20168 let (_overlay, guard) = shared
20169 .prepare_session_materialization(
20170 &address,
20171 &agent("codex"),
20172 SessionMode::Pty,
20173 Some(&receipt),
20174 Some(&bundle_receipt),
20175 None,
20176 None,
20177 )
20178 .unwrap()
20179 .unwrap();
20180 let materialization_id = guard.id().unwrap().clone();
20181 let skill_path = shared
20182 .materializations
20183 .lock()
20184 .unwrap_or_else(|poisoned| poisoned.into_inner())
20185 .get(&materialization_id)
20186 .unwrap()
20187 .provider_home()
20188 .join("skills/review-code/SKILL.md");
20189 let record_id = shared
20190 .bind_spawn_session_with_materialization(
20191 &address,
20192 agent("codex"),
20193 SessionMode::Pty,
20194 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20195 SpawnRecordPolicy::ProviderIdentityOnly,
20196 Some(receipt.clone()),
20197 Some(bundle_receipt.clone()),
20198 None,
20199 Some(materialization_id.clone()),
20200 )
20201 .unwrap()
20202 .unwrap();
20203 guard.retain();
20204 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20205
20206 let error = match shared.resolve_record_materialization(
20207 &record_id,
20208 &agent("codex"),
20209 SessionMode::Pty,
20210 Some(&receipt),
20211 Some(&bundle_receipt),
20212 None,
20213 ) {
20214 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20215 Err(error) => error,
20216 };
20217 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20218 assert_eq!(
20219 shared
20220 .materializations
20221 .lock()
20222 .unwrap_or_else(|poisoned| poisoned.into_inner())
20223 .get(&materialization_id)
20224 .unwrap()
20225 .state(),
20226 MaterializationState::RecoveryRequired,
20227 );
20228 drop(shared);
20229 drop(runtime);
20230 let _ = std::fs::remove_dir_all(root);
20231 }
20232
20233 fn bind_record_owned_materialization_fixture(
20234 shared: &NodeShared,
20235 receipt: &ResolvedEnvironmentProfileReceipt,
20236 instance_id: u64,
20237 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20238 let address = SessionAddress {
20239 workspace_id: WorkspaceId::new("primary").unwrap(),
20240 session: SessionKey {
20241 instance_id: AgentInstanceId(instance_id),
20242 generation: SessionGeneration(1),
20243 },
20244 };
20245 let (overlay, materialization_guard) = shared
20246 .prepare_session_materialization(
20247 &address,
20248 &agent("claude"),
20249 SessionMode::Pty,
20250 Some(receipt),
20251 None,
20252 None,
20253 None,
20254 )
20255 .unwrap()
20256 .unwrap();
20257 let materialization_id = materialization_guard.id().unwrap().clone();
20258 let materialization_root = shared
20259 .materializations
20260 .lock()
20261 .unwrap_or_else(|poisoned| poisoned.into_inner())
20262 .get(&materialization_id)
20263 .unwrap()
20264 .root()
20265 .to_path_buf();
20266 let selection = shared
20267 .select_environment_profile(
20268 address.session.instance_id,
20269 &agent("claude"),
20270 SessionMode::Pty,
20271 Some(receipt),
20272 )
20273 .unwrap()
20274 .unwrap();
20275 assert!(shared
20276 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20277 .unwrap()
20278 .is_none());
20279 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20280 let record_id = shared
20281 .bind_spawn_session_with_materialization(
20282 &address,
20283 agent("claude"),
20284 SessionMode::Pty,
20285 policy,
20286 SpawnRecordPolicy::ProviderIdentityOnly,
20287 Some(receipt.clone()),
20288 None,
20289 None,
20290 Some(materialization_id.clone()),
20291 )
20292 .unwrap()
20293 .unwrap();
20294 selection.retain();
20295 materialization_guard.retain();
20296 (address, record_id, materialization_id, materialization_root)
20297 }
20298
20299 #[test]
20300 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20301 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20302 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20303 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20304 }));
20305 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20306 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20307 let mut spec = spawn_spec_fixture();
20308 spec.expected_profile_revision =
20309 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20310 assert!(matches!(
20311 &spec.overrides.environment_profile_id,
20312 crate::protocol::SpawnOverride::Inherit,
20313 ));
20314 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20315 .requires_child_environment_profile_capability());
20316 assert!(request_requires_child_environment_profile(
20317 &shared,
20318 &NodeRequest::SpawnSpec { spec: spec.clone() },
20319 ));
20320
20321 let address = terminal_address(1);
20322 shared.bind_session_with_policy(
20323 &address,
20324 ProviderRuntimePolicy::raw_pty(),
20325 Some(environment_profile.clone()),
20326 );
20327 assert!(request_requires_child_environment_profile(
20328 &shared,
20329 &NodeRequest::Input {
20330 session: address.clone(),
20331 text: "x".to_owned(),
20332 },
20333 ));
20334
20335 let mut record = record("claude", "profile-record");
20336 record.active_session = Some(address.clone());
20337 record.environment_profile = Some(environment_profile.clone());
20338 let record_id = record.record_id.clone();
20339 shared.insert_record(record.clone()).unwrap();
20340 assert!(request_requires_child_environment_profile(
20341 &shared,
20342 &NodeRequest::ResumeSessionRecord {
20343 record_id,
20344 terminal_size: gate4agent_types::TerminalSize {
20345 rows: 24,
20346 columns: 80,
20347 },
20348 initial_prompt: None,
20349 },
20350 ));
20351
20352 let snapshot = snapshot_for_wire(
20353 &shared, false, true, true, true, false, false, false, false, true,
20354 );
20355 assert_eq!(snapshot.session_records.len(), 1);
20356 assert!(snapshot.session_records[0].environment_profile.is_none());
20357 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20358 sequence: 1,
20359 event: NodeEvent::SessionRecordUpserted { record },
20360 });
20361 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20362 panic!("record projection changed the event kind");
20363 };
20364 assert!(record.environment_profile.is_none());
20365
20366 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20367 let mut reply = ResponseEnvelope {
20368 request_id: 1,
20369 result: Ok(NodeResponse::SpawnSpecAccepted {
20370 receipt: resolved.receipt_with_environment(
20371 shared.incarnation_id,
20372 address,
20373 Some(environment_profile),
20374 ),
20375 }),
20376 };
20377 project_response_without_child_environment_profile(&mut reply);
20378 assert_eq!(
20379 reply.result.unwrap_err().code,
20380 NodeFailureCode::UnsupportedCapability,
20381 );
20382 }
20383
20384 #[test]
20385 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20386 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20387 let snapshot = shared.snapshot();
20388 let profiles = snapshot
20389 .launch_inventory
20390 .as_ref()
20391 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20392 .unwrap();
20393 assert_eq!(profiles.len(), 1);
20394 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20395 assert!(snapshot.requires_child_environment_profile_capability());
20396
20397 let mut legacy = snapshot.clone();
20398 clear_snapshot_child_environment_profiles(&mut legacy);
20399 assert!(legacy
20400 .launch_inventory
20401 .as_ref()
20402 .unwrap()
20403 .spawn_profiles
20404 .as_ref()
20405 .unwrap()[0]
20406 .environment_profile
20407 .is_none());
20408 assert!(!legacy.requires_child_environment_profile_capability());
20409
20410 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20411 let bindings = shared
20412 .environment_profiles
20413 .read()
20414 .unwrap_or_else(|poisoned| poisoned.into_inner())
20415 .clone();
20416 let mut provider_mismatch = bindings.clone();
20417 provider_mismatch
20418 .values_mut()
20419 .next()
20420 .unwrap()
20421 .provider = agent("codex");
20422 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20423 let mut mode_mismatch = profile;
20424 mode_mismatch.mode = SessionMode::Inline;
20425 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20426
20427 shared
20428 .environment_profiles
20429 .write()
20430 .unwrap_or_else(|poisoned| poisoned.into_inner())
20431 .clear();
20432 assert!(shared
20433 .snapshot()
20434 .launch_inventory
20435 .unwrap()
20436 .spawn_profiles
20437 .unwrap()
20438 .is_empty());
20439 }
20440
20441 #[test]
20442 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20443 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20444 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20445 }));
20446 let shared = terminal_test_shared();
20447 let bundle = ResolvedBundleReceipt {
20448 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20449 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20450 digest: crate::protocol::SpawnBundleDigest::new(format!(
20451 "sha256:{}",
20452 "0".repeat(64),
20453 ))
20454 .unwrap(),
20455 };
20456 let mut bundled = record("claude", "bundle-record");
20457 bundled.bundle = Some(bundle.clone());
20458 let record_id = bundled.record_id.clone();
20459 shared.insert_record(bundled.clone()).unwrap();
20460 assert!(request_requires_session_bundle(
20461 &shared,
20462 &NodeRequest::ForgetSessionRecord {
20463 record_id: record_id.clone(),
20464 },
20465 ));
20466
20467 let snapshot = snapshot_for_wire(
20468 &shared, false, true, true, true, true, false, false, false, true,
20469 );
20470 assert!(snapshot.session_records[0].bundle.is_none());
20471 let launch = snapshot.launch_inventory.as_ref().unwrap();
20472 assert!(launch.spawn_profiles.is_some());
20473 assert!(launch.bundles.is_none());
20474 let event = project_event_without_session_bundle(NodeEventEnvelope {
20475 sequence: 1,
20476 event: NodeEvent::SessionRecordUpserted { record: bundled },
20477 });
20478 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20479 panic!("record projection changed the event kind");
20480 };
20481 assert!(record.bundle.is_none());
20482
20483 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20484 let mut reply = ResponseEnvelope {
20485 request_id: 1,
20486 result: Ok(NodeResponse::SpawnSpecAccepted {
20487 receipt: resolved.receipt_with_materialization(
20488 shared.incarnation_id,
20489 terminal_address(17),
20490 None,
20491 Some(bundle),
20492 None,
20493 ),
20494 }),
20495 };
20496 project_response_without_session_bundle(&mut reply);
20497 assert_eq!(
20498 reply.result.unwrap_err().code,
20499 NodeFailureCode::UnsupportedCapability,
20500 );
20501 }
20502
20503 #[test]
20504 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20505 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20506 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20507 }));
20508 let context_id = SpawnContextId::new("context-review").unwrap();
20509 let context = ResolvedContextPackReceipt {
20510 id: context_id.clone(),
20511 digest: crate::protocol::SpawnContextDigest::new(format!(
20512 "sha256:{}",
20513 "c".repeat(64),
20514 ))
20515 .unwrap(),
20516 lineage: ContextPackLineageReceipt {
20517 source_node_id: NodeId::new("node-source").unwrap(),
20518 source_session: terminal_address(1),
20519 source_provider: agent("kimi"),
20520 },
20521 source_message_count: 2,
20522 retained_message_count: 2,
20523 byte_len: 256,
20524 truncated: false,
20525 };
20526 assert!(context.is_valid());
20527 let mut bound_record = record("codex", "context-record");
20528 bound_record.context_id = Some(context_id);
20529 bound_record.context = Some(context.clone());
20530 let session = gate4agent_types::SessionSnapshot {
20531 instance_id: AgentInstanceId(91),
20532 agent_id: agent("kimi"),
20533 transport: TransportKind::Pty,
20534 generation: SessionGeneration(3),
20535 status: SessionStatus::Running,
20536 pending_operation: None,
20537 pending_input: None,
20538 process_id: Some(9001),
20539 terminal_size: None,
20540 terminal_frame: None,
20541 terminal_stale: None,
20542 session_options: None,
20543 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20544 history: gate4agent_types::HistorySnapshot {
20545 pending: None,
20546 candidates: vec![HistoryCandidateSummary {
20547 id: "candidate-opaque".to_owned(),
20548 session_id_hint: "session-hint".to_owned(),
20549 modified_at_unix_ms: Some(1),
20550 }],
20551 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20552 loaded: Some(HistorySessionRecord {
20553 session_id: "private-vendor-session".to_owned(),
20554 title: Some("private title".to_owned()),
20555 cwd: Some(r"C:\private\history-root".to_owned()),
20556 model: Some("private-model".to_owned()),
20557 message_count: 2,
20558 completed_turn_count: None,
20559 total_tokens: 19,
20560 messages: vec![
20561 gate4agent_types::HistoryMessageRecord {
20562 role: gate4agent_types::HistoryMessageRole::User,
20563 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20564 },
20565 gate4agent_types::HistoryMessageRecord {
20566 role: gate4agent_types::HistoryMessageRole::Assistant,
20567 text: "private answer".to_owned(),
20568 },
20569 ],
20570 }),
20571 last_error: None,
20572 },
20573 resume: gate4agent_types::ResumeSnapshot::default(),
20574 foreground: gate4agent_types::ForegroundSnapshot::default(),
20575 provider: gate4agent_types::ProviderSnapshot::default(),
20576 screen_state: gate4agent_types::PtyScreenState::default(),
20577 };
20578 let original = NodeSnapshot {
20579 node_id: NodeId::new("node-terminal-test").unwrap(),
20580 enabled_providers: vec![agent("kimi"), agent("codex")],
20581 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20582 workspaces: vec![WorkspaceSnapshot {
20583 workspace_id: WorkspaceId::new("primary").unwrap(),
20584 canonical_root: opaque_windows_path(
20585 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20586 ),
20587 sessions: vec![session],
20588 worktree_service_mode: None,
20589 managed_worktree_profiles: None,
20590 }],
20591 session_records: vec![bound_record],
20592 managed_worktrees: Vec::new(),
20593 launch_inventory: None,
20594 agent_progress: Vec::new(),
20595 };
20596
20597 let mut negotiated = original.clone();
20598 project_snapshot_history_for_wire(&mut negotiated, true);
20599 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20600 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20601 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20602 assert!(!serde_json::to_string(&negotiated)
20603 .unwrap()
20604 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20605
20606 let mut legacy = original;
20607 project_snapshot_history_for_wire(&mut legacy, false);
20608 clear_snapshot_context_packs(&mut legacy);
20609 assert_eq!(
20610 legacy.workspaces[0].sessions[0].history,
20611 gate4agent_types::HistorySnapshot::default(),
20612 );
20613 assert!(legacy.session_records[0].context_id.is_none());
20614 assert!(legacy.session_records[0].context.is_none());
20615 let encoded = serde_json::to_string(&legacy).unwrap();
20616 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20617 assert!(!encoded.contains("context-review"));
20618
20619 let mut reply = ResponseEnvelope {
20620 request_id: 7,
20621 result: Ok(NodeResponse::ContextPackExported { context }),
20622 };
20623 project_response_without_context_pack(&mut reply);
20624 assert_eq!(
20625 reply.result.unwrap_err().code,
20626 NodeFailureCode::UnsupportedCapability,
20627 );
20628 }
20629
20630 #[test]
20631 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20632 let shared = terminal_test_shared();
20633 let history = HistorySessionRecord {
20634 session_id: "fixture-history".to_owned(),
20635 title: None,
20636 cwd: None,
20637 model: None,
20638 message_count: 1,
20639 completed_turn_count: None,
20640 total_tokens: 0,
20641 messages: vec![gate4agent_types::HistoryMessageRecord {
20642 role: gate4agent_types::HistoryMessageRole::User,
20643 text: "bounded handoff".to_owned(),
20644 }],
20645 };
20646 let pack = NodeContextPack::export(
20647 ContextPackLineageReceipt {
20648 source_node_id: shared.node_id.clone(),
20649 source_session: terminal_address(1),
20650 source_provider: agent("claude"),
20651 },
20652 &history,
20653 )
20654 .unwrap();
20655 let context = shared
20656 .context_catalog
20657 .write()
20658 .unwrap_or_else(|poisoned| poisoned.into_inner())
20659 .insert(pack)
20660 .unwrap();
20661 let mut retained = record("codex", "context-retained-record");
20662 retained.context_id = Some(context.id.clone());
20663 retained.context = Some(context.clone());
20664 let record_id = retained.record_id.clone();
20665 shared.insert_record(retained).unwrap();
20666 assert_eq!(
20667 shared.forget_context_pack(&context.id).unwrap_err().code,
20668 NodeFailureCode::ContextPackBusy,
20669 );
20670 assert!(shared
20671 .context_catalog
20672 .read()
20673 .unwrap_or_else(|poisoned| poisoned.into_inner())
20674 .get(&context.id)
20675 .is_some());
20676 shared
20677 .session_records
20678 .lock()
20679 .unwrap_or_else(|poisoned| poisoned.into_inner())
20680 .records
20681 .remove(&record_id);
20682
20683 let mut spec = spawn_spec_fixture();
20684 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20685 value: context.id.clone(),
20686 };
20687 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20688 let receipt = resolved.receipt_with_materialization(
20689 shared.incarnation_id,
20690 terminal_address(2),
20691 None,
20692 None,
20693 Some(context.clone()),
20694 );
20695 assert!(receipt.context_binding_is_valid());
20696 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20697
20698 shared.forget_context_pack(&context.id).unwrap();
20699 assert!(shared
20700 .context_catalog
20701 .read()
20702 .unwrap_or_else(|poisoned| poisoned.into_inner())
20703 .get(&context.id)
20704 .is_none());
20705 assert!(shared
20706 .replay_spawn_spec(&spec, Instant::now())
20707 .unwrap()
20708 .is_none());
20709 }
20710
20711 #[test]
20712 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20713 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20714 let address = terminal_address(1);
20715 let selection = shared
20716 .select_environment_profile(
20717 address.session.instance_id,
20718 &agent("claude"),
20719 SessionMode::Pty,
20720 Some(&environment_profile),
20721 )
20722 .unwrap()
20723 .unwrap();
20724 shared.bind_session_with_policy(
20725 &address,
20726 ProviderRuntimePolicy::raw_pty(),
20727 Some(environment_profile.clone()),
20728 );
20729 selection.retain();
20730 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20731 assert!(matches!(
20732 runtime.remove_native_launch_profile(&native_id),
20733 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20734 ));
20735 assert!(shared.remove_binding(&address).is_some());
20736 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20737
20738 let mismatched = ResolvedEnvironmentProfileReceipt {
20739 profile_id: environment_profile.profile_id,
20740 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20741 "local-claude-r2",
20742 )
20743 .unwrap(),
20744 network_allowlist: None,
20745 browser_profile_id: None,
20746 };
20747 let error = match shared.select_environment_profile(
20748 AgentInstanceId(42),
20749 &agent("claude"),
20750 SessionMode::Pty,
20751 Some(&mismatched),
20752 ) {
20753 Ok(_) => panic!("mismatched environment profile revision was selected"),
20754 Err(error) => error,
20755 };
20756 assert_eq!(
20757 error.code,
20758 NodeFailureCode::EnvironmentProfileBindingMismatch,
20759 );
20760 assert!(!shared
20761 .session_bindings
20762 .lock()
20763 .unwrap_or_else(|poisoned| poisoned.into_inner())
20764 .contains_key(&AgentInstanceId(42)));
20765 }
20766
20767 #[test]
20768 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20769 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20770 let address = terminal_address(1);
20771 let selection = shared
20772 .select_environment_profile(
20773 address.session.instance_id,
20774 &agent("claude"),
20775 SessionMode::Pty,
20776 Some(&environment_profile),
20777 )
20778 .unwrap()
20779 .unwrap();
20780 let overlay = NativeInstanceLaunchOverlay::new(
20781 agent("claude"),
20782 TransportKind::Pty,
20783 vec![EnvMutation {
20784 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20785 value: Some(OsString::from("fixture-secret")),
20786 }],
20787 vec![
20788 OsString::from("--plugin-dir"),
20789 std::env::current_dir().unwrap().into_os_string(),
20790 ],
20791 )
20792 .unwrap();
20793 let instance_overlay = shared
20794 .install_prepared_launch_overlay(
20795 address.session.instance_id,
20796 PreparedNativeLaunchOverlay::Instance(overlay),
20797 )
20798 .unwrap()
20799 .unwrap();
20800 let bundle = ResolvedBundleReceipt {
20801 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20802 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20803 digest: crate::protocol::SpawnBundleDigest::new(format!(
20804 "sha256:{}",
20805 "0".repeat(64),
20806 ))
20807 .unwrap(),
20808 };
20809 shared.bind_session_with_materialization(
20810 &address,
20811 ProviderRuntimePolicy::raw_pty(),
20812 Some(environment_profile),
20813 Some(bundle),
20814 None,
20815 None,
20816 );
20817 selection.retain();
20818 instance_overlay.retain();
20819
20820 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20821 assert!(matches!(
20822 runtime.remove_native_launch_profile(&native_id),
20823 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20824 ));
20825 assert!(shared.remove_binding(&address).is_some());
20826 assert!(!shared
20827 .native_launch_profile_control
20828 .as_ref()
20829 .unwrap()
20830 .clear_native_instance_launch_overlay(address.session.instance_id));
20831 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20832 }
20833
20834 #[test]
20835 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20836 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20837 let address = SessionAddress {
20838 workspace_id: WorkspaceId::new("primary").unwrap(),
20839 session: SessionKey {
20840 instance_id: AgentInstanceId(201),
20841 generation: SessionGeneration(1),
20842 },
20843 };
20844 let error = match shared.prepare_session_materialization(
20845 &address,
20846 &agent("claude"),
20847 SessionMode::Pty,
20848 Some(&receipt),
20849 None,
20850 None,
20851 None,
20852 ) {
20853 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20854 Err(error) => error,
20855 };
20856 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20857 assert!(shared.handle.snapshot().sessions.is_empty());
20858 assert!(shared
20859 .session_bindings
20860 .lock()
20861 .unwrap_or_else(|poisoned| poisoned.into_inner())
20862 .is_empty());
20863 assert!(shared
20864 .materializations
20865 .lock()
20866 .unwrap_or_else(|poisoned| poisoned.into_inner())
20867 .is_empty());
20868 drop(shared);
20869 drop(runtime);
20870 std::fs::remove_dir_all(root).unwrap();
20871 }
20872
20873 #[test]
20874 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20875 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20876 let address = SessionAddress {
20877 workspace_id: WorkspaceId::new("primary").unwrap(),
20878 session: SessionKey {
20879 instance_id: AgentInstanceId(202),
20880 generation: SessionGeneration(1),
20881 },
20882 };
20883 let (overlay, materialization_guard) = shared
20884 .prepare_session_materialization(
20885 &address,
20886 &agent("claude"),
20887 SessionMode::Pty,
20888 Some(&receipt),
20889 None,
20890 None,
20891 None,
20892 )
20893 .unwrap()
20894 .unwrap();
20895 let materialization_id = materialization_guard.id().unwrap().clone();
20896 let materialization_root = shared
20897 .materializations
20898 .lock()
20899 .unwrap_or_else(|poisoned| poisoned.into_inner())
20900 .get(&materialization_id)
20901 .unwrap()
20902 .root()
20903 .to_path_buf();
20904 let selection = shared
20905 .select_environment_profile(
20906 address.session.instance_id,
20907 &agent("claude"),
20908 SessionMode::Pty,
20909 Some(&receipt),
20910 )
20911 .unwrap()
20912 .unwrap();
20913 assert!(shared
20914 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20915 .unwrap()
20916 .is_none());
20917 shared.bind_session_with_materialization(
20918 &address,
20919 ProviderRuntimePolicy::raw_pty(),
20920 Some(receipt),
20921 None,
20922 None,
20923 Some(materialization_id),
20924 );
20925 selection.retain();
20926 materialization_guard.retain();
20927
20928 assert!(materialization_root.is_dir());
20929 assert!(shared
20930 .session_bindings
20931 .lock()
20932 .unwrap_or_else(|poisoned| poisoned.into_inner())
20933 .contains_key(&address.session.instance_id));
20934
20935 let removed = shared.remove_binding(&address).unwrap();
20936 shared
20937 .cleanup_session_owned_materialization(&address, &removed)
20938 .unwrap();
20939 assert!(!materialization_root.exists());
20940 assert!(shared
20941 .materializations
20942 .lock()
20943 .unwrap_or_else(|poisoned| poisoned.into_inner())
20944 .is_empty());
20945 drop(shared);
20946 drop(runtime);
20947 std::fs::remove_dir_all(root).unwrap();
20948 }
20949
20950 #[tokio::test]
20951 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20952 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20953 let address = SessionAddress {
20954 workspace_id: WorkspaceId::new("primary").unwrap(),
20955 session: SessionKey {
20956 instance_id: AgentInstanceId(203),
20957 generation: SessionGeneration(1),
20958 },
20959 };
20960 let (overlay, materialization_guard) = shared
20961 .prepare_session_materialization(
20962 &address,
20963 &agent("claude"),
20964 SessionMode::Pty,
20965 Some(&receipt),
20966 None,
20967 None,
20968 None,
20969 )
20970 .unwrap()
20971 .unwrap();
20972 let materialization_id = materialization_guard.id().unwrap().clone();
20973 let materialization_root = shared
20974 .materializations
20975 .lock()
20976 .unwrap_or_else(|poisoned| poisoned.into_inner())
20977 .get(&materialization_id)
20978 .unwrap()
20979 .root()
20980 .to_path_buf();
20981 let selection = shared
20982 .select_environment_profile(
20983 address.session.instance_id,
20984 &agent("claude"),
20985 SessionMode::Pty,
20986 Some(&receipt),
20987 )
20988 .unwrap()
20989 .unwrap();
20990 assert!(shared
20991 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20992 .unwrap()
20993 .is_none());
20994 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20995 let record_id = shared
20996 .bind_spawn_session_with_materialization(
20997 &address,
20998 agent("claude"),
20999 SessionMode::Pty,
21000 policy,
21001 SpawnRecordPolicy::ProviderIdentityOnly,
21002 Some(receipt.clone()),
21003 None,
21004 None,
21005 Some(materialization_id.clone()),
21006 )
21007 .unwrap()
21008 .unwrap();
21009 selection.retain();
21010 materialization_guard.retain();
21011 assert_eq!(
21012 shared
21013 .materializations
21014 .lock()
21015 .unwrap_or_else(|poisoned| poisoned.into_inner())
21016 .get(&materialization_id)
21017 .unwrap()
21018 .owner(),
21019 &MaterializationOwner::Record {
21020 record_id: record_id.clone(),
21021 },
21022 );
21023
21024 let removed = shared.remove_binding(&address).unwrap();
21025 shared
21026 .cleanup_session_owned_materialization(&address, &removed)
21027 .unwrap();
21028 assert!(materialization_root.is_dir());
21029 assert!(shared
21030 .resolve_record_materialization(
21031 &record_id,
21032 &agent("claude"),
21033 SessionMode::Pty,
21034 Some(&receipt),
21035 None,
21036 None,
21037 )
21038 .unwrap()
21039 .is_some());
21040 {
21041 let mut records = shared
21042 .session_records
21043 .lock()
21044 .unwrap_or_else(|poisoned| poisoned.into_inner());
21045 let record = records.records.get_mut(&record_id).unwrap();
21046 record.active_session = None;
21047 record.state = ManagedSessionState::Unavailable;
21048 }
21049 shared.forget_session_record(&record_id).await.unwrap();
21050 assert!(!materialization_root.exists());
21051 assert!(shared
21052 .materializations
21053 .lock()
21054 .unwrap_or_else(|poisoned| poisoned.into_inner())
21055 .is_empty());
21056 drop(shared);
21057 drop(runtime);
21058 std::fs::remove_dir_all(root).unwrap();
21059 }
21060
21061 #[test]
21062 fn record_materialization_revalidation_failure_persists_recovery_required() {
21063 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21064 let state_path = root.join("node-state-v6.json");
21065 shared.state_path = Some(state_path.clone());
21066 let (_address, _record_id, materialization_id, materialization_root) =
21067 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21068 std::fs::write(
21069 materialization_root.join(".gate4agent-materialization-owner"),
21070 b"tampered",
21071 )
21072 .unwrap();
21073
21074 let error = match shared.resolve_record_materialization(
21075 &_record_id,
21076 &agent("claude"),
21077 SessionMode::Pty,
21078 Some(&receipt),
21079 None,
21080 None,
21081 ) {
21082 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21083 Err(error) => error,
21084 };
21085 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21086 assert_eq!(
21087 shared
21088 .materializations
21089 .lock()
21090 .unwrap_or_else(|poisoned| poisoned.into_inner())
21091 .get(&materialization_id)
21092 .unwrap()
21093 .state(),
21094 MaterializationState::RecoveryRequired,
21095 );
21096 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21097 assert_eq!(loaded.materializations.len(), 1);
21098 assert_eq!(
21099 loaded.materializations[0].state(),
21100 MaterializationState::RecoveryRequired,
21101 );
21102 drop(shared);
21103 drop(runtime);
21104 std::fs::remove_dir_all(root).unwrap();
21105 }
21106
21107 #[test]
21108 fn transient_record_materialization_resolution_failure_remains_ready() {
21109 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21110 let (_address, record_id, materialization_id, _materialization_root) =
21111 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21112 deny.store(true, Ordering::Release);
21113
21114 let error = match shared.resolve_record_materialization(
21115 &record_id,
21116 &agent("claude"),
21117 SessionMode::Pty,
21118 Some(&receipt),
21119 None,
21120 None,
21121 ) {
21122 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21123 Err(error) => error,
21124 };
21125 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21126 assert_eq!(
21127 shared
21128 .materializations
21129 .lock()
21130 .unwrap_or_else(|poisoned| poisoned.into_inner())
21131 .get(&materialization_id)
21132 .unwrap()
21133 .state(),
21134 MaterializationState::Ready,
21135 );
21136 drop(shared);
21137 drop(runtime);
21138 std::fs::remove_dir_all(root).unwrap();
21139 }
21140
21141 #[test]
21142 fn startup_missing_record_materialization_is_not_resumable() {
21143 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21144 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21145 shared
21146 .insert_record(ManagedSessionRecord {
21147 record_id: record_id.clone(),
21148 display_name: "missing materialization".to_owned(),
21149 provider: agent("claude"),
21150 mode: SessionMode::Pty,
21151 state: ManagedSessionState::Dormant,
21152 workspace_id: WorkspaceId::new("primary").unwrap(),
21153 canonical_root: opaque_windows_path(
21154 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21155 ),
21156 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21157 key: gate4agent_types::ProviderSessionKey::SessionId,
21158 id: "provider-session-missing-materialization".to_owned(),
21159 transcript_path: None,
21160 }),
21161 active_session: None,
21162 environment_profile: Some(receipt),
21163 bundle: None,
21164 context_id: None,
21165 context: None,
21166 exported_context: None,
21167 task_binding: None,
21168 created_at_unix_ms: 1,
21169 updated_at_unix_ms: 1,
21170 last_error: None,
21171 })
21172 .unwrap();
21173
21174 shared.reconcile_materializations();
21175 let record = shared.record(&record_id).unwrap();
21176 assert_eq!(record.state, ManagedSessionState::Unavailable);
21177 assert!(record.provider_session.is_none());
21178 assert_eq!(
21179 record.last_error.as_deref(),
21180 Some("environment-profile-unavailable"),
21181 );
21182 drop(shared);
21183 drop(runtime);
21184 std::fs::remove_dir_all(root).unwrap();
21185 }
21186
21187 #[tokio::test]
21188 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21189 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21190 let lease = install_managed_lease(&shared);
21191 let address = SessionAddress {
21192 workspace_id: WorkspaceId::new("primary").unwrap(),
21193 session: SessionKey {
21194 instance_id: AgentInstanceId(204),
21195 generation: SessionGeneration(1),
21196 },
21197 };
21198 let (overlay, materialization_guard) = shared
21199 .prepare_session_materialization(
21200 &address,
21201 &agent("claude"),
21202 SessionMode::Pty,
21203 Some(&receipt),
21204 None,
21205 None,
21206 Some(lease.lease_id.clone()),
21207 )
21208 .unwrap()
21209 .unwrap();
21210 let materialization_id = materialization_guard.id().unwrap().clone();
21211 let materialization_root = shared
21212 .materializations
21213 .lock()
21214 .unwrap_or_else(|poisoned| poisoned.into_inner())
21215 .get(&materialization_id)
21216 .unwrap()
21217 .root()
21218 .to_path_buf();
21219 let selection = shared
21220 .select_environment_profile(
21221 address.session.instance_id,
21222 &agent("claude"),
21223 SessionMode::Pty,
21224 Some(&receipt),
21225 )
21226 .unwrap()
21227 .unwrap();
21228 assert!(shared
21229 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21230 .unwrap()
21231 .is_none());
21232 shared.bind_session_with_materialization(
21233 &address,
21234 ProviderRuntimePolicy::raw_pty(),
21235 Some(receipt),
21236 None,
21237 None,
21238 Some(materialization_id),
21239 );
21240 selection.retain();
21241 materialization_guard.retain();
21242
21243 let blocked = shared
21244 .cleanup_managed_worktree(&lease.lease_id, false)
21245 .await
21246 .unwrap_err();
21247 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21248 assert!(materialization_root.is_dir());
21249 let removed = shared.remove_binding(&address).unwrap();
21250 shared
21251 .cleanup_session_owned_materialization(&address, &removed)
21252 .unwrap();
21253 assert!(!materialization_root.exists());
21254 drop(shared);
21255 drop(runtime);
21256 std::fs::remove_dir_all(root).unwrap();
21257 }
21258
21259 #[tokio::test]
21260 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21261 let git_root = temporary_workspace_root("raw-managed-inventory");
21262 let source = git_root.join("source");
21263 let allocation = git_root.join("allocation");
21264 std::fs::create_dir_all(&source).unwrap();
21265 std::fs::create_dir_all(&allocation).unwrap();
21266 run_test_git(&source, &["init"]);
21267 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21268 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21269 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21270 run_test_git(&source, &["add", "seed.txt"]);
21271 run_test_git(&source, &["commit", "-m", "seed"]);
21272 let profile = ManagedWorktreeProfile::new(
21273 WorktreeProfileId::new("default").unwrap(),
21274 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21275 &allocation,
21276 "gate4agent",
21277 "HEAD",
21278 ManagedWorktreeRetention::RemoveWhenReleased,
21279 )
21280 .unwrap();
21281 let workspace = WorkspaceConfig::new(
21282 WorkspaceId::new("primary").unwrap(),
21283 &source,
21284 )
21285 .unwrap()
21286 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21287 .with_managed_worktree_profile(profile.clone())
21288 .unwrap();
21289 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21290 materialization_test_shared_with_workspace(false, workspace);
21291 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21292 let source_text = source.to_string_lossy().into_owned();
21293 let target_text = target.to_string_lossy().into_owned();
21294 let created = create_git_worktree(
21295 &source_text,
21296 &target_text,
21297 "gate4agent/mw-raw-inventory",
21298 Some("HEAD"),
21299 )
21300 .await
21301 .unwrap();
21302 let lease = ManagedWorktreeLeaseRecord {
21303 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21304 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21305 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21306 profile_id: profile.profile_id().clone(),
21307 profile_revision: profile.revision().clone(),
21308 target_root: created.path.clone(),
21309 branch: "gate4agent/mw-raw-inventory".to_owned(),
21310 base_commit: created.head.clone(),
21311 expected_head: Some(created.head.clone()),
21312 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21313 state: ManagedWorktreeLeaseState::Ready,
21314 session_holders: Vec::new(),
21315 record_holders: Vec::new(),
21316 cleanup_failure: None,
21317 created_at_unix_ms: 1,
21318 updated_at_unix_ms: 1,
21319 };
21320 shared
21321 .workspaces
21322 .write()
21323 .unwrap_or_else(|poisoned| poisoned.into_inner())
21324 .insert(lease.workspace_id.clone(), created.path.clone());
21325 *shared
21326 .managed_worktrees
21327 .lock()
21328 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21329 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21330 let address = SessionAddress {
21331 workspace_id: lease.workspace_id.clone(),
21332 session: SessionKey {
21333 instance_id: AgentInstanceId(205),
21334 generation: SessionGeneration(1),
21335 },
21336 };
21337 let (overlay, materialization_guard) = shared
21338 .prepare_session_materialization(
21339 &address,
21340 &agent("claude"),
21341 SessionMode::Pty,
21342 Some(&receipt),
21343 None,
21344 None,
21345 Some(lease.lease_id.clone()),
21346 )
21347 .unwrap()
21348 .unwrap();
21349 let materialization_id = materialization_guard.id().unwrap().clone();
21350 let materialization_root = shared
21351 .materializations
21352 .lock()
21353 .unwrap_or_else(|poisoned| poisoned.into_inner())
21354 .get(&materialization_id)
21355 .unwrap()
21356 .root()
21357 .to_path_buf();
21358 let selection = shared
21359 .select_environment_profile(
21360 address.session.instance_id,
21361 &agent("claude"),
21362 SessionMode::Pty,
21363 Some(&receipt),
21364 )
21365 .unwrap()
21366 .unwrap();
21367 assert!(shared
21368 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21369 .unwrap()
21370 .is_none());
21371 let record_id = shared
21372 .bind_spawn_session_with_materialization(
21373 &address,
21374 agent("claude"),
21375 SessionMode::Pty,
21376 ProviderRuntimePolicy::raw_pty(),
21377 SpawnRecordPolicy::Always,
21378 Some(receipt.clone()),
21379 None,
21380 None,
21381 Some(materialization_id.clone()),
21382 )
21383 .unwrap()
21384 .unwrap();
21385 selection.retain();
21386 materialization_guard.retain();
21387 let record = shared.record(&record_id).unwrap();
21388 assert_eq!(record.state, ManagedSessionState::Live);
21389 assert!(record.provider_session.is_none());
21390 assert!(record.environment_profile.is_none());
21391 assert!(record.bundle.is_none());
21392 assert!(record.context_id.is_none());
21393 assert!(record.context.is_none());
21394 assert_eq!(record.active_session.as_ref(), Some(&address));
21395 assert_eq!(
21396 shared
21397 .materializations
21398 .lock()
21399 .unwrap_or_else(|poisoned| poisoned.into_inner())
21400 .get(&materialization_id)
21401 .unwrap()
21402 .owner(),
21403 &MaterializationOwner::Session {
21404 incarnation_id: shared.incarnation_id,
21405 instance_id: address.session.instance_id,
21406 generation: address.session.generation,
21407 },
21408 );
21409 {
21410 let mut bindings = shared
21411 .session_bindings
21412 .lock()
21413 .unwrap_or_else(|poisoned| poisoned.into_inner());
21414 bindings
21415 .get_mut(&address.session.instance_id)
21416 .unwrap()
21417 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21418 }
21419 let lease_snapshot = shared
21420 .managed_worktrees
21421 .lock()
21422 .unwrap_or_else(|poisoned| poisoned.into_inner())
21423 .bind_session(
21424 &lease.lease_id,
21425 ManagedWorktreeSessionHolder {
21426 incarnation_id: shared.incarnation_id,
21427 instance_id: address.session.instance_id,
21428 generation: address.session.generation,
21429 },
21430 None,
21431 unix_time_ms(),
21432 )
21433 .unwrap();
21434 assert_eq!(lease_snapshot.active_session_count, 1);
21435 assert_eq!(lease_snapshot.managed_record_count, 0);
21436 shared.persist_state().unwrap();
21437 assert!(shared
21438 .managed_worktrees
21439 .lock()
21440 .unwrap_or_else(|poisoned| poisoned.into_inner())
21441 .get(&lease.lease_id)
21442 .unwrap()
21443 .record_holders
21444 .is_empty());
21445 shared
21446 .handle
21447 .dispatch(shared.prepare_command(ControlCommand::Register {
21448 instance_id: address.session.instance_id,
21449 agent_id: agent("claude"),
21450 transport: TransportKind::Pty,
21451 }))
21452 .unwrap();
21453 runtime.tick().await;
21454 shared.reconcile_managed_record(
21455 &record_id,
21456 &address,
21457 &ControlEvent {
21458 sequence: 1,
21459 command_id: None,
21460 instance_id: address.session.instance_id,
21461 generation: address.session.generation,
21462 event: ControlEventKind::Removed,
21463 },
21464 false,
21465 false,
21466 );
21467 let remove = shared.remove_session(&address);
21468 let drive_runtime = async {
21469 runtime.tick().await;
21470 };
21471 let (removed, ()) = tokio::join!(remove, drive_runtime);
21472 removed.unwrap();
21473
21474 assert!(!materialization_root.exists());
21475 assert!(!Path::new(&created.path).exists());
21476 assert_eq!(
21477 shared
21478 .managed_worktrees
21479 .lock()
21480 .unwrap_or_else(|poisoned| poisoned.into_inner())
21481 .get(&lease.lease_id)
21482 .unwrap()
21483 .state,
21484 ManagedWorktreeLeaseState::Removed,
21485 );
21486 let renamed = shared
21487 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21488 .unwrap();
21489 assert_eq!(renamed.record_id, record_id);
21490 assert_eq!(renamed.display_name, "raw managed inventory");
21491 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21492 assert!(renamed.active_session.is_none());
21493
21494 drop(shared);
21495 drop(runtime);
21496 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21497 std::fs::remove_dir_all(git_root).unwrap();
21498 }
21499
21500 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21501 let root = std::env::current_dir().unwrap()
21502 .join("managed-test-target")
21503 .to_string_lossy()
21504 .into_owned();
21505 let lease = ManagedWorktreeLeaseRecord {
21506 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21507 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21508 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21509 profile_id: WorktreeProfileId::new("default").unwrap(),
21510 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21511 target_root: root.clone(),
21512 branch: "gate4agent/mw-test".to_owned(),
21513 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21514 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21515 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21516 state: ManagedWorktreeLeaseState::Ready,
21517 session_holders: Vec::new(),
21518 record_holders: Vec::new(),
21519 cleanup_failure: None,
21520 created_at_unix_ms: 1,
21521 updated_at_unix_ms: 1,
21522 };
21523 shared.workspaces.write()
21524 .unwrap_or_else(|poisoned| poisoned.into_inner())
21525 .insert(lease.workspace_id.clone(), root);
21526 *shared.managed_worktrees.lock()
21527 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21528 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21529 lease
21530 }
21531
21532 #[cfg(feature = "fixture")]
21533 #[test]
21534 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21535 let mut shared = terminal_test_shared();
21536 shared.fixture_semantic_hook_policy = true;
21537 let lease = install_managed_lease(&shared);
21538 let address = SessionAddress {
21539 workspace_id: lease.workspace_id.clone(),
21540 session: SessionKey {
21541 instance_id: AgentInstanceId(206),
21542 generation: SessionGeneration(1),
21543 },
21544 };
21545 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21546 let effective_runtime_policy =
21547 shared.effective_spawn_runtime_policy(pre_admission_policy);
21548 assert_ne!(effective_runtime_policy, pre_admission_policy);
21549 assert!(effective_runtime_policy.provider_session_identity);
21550
21551 let record_id = shared
21552 .bind_spawn_session(
21553 &address,
21554 agent("claude"),
21555 SessionMode::Pty,
21556 effective_runtime_policy,
21557 SpawnRecordPolicy::Always,
21558 None,
21559 )
21560 .unwrap()
21561 .unwrap();
21562 assert_eq!(
21563 shared.record(&record_id).unwrap().state,
21564 ManagedSessionState::IdentityPending,
21565 );
21566 let (record_holder, raw_inventory_record_id) = {
21567 let mut bindings = shared
21568 .session_bindings
21569 .lock()
21570 .unwrap_or_else(|poisoned| poisoned.into_inner());
21571 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21572 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21573 managed_spawn_record_ownership(binding, effective_runtime_policy)
21574 };
21575 assert_eq!(record_holder.as_ref(), Some(&record_id));
21576 assert!(raw_inventory_record_id.is_none());
21577 let snapshot = shared
21578 .managed_worktrees
21579 .lock()
21580 .unwrap_or_else(|poisoned| poisoned.into_inner())
21581 .bind_session(
21582 &lease.lease_id,
21583 ManagedWorktreeSessionHolder {
21584 incarnation_id: shared.incarnation_id,
21585 instance_id: address.session.instance_id,
21586 generation: address.session.generation,
21587 },
21588 record_holder,
21589 unix_time_ms(),
21590 )
21591 .unwrap();
21592 assert_eq!(snapshot.managed_record_count, 1);
21593 assert_eq!(
21594 shared
21595 .managed_worktrees
21596 .lock()
21597 .unwrap_or_else(|poisoned| poisoned.into_inner())
21598 .get(&lease.lease_id)
21599 .unwrap()
21600 .record_holders,
21601 vec![record_id.clone()],
21602 );
21603
21604 let identity = ProviderSessionIdentity {
21605 key: ProviderSessionKey::SessionId,
21606 id: "monitoring-fixture-managed-session".to_owned(),
21607 transcript_path: None,
21608 };
21609 shared.publish_control(ControlEvent {
21610 sequence: 1,
21611 command_id: None,
21612 instance_id: address.session.instance_id,
21613 generation: address.session.generation,
21614 event: ControlEventKind::ProviderEvent {
21615 sequence: 1,
21616 source: gate4agent_types::ProviderSource {
21617 family: AdapterFamily::Hook,
21618 binding: AdapterBinding::new(
21619 gate4agent_types::AdapterId::new("claude-code").unwrap(),
21620 "fixture/v1",
21621 gate4agent_types::AdapterVerification::SyntheticFixture,
21622 )
21623 .unwrap(),
21624 },
21625 source_sequence: 1,
21626 event: ProviderEvent::SessionIdentityObserved {
21627 identity: identity.clone(),
21628 },
21629 },
21630 });
21631
21632 let live = shared.record(&record_id).unwrap();
21633 assert_eq!(live.state, ManagedSessionState::Live);
21634 assert_eq!(live.provider_session.as_ref(), Some(&identity));
21635 assert_eq!(live.active_session.as_ref(), Some(&address));
21636 assert_eq!(
21637 shared
21638 .managed_worktrees
21639 .lock()
21640 .unwrap_or_else(|poisoned| poisoned.into_inner())
21641 .get(&lease.lease_id)
21642 .unwrap()
21643 .record_holders,
21644 vec![record_id],
21645 );
21646 }
21647
21648 fn managed_v2_test_shared(
21649 allocation_root: &Path,
21650 state_path: Option<PathBuf>,
21651 profile_revision: &str,
21652 ) -> NodeShared {
21653 let profile = ManagedWorktreeProfile::new(
21654 WorktreeProfileId::new("default").unwrap(),
21655 WorktreeProfileRevision::new(profile_revision).unwrap(),
21656 allocation_root,
21657 "gate4agent",
21658 "HEAD",
21659 ManagedWorktreeRetention::RemoveWhenReleased,
21660 )
21661 .unwrap();
21662 let workspace = WorkspaceConfig::new(
21663 WorkspaceId::new("primary").unwrap(),
21664 std::env::current_dir().unwrap(),
21665 )
21666 .unwrap()
21667 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21668 .with_managed_worktree_profile(profile)
21669 .unwrap();
21670 let catalog = active_registry().unwrap();
21671 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21672 let mut shared = NodeShared::new(
21673 handle,
21674 "fixture-token".to_owned(),
21675 NodeId::new("node-terminal-test").unwrap(),
21676 vec![workspace],
21677 vec![agent("claude")],
21678 );
21679 shared.state_path = state_path;
21680 shared
21681 }
21682
21683 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21684 let mut spawn_spec = spawn_spec_fixture();
21685 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21686 ManagedWorktreeSpawnRequestV2 {
21687 spawn_spec,
21688 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21689 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21690 }
21691 }
21692
21693 #[test]
21694 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21695 let shared = terminal_test_shared();
21696 let mut lease = install_managed_lease(&shared);
21697 let session = SessionAddress {
21698 workspace_id: lease.workspace_id.clone(),
21699 session: SessionKey {
21700 instance_id: AgentInstanceId(73),
21701 generation: SessionGeneration(4),
21702 },
21703 };
21704 lease.state = ManagedWorktreeLeaseState::InUse;
21705 lease.session_holders.push(ManagedWorktreeSessionHolder {
21706 incarnation_id: shared.incarnation_id,
21707 instance_id: session.session.instance_id,
21708 generation: session.session.generation,
21709 });
21710 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21711 assert!(resolved.target.worktree_id.is_none());
21712
21713 let receipt = managed_spawn_receipt(
21714 &resolved,
21715 shared.incarnation_id,
21716 session.clone(),
21717 lease.snapshot(),
21718 None,
21719 None,
21720 None,
21721 );
21722 assert!(resolved.target.worktree_id.is_none());
21723 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21724 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21725 assert_eq!(receipt.spawn.session, session);
21726 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21727 let encoded = serde_json::to_vec(&receipt).unwrap();
21728 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21729 assert_eq!(decoded, receipt);
21730 }
21731
21732 #[tokio::test]
21733 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21734 let root = temporary_workspace_root("managed-v2-revision");
21735 let allocation = root.join("allocation");
21736 std::fs::create_dir_all(&allocation).unwrap();
21737 let shared = managed_v2_test_shared(
21738 &allocation,
21739 Some(root.join("node-state.json")),
21740 "v1",
21741 );
21742 let mut request = managed_v2_request("managed-v2-revision");
21743 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21744
21745 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21746
21747 assert_eq!(
21748 failure.code,
21749 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21750 );
21751 assert!(shared
21752 .managed_worktrees
21753 .lock()
21754 .unwrap_or_else(|poisoned| poisoned.into_inner())
21755 .records()
21756 .is_empty());
21757 assert!(shared
21758 .managed_spawn_replays
21759 .lock()
21760 .unwrap_or_else(|poisoned| poisoned.into_inner())
21761 .is_empty());
21762 std::fs::remove_dir_all(root).unwrap();
21763 }
21764
21765 #[test]
21766 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21767 let root = temporary_workspace_root("managed-v2-concurrent");
21768 let allocation = root.join("allocation");
21769 std::fs::create_dir_all(&allocation).unwrap();
21770 let shared = Arc::new(managed_v2_test_shared(
21771 &allocation,
21772 Some(root.join("node-state.json")),
21773 "v1",
21774 ));
21775 let request = managed_v2_request("managed-v2-concurrent");
21776 let barrier = Arc::new(std::sync::Barrier::new(3));
21777 let mut joins = Vec::new();
21778 for _ in 0..2 {
21779 let shared = Arc::clone(&shared);
21780 let request = request.clone();
21781 let barrier = Arc::clone(&barrier);
21782 joins.push(std::thread::spawn(move || {
21783 let digest = managed_spawn_request_digest_v2(&request).unwrap();
21784 barrier.wait();
21785 shared.acquire_durable_managed_spawn_v2(&request, digest)
21786 }));
21787 }
21788 barrier.wait();
21789 let results = joins
21790 .into_iter()
21791 .map(|join| join.join().unwrap())
21792 .collect::<Vec<_>>();
21793 assert_eq!(
21794 results
21795 .iter()
21796 .filter(|result| {
21797 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21798 })
21799 .count(),
21800 1,
21801 );
21802 assert_eq!(
21803 results
21804 .iter()
21805 .filter(|result| {
21806 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21807 })
21808 .count(),
21809 1,
21810 );
21811 assert_eq!(
21812 shared
21813 .managed_spawn_replays
21814 .lock()
21815 .unwrap_or_else(|poisoned| poisoned.into_inner())
21816 .len(),
21817 1,
21818 );
21819 assert!(shared
21820 .managed_worktrees
21821 .lock()
21822 .unwrap_or_else(|poisoned| poisoned.into_inner())
21823 .records()
21824 .is_empty());
21825 drop(shared);
21826 std::fs::remove_dir_all(root).unwrap();
21827 }
21828
21829 #[tokio::test]
21830 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21831 let root = temporary_workspace_root("managed-v2-linked-pending");
21832 let allocation = root.join("allocation");
21833 std::fs::create_dir_all(&allocation).unwrap();
21834 let state_path = root.join("node-state.json");
21835 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21836 let lease = install_managed_lease(&shared);
21837 let request = managed_v2_request("managed-v2-linked-pending");
21838 assert!(matches!(
21839 shared
21840 .acquire_durable_managed_spawn_v2(
21841 &request,
21842 managed_spawn_request_digest_v2(&request).unwrap(),
21843 )
21844 .unwrap(),
21845 DurableManagedSpawnReplayDecision::Reserved,
21846 ));
21847 {
21848 let _transaction = shared
21849 .state_transaction
21850 .lock()
21851 .unwrap_or_else(|poisoned| poisoned.into_inner());
21852 shared
21853 .link_durable_managed_spawn_v2_lease_locked(
21854 &request.spawn_spec.idempotency_key,
21855 &lease.lease_id,
21856 )
21857 .unwrap();
21858 shared.persist_state_locked().unwrap();
21859 }
21860 drop(shared);
21861
21862 let mut loaded = session_registry::load(
21863 Some(&state_path),
21864 &NodeId::new("node-terminal-test").unwrap(),
21865 )
21866 .unwrap();
21867 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21868 *reopened
21869 .managed_worktrees
21870 .lock()
21871 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21872 ManagedWorktreeRegistry::from_records(
21873 std::mem::take(&mut loaded.managed_worktrees),
21874 std::mem::take(&mut loaded.managed_worktree_tombstones),
21875 )
21876 .unwrap();
21877 *reopened
21878 .managed_spawn_replays
21879 .lock()
21880 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21881 .managed_spawn_replays
21882 .into_iter()
21883 .map(|record| (record.idempotency_key.clone(), record))
21884 .collect();
21885 let lease_count = reopened
21886 .managed_worktrees
21887 .lock()
21888 .unwrap_or_else(|poisoned| poisoned.into_inner())
21889 .records()
21890 .len();
21891
21892 let failure = reopened
21893 .spawn_managed_worktree_v2(request)
21894 .await
21895 .unwrap_err();
21896
21897 assert_eq!(
21898 failure.code,
21899 NodeFailureCode::ManagedWorktreeRecoveryRequired,
21900 );
21901 assert_eq!(
21902 reopened
21903 .managed_worktrees
21904 .lock()
21905 .unwrap_or_else(|poisoned| poisoned.into_inner())
21906 .records()
21907 .len(),
21908 lease_count,
21909 );
21910 std::fs::remove_dir_all(root).unwrap();
21911 }
21912
21913 #[tokio::test]
21914 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21915 let root = temporary_workspace_root("managed-v2-reopen");
21916 let allocation = root.join("allocation");
21917 std::fs::create_dir_all(&allocation).unwrap();
21918 let state_path = root.join("node-state.json");
21919 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21920 let lease = install_managed_lease(&shared);
21921 let request = managed_v2_request("managed-v2-reopen");
21922 let session = SessionAddress {
21923 workspace_id: lease.workspace_id.clone(),
21924 session: SessionKey {
21925 instance_id: AgentInstanceId(91),
21926 generation: SessionGeneration(1),
21927 },
21928 };
21929 let snapshot = {
21930 let mut registry = shared
21931 .managed_worktrees
21932 .lock()
21933 .unwrap_or_else(|poisoned| poisoned.into_inner());
21934 let current = registry.get_mut(&lease.lease_id).unwrap();
21935 current.state = ManagedWorktreeLeaseState::InUse;
21936 current.session_holders.push(ManagedWorktreeSessionHolder {
21937 incarnation_id: shared.incarnation_id,
21938 instance_id: session.session.instance_id,
21939 generation: session.session.generation,
21940 });
21941 current.snapshot()
21942 };
21943 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21944 let receipt = managed_spawn_receipt(
21945 &resolved,
21946 shared.incarnation_id,
21947 session,
21948 snapshot,
21949 None,
21950 None,
21951 None,
21952 );
21953 assert!(matches!(
21954 shared
21955 .acquire_durable_managed_spawn_v2(
21956 &request,
21957 managed_spawn_request_digest_v2(&request).unwrap(),
21958 )
21959 .unwrap(),
21960 DurableManagedSpawnReplayDecision::Reserved,
21961 ));
21962 {
21963 let _transaction = shared
21964 .state_transaction
21965 .lock()
21966 .unwrap_or_else(|poisoned| poisoned.into_inner());
21967 shared
21968 .link_durable_managed_spawn_v2_lease_locked(
21969 &request.spawn_spec.idempotency_key,
21970 &lease.lease_id,
21971 )
21972 .unwrap();
21973 shared.persist_state_locked().unwrap();
21974 }
21975 shared
21976 .commit_durable_managed_spawn_v2(&request, receipt.clone())
21977 .unwrap();
21978 let legacy_same_key = ManagedWorktreeSpawnRequest {
21979 spawn_spec: request.spawn_spec.clone(),
21980 worktree_profile_id: request.worktree_profile_id.clone(),
21981 };
21982 shared.remember_managed_spawn_attempt(
21983 Some(&request.spawn_spec.idempotency_key),
21984 legacy_same_key.clone(),
21985 Ok(receipt.clone()),
21986 Instant::now(),
21987 );
21988 assert!(shared
21989 .replay_managed_spawn(&legacy_same_key, Instant::now())
21990 .unwrap()
21991 .is_none());
21992 drop(shared);
21993
21994 let mut loaded = session_registry::load(
21995 Some(&state_path),
21996 &NodeId::new("node-terminal-test").unwrap(),
21997 )
21998 .unwrap();
21999 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22000 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22001 *reopened
22002 .managed_worktrees
22003 .lock()
22004 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22005 ManagedWorktreeRegistry::from_records(
22006 std::mem::take(&mut loaded.managed_worktrees),
22007 std::mem::take(&mut loaded.managed_worktree_tombstones),
22008 )
22009 .unwrap();
22010 *reopened
22011 .managed_spawn_replays
22012 .lock()
22013 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22014 .managed_spawn_replays
22015 .into_iter()
22016 .map(|record| (record.idempotency_key.clone(), record))
22017 .collect();
22018 let lease_count_before = reopened
22019 .managed_worktrees
22020 .lock()
22021 .unwrap_or_else(|poisoned| poisoned.into_inner())
22022 .records()
22023 .len();
22024
22025 assert_eq!(
22026 reopened
22027 .spawn_managed_worktree_v2(request.clone())
22028 .await
22029 .unwrap(),
22030 receipt,
22031 );
22032 assert_eq!(
22033 reopened
22034 .managed_worktrees
22035 .lock()
22036 .unwrap_or_else(|poisoned| poisoned.into_inner())
22037 .records()
22038 .len(),
22039 lease_count_before,
22040 );
22041 let mut changed = request;
22042 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22043 let failure = reopened
22044 .spawn_managed_worktree_v2(changed)
22045 .await
22046 .unwrap_err();
22047 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22048 assert_eq!(
22049 reopened
22050 .managed_worktrees
22051 .lock()
22052 .unwrap_or_else(|poisoned| poisoned.into_inner())
22053 .records()
22054 .len(),
22055 lease_count_before,
22056 );
22057 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22058 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22059 let failure = reopened
22060 .spawn_managed_worktree_v2(new_key)
22061 .await
22062 .unwrap_err();
22063 assert_eq!(
22064 failure.code,
22065 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22066 );
22067 assert_eq!(
22068 reopened
22069 .managed_worktrees
22070 .lock()
22071 .unwrap_or_else(|poisoned| poisoned.into_inner())
22072 .records()
22073 .len(),
22074 lease_count_before,
22075 );
22076 assert_eq!(
22077 reopened
22078 .managed_spawn_replays
22079 .lock()
22080 .unwrap_or_else(|poisoned| poisoned.into_inner())
22081 .len(),
22082 1,
22083 );
22084 std::fs::remove_dir_all(root).unwrap();
22085 }
22086
22087 #[test]
22088 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22089 let shared = terminal_test_shared();
22090 let request = ManagedWorktreeSpawnRequest {
22091 spawn_spec: spawn_spec_fixture(),
22092 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22093 };
22094 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22095 shared.remember_managed_spawn(
22096 request.clone(),
22097 Err(expected.clone()),
22098 Instant::now(),
22099 );
22100
22101 assert_eq!(
22102 shared
22103 .replay_managed_spawn(&request, Instant::now())
22104 .unwrap()
22105 .unwrap(),
22106 Err(expected),
22107 );
22108 assert!(shared
22109 .managed_spawn_replays
22110 .lock()
22111 .unwrap_or_else(|poisoned| poisoned.into_inner())
22112 .is_empty());
22113 }
22114
22115 #[tokio::test]
22116 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22117 let shared = terminal_test_shared();
22118 let lease = install_managed_lease(&shared);
22119 let raw = shared.spawn_session(
22120 lease.workspace_id.clone(),
22121 agent("claude"),
22122 SessionMode::Pty,
22123 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22124 None,
22125 ).await.unwrap_err();
22126 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22127 let mut spec = spawn_spec_fixture();
22128 spec.target.workspace_id = lease.workspace_id.clone();
22129 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22130 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22131 let mut selected = spawn_spec_fixture();
22132 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22133 selected.target.worktree_id = Some(lease.workspace_id);
22134 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22135 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22136 assert!(shared.handle.snapshot().sessions.is_empty());
22137 }
22138
22139 #[test]
22140 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22141 let shared = terminal_test_shared();
22142 let lease = install_managed_lease(&shared);
22143 let snapshot = snapshot_for_wire(
22144 &shared, true, true, true, false, true, true, false, false, true,
22145 );
22146 assert!(snapshot.managed_worktrees.is_empty());
22147 assert!(snapshot.workspaces.iter().all(|workspace| {
22148 workspace.managed_worktree_profiles.is_none()
22149 }));
22150 let mut reply = ResponseEnvelope {
22151 request_id: 1,
22152 result: Ok(NodeResponse::Resync {
22153 event_sequence: 1,
22154 oldest_available_sequence: 1,
22155 snapshot: shared.snapshot(),
22156 events: vec![NodeEventEnvelope {
22157 sequence: 1,
22158 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22159 }],
22160 }),
22161 };
22162 project_response_without_managed_worktrees(&mut reply);
22163 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22164 panic!("projection changed the response kind");
22165 };
22166 assert!(snapshot.managed_worktrees.is_empty());
22167 assert!(events.is_empty());
22168 let mut git = git_snapshot_for_parser();
22169 git.branch = Some(lease.branch.clone());
22170 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22171 lease_id: lease.lease_id.clone(),
22172 source_workspace_id: lease.source_workspace_id.clone(),
22173 branch: lease.branch.clone(),
22174 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22175 active_session_count: 1,
22176 managed_record_count: 0,
22177 });
22178 let mut inspection_reply = ResponseEnvelope {
22179 request_id: 2,
22180 result: Ok(NodeResponse::WorkspaceInspected {
22181 inspection: WorkspaceInspection {
22182 workspace_id: lease.workspace_id.clone(),
22183 entries: Vec::new(),
22184 tree_truncated: false,
22185 git,
22186 truncation: None,
22187 },
22188 }),
22189 };
22190 project_response_without_managed_worktrees(&mut inspection_reply);
22191 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22192 panic!("projection changed the inspection response kind");
22193 };
22194 assert!(inspection.git.managed_worktree.is_none());
22195 let failure = managed_git_worktree_failure(GitWorktreeError {
22196 kind: GitWorktreeErrorKind::Failed,
22197 message: r"git failed at C:\private\managed-target".to_owned(),
22198 });
22199 assert!(!failure.message.contains("private"));
22200 assert!(!failure.message.contains("managed-target"));
22201 }
22202
22203 #[tokio::test]
22204 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22205 let root = temporary_workspace_root("managed-restart");
22206 let source = root.join("source");
22207 let allocation = root.join("allocation");
22208 std::fs::create_dir_all(&source).unwrap();
22209 std::fs::create_dir_all(&allocation).unwrap();
22210 run_test_git(&source, &["init"]);
22211 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22212 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22213 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22214 run_test_git(&source, &["add", "seed.txt"]);
22215 run_test_git(&source, &["commit", "-m", "seed"]);
22216 let profile = ManagedWorktreeProfile::new(
22217 WorktreeProfileId::new("default").unwrap(),
22218 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22219 &allocation,
22220 "gate4agent",
22221 "HEAD",
22222 ManagedWorktreeRetention::Retain,
22223 ).unwrap();
22224 let workspace = WorkspaceConfig::new(
22225 WorkspaceId::new("primary").unwrap(),
22226 &source,
22227 ).unwrap()
22228 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22229 .with_managed_worktree_profile(profile.clone()).unwrap();
22230 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22231 let source_text = workspace.canonical_root().to_owned();
22232 let target_text = target.to_string_lossy().into_owned();
22233 let created = create_git_worktree(
22234 &source_text,
22235 &target_text,
22236 "gate4agent/mw-restart",
22237 Some("HEAD"),
22238 ).await.unwrap();
22239 let lease = ManagedWorktreeLeaseRecord {
22240 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22241 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22242 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22243 profile_id: profile.profile_id().clone(),
22244 profile_revision: profile.revision().clone(),
22245 target_root: created.path.clone(),
22246 branch: "gate4agent/mw-restart".to_owned(),
22247 base_commit: created.head.clone(),
22248 expected_head: None,
22249 retention: ManagedWorktreeRetention::Retain,
22250 state: ManagedWorktreeLeaseState::Allocating,
22251 session_holders: Vec::new(),
22252 record_holders: Vec::new(),
22253 cleanup_failure: None,
22254 created_at_unix_ms: 1,
22255 updated_at_unix_ms: 1,
22256 };
22257 let catalog = active_registry().unwrap();
22258 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22259 let shared = NodeShared::new_with_incarnation(
22260 handle,
22261 "fixture-token".to_owned(),
22262 NodeId::new("node-managed-restart").unwrap(),
22263 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22264 vec![workspace],
22265 vec![agent("claude")],
22266 ProviderRuntimeStatuses::default(),
22267 None,
22268 Vec::new(),
22269 Vec::new(),
22270 SpawnProfileRegistry::default(),
22271 None,
22272 None,
22273 None,
22274 None,
22275 Vec::new(),
22276 vec![lease.clone()],
22277 Vec::new(),
22278 Vec::new(),
22279 None,
22280 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22281 );
22282 shared.reconcile_managed_worktrees().await;
22283 let retained = shared.managed_worktrees.lock()
22284 .unwrap_or_else(|poisoned| poisoned.into_inner())
22285 .get(&lease.lease_id).unwrap().clone();
22286 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22287 assert!(shared.workspaces.read()
22288 .unwrap_or_else(|poisoned| poisoned.into_inner())
22289 .contains_key(&lease.workspace_id));
22290 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22291 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22292 assert!(!Path::new(&created.path).exists());
22293 std::fs::remove_dir_all(root).unwrap();
22294 }
22295
22296 fn run_test_git(root: &Path, arguments: &[&str]) {
22297 let output = std::process::Command::new("git")
22298 .arg("-C").arg(root).args(arguments).output().unwrap();
22299 assert!(
22300 output.status.success(),
22301 "git {:?} failed: {}",
22302 arguments,
22303 String::from_utf8_lossy(&output.stderr),
22304 );
22305 }
22306
22307 #[tokio::test]
22308 async fn spawn_spec_validation_fails_before_session_mutation() {
22309 let shared = terminal_test_shared();
22310 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22311 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22312 }));
22313 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22314 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22315 }));
22316 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22317 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22318 }));
22319
22320 let mut target_mismatch = spawn_spec_fixture();
22321 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22322 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22323 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22324
22325 let mut unknown_profile = spawn_spec_fixture();
22326 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22327 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22328 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22329 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22330
22331 let mut revision_mismatch = spawn_spec_fixture();
22332 revision_mismatch.idempotency_key =
22333 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22334 revision_mismatch.expected_profile_revision =
22335 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22336 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22337 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22338
22339 let mut unsupported_materializer = spawn_spec_fixture();
22340 unsupported_materializer.idempotency_key =
22341 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22342 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22343 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22344 };
22345 let failure = shared
22346 .spawn_from_spec(unsupported_materializer)
22347 .await
22348 .unwrap_err();
22349 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22350
22351 let failure = shared
22352 .spawn_session_with_deadline(
22353 WorkspaceId::new("primary").unwrap(),
22354 agent("claude"),
22355 SessionMode::Pty,
22356 gate4agent_types::TerminalSize {
22357 rows: 24,
22358 columns: 80,
22359 },
22360 None,
22361 None,
22362 None,
22363 None,
22364 None,
22365 None,
22366 SpawnRecordPolicy::ProviderIdentityOnly,
22367 Some(Instant::now()),
22368 &[],
22369 None,
22370 ApprovalLevel::default(),
22371 None,
22372 None,
22373 )
22374 .await
22375 .unwrap_err();
22376 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22377 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22378 assert!(shared.handle.snapshot().sessions.is_empty());
22379 }
22380
22381 #[test]
22382 fn worktree_selection_requires_its_separate_negotiated_capability() {
22383 let mut spec = spawn_spec_fixture();
22384 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22385 let request = NodeRequest::SpawnSpec { spec };
22386 let spawn_spec_capability =
22387 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22388 assert!(request_uses_unnegotiated_capability(
22389 &request,
22390 &[spawn_spec_capability.clone()],
22391 ));
22392 let worktree_capability =
22393 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22394 assert!(request_uses_unnegotiated_capability(
22395 &request,
22396 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22397 ));
22398 let profile_revision_capability =
22399 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22400 assert!(request_uses_unnegotiated_capability(
22401 &request,
22402 &[
22403 spawn_spec_capability.clone(),
22404 worktree_capability.clone(),
22405 profile_revision_capability.clone(),
22406 ],
22407 ));
22408 let bundle_materialization_capability =
22415 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22416 assert!(request_uses_unnegotiated_capability(
22417 &request,
22418 &[
22419 spawn_spec_capability.clone(),
22420 worktree_capability.clone(),
22421 profile_revision_capability.clone(),
22422 bundle_materialization_capability.clone(),
22423 ],
22424 ));
22425 let history_context_pack_capability =
22426 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22427 assert!(!request_uses_unnegotiated_capability(
22428 &request,
22429 &[
22430 spawn_spec_capability,
22431 worktree_capability,
22432 profile_revision_capability,
22433 bundle_materialization_capability,
22434 history_context_pack_capability,
22435 ],
22436 ));
22437 }
22438
22439 #[tokio::test]
22440 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22441 let mut shared = terminal_test_shared();
22442 let spec = spawn_spec_fixture();
22443 let receipt = shared
22444 .resolve_spawn_spec(&spec)
22445 .unwrap()
22446 .receipt(shared.incarnation_id, terminal_address(1));
22447 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22448
22449 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22450 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22451 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22452
22453 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22454
22455 let mut revision_conflicting = spec.clone();
22456 revision_conflicting.expected_profile_revision =
22457 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22458 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22459 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22460
22461 let mut stale_cache_miss = spec.clone();
22462 stale_cache_miss.idempotency_key =
22463 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22464 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22465 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22466
22467 let mut conflicting = spec;
22468 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22469 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22470 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22471 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22472 assert!(shared.handle.snapshot().sessions.is_empty());
22473 }
22474
22475 #[tokio::test]
22476 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22477 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22478 let mut spec = spawn_spec_fixture();
22479 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22480 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22481 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22482
22483 let failure = shared
22484 .create_worktree(
22485 WorkspaceId::new("primary").unwrap(),
22486 WorkspaceId::new("new-worktree").unwrap(),
22487 "not-an-absolute-path".to_owned(),
22488 "invalid branch".to_owned(),
22489 None,
22490 )
22491 .await
22492 .unwrap_err();
22493 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22494
22495 let failure = shared
22496 .remove_worktree(
22497 WorkspaceId::new("primary").unwrap(),
22498 "not-an-absolute-path".to_owned(),
22499 )
22500 .await
22501 .unwrap_err();
22502 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22503 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22504 assert!(shared.handle.snapshot().sessions.is_empty());
22505 }
22506
22507 #[tokio::test]
22508 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22509 let root = temporary_workspace_root("manual-worktree-compensation");
22510 let source = root.join("source");
22511 let target = root.join("target");
22512 let invalid_state_parent = root.join("state-parent-is-a-file");
22513 let invalid_state_path = invalid_state_parent.join("state.json");
22514 std::fs::create_dir_all(&source).unwrap();
22515 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22516 run_test_git(&source, &["init"]);
22517 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22518 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22519 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22520 run_test_git(&source, &["add", "seed.txt"]);
22521 run_test_git(&source, &["commit", "-m", "seed"]);
22522
22523 let catalog = active_registry().unwrap();
22524 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22525 let workspace = WorkspaceConfig::new(
22526 WorkspaceId::new("primary").unwrap(),
22527 &source,
22528 ).unwrap();
22529 let mut shared = NodeShared::new(
22530 handle,
22531 "fixture-token".to_owned(),
22532 NodeId::new("node-worktree-compensation").unwrap(),
22533 vec![workspace],
22534 vec![agent("claude")],
22535 );
22536 shared.state_path = Some(invalid_state_path);
22537
22538 let error = shared.create_worktree(
22539 WorkspaceId::new("primary").unwrap(),
22540 WorkspaceId::new("compensated").unwrap(),
22541 target.to_string_lossy().into_owned(),
22542 "gate4agent/compensated".to_owned(),
22543 Some("HEAD".to_owned()),
22544 ).await.unwrap_err();
22545
22546 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22547 assert!(!target.exists());
22548 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22549 .iter()
22550 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22551 std::fs::remove_dir_all(root).unwrap();
22552 }
22553
22554 #[test]
22555 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22556 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22557 NativeGitWorktreeSnapshot {
22558 path: path.to_owned(),
22559 head: "0123456789abcdef".to_owned(),
22560 branch: Some("refs/heads/test".to_owned()),
22561 is_bare,
22562 is_main,
22563 locked: false,
22564 lock_reason: None,
22565 prunable: false,
22566 prunable_reason: None,
22567 workspace_id: None,
22568 }
22569 }
22570
22571 let source = r"C:\repo";
22572 let selected = r"C:\repo-worktree";
22573 let listed = vec![
22574 worktree(source, true, false),
22575 worktree(selected, false, false),
22576 ];
22577 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22578
22579 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22580 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22581
22582 let bare = vec![
22583 worktree(source, true, false),
22584 worktree(selected, false, true),
22585 ];
22586 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22587 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22588
22589 let foreign_failure =
22590 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22591 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22592 }
22593
22594 fn terminal_frame(sequence: u64) -> TerminalFrame {
22595 TerminalFrame {
22596 sequence,
22597 size: gate4agent_types::TerminalSize {
22598 rows: 24,
22599 columns: 80,
22600 },
22601 cursor_row: 2,
22602 cursor_column: 3,
22603 contents: format!("frame-{sequence}"),
22604 formatted: vec![1, 2, 3],
22605 scrollback_formatted: Vec::new(),
22606 alternate_screen: false,
22607 mouse_protocol_enabled: false,
22608 mouse_protocol_encoding: Default::default(),
22609 produced_at_unix_ms: 0,
22610 screen_state: gate4agent_types::PtyScreenState::default(),
22611 bracketed_paste: None,
22612 }
22613 }
22614
22615 #[test]
22616 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22617 let shared = terminal_test_shared();
22618 let mut receiver = shared.terminal_event_tx.subscribe();
22619 let original = terminal_address(1);
22620
22621 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22622 let first = receiver.try_recv().unwrap();
22623 assert_eq!(first.len(), 1);
22624 assert!(matches!(
22625 &first[0].event,
22626 NodeEvent::TerminalFrame { address, frame }
22627 if address == &original && frame.sequence == 7
22628 ));
22629
22630 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22631 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22632 assert!(matches!(
22633 receiver.try_recv(),
22634 Err(broadcast::error::TryRecvError::Empty)
22635 ));
22636
22637 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22638 let advanced = receiver.try_recv().unwrap();
22639 assert_eq!(advanced[0].sequence, 2);
22640
22641 let resumed = terminal_address(2);
22642 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22643 let reset = receiver.try_recv().unwrap();
22644 assert_eq!(reset[0].sequence, 3);
22645 assert!(matches!(
22646 &reset[0].event,
22647 NodeEvent::TerminalFrame { address, frame }
22648 if address == &resumed && frame.sequence == 1
22649 ));
22650 shared.clear_terminal_frame_watermark(&resumed);
22651 assert!(!shared
22652 .terminal_frame_watermarks
22653 .lock()
22654 .unwrap_or_else(|poisoned| poisoned.into_inner())
22655 .contains_key(&resumed.session.instance_id));
22656 }
22657
22658 #[test]
22659 fn terminal_frame_batches_replace_without_entering_node_event_history() {
22660 let shared = terminal_test_shared();
22661 let mut receiver = shared.terminal_event_tx.subscribe();
22662 let address = terminal_address(1);
22663 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22664
22665 for sequence in 1..=count {
22666 shared.publish_terminal_frame_candidates(vec![(
22667 address.clone(),
22668 terminal_frame(sequence),
22669 )]);
22670 }
22671
22672 let history = shared
22673 .history
22674 .lock()
22675 .unwrap_or_else(|poisoned| poisoned.into_inner());
22676 assert!(history.events.is_empty());
22677 assert_eq!(history.last_sequence, count);
22678 assert_eq!(history.replay_floor_sequence, 1);
22679 drop(history);
22680 let NodeResponse::Resync {
22681 event_sequence,
22682 oldest_available_sequence,
22683 events,
22684 ..
22685 } = shared.resync(0) else {
22686 unreachable!("resync helper returned another response")
22687 };
22688 assert_eq!(event_sequence, count);
22689 assert_eq!(oldest_available_sequence, 1);
22690 assert!(events.is_empty());
22691 assert!(matches!(
22692 resync_required_event(&shared).event,
22693 NodeEvent::ResyncRequired {
22694 oldest_available_sequence: 1,
22695 }
22696 ));
22697 assert!(matches!(
22698 receiver.try_recv(),
22699 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22700 ));
22701 let latest = receiver.try_recv().unwrap();
22702 assert_eq!(latest.len(), 1);
22703 assert_eq!(latest[0].sequence, count);
22704 }
22705
22706 #[test]
22707 fn real_durable_eviction_advances_authoritative_replay_floor() {
22708 let shared = terminal_test_shared();
22709 for _ in 0..=NODE_EVENT_HISTORY_MAX {
22710 shared.publish(NodeEvent::ControllerChanged { controller: None });
22711 }
22712
22713 let NodeResponse::Resync {
22714 event_sequence,
22715 oldest_available_sequence,
22716 events,
22717 ..
22718 } = shared.resync(0) else {
22719 unreachable!("resync helper returned another response")
22720 };
22721 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22722 assert_eq!(oldest_available_sequence, 2);
22723 assert_eq!(events.first().map(|event| event.sequence), Some(2));
22724 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22725 assert!(matches!(
22726 resync_required_event(&shared).event,
22727 NodeEvent::ResyncRequired {
22728 oldest_available_sequence: 2,
22729 }
22730 ));
22731 }
22732
22733 #[test]
22734 fn legacy_terminal_frame_connection_has_no_live_subscription() {
22735 let shared = terminal_test_shared();
22736 let without_capability = terminal_event_subscription(&shared, false);
22737 let with_capability = terminal_event_subscription(&shared, true);
22738 assert!(without_capability.is_none());
22739 assert!(with_capability.is_some());
22740 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22741 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22742 }));
22743 }
22744
22745 #[test]
22746 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22747 let mut pending = Vec::new();
22748 let mut event_sequence = 0_u64;
22749 for frame_sequence in 1..=4 {
22750 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22751 event_sequence += 1;
22752 let mut address = terminal_address(1);
22753 address.session.instance_id = AgentInstanceId(instance);
22754 queue_connection_event(
22755 &mut pending,
22756 NodeEventEnvelope {
22757 sequence: event_sequence,
22758 event: NodeEvent::TerminalFrame {
22759 address,
22760 frame: terminal_frame(frame_sequence),
22761 },
22762 },
22763 0,
22764 );
22765 }
22766 }
22767 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22768 pending.sort_unstable_by_key(|event| event.sequence);
22769
22770 let mut uninterrupted = pending.clone();
22771 let first_burst = connection_event_burst_len(uninterrupted.len());
22772 uninterrupted.drain(..first_burst);
22773 let second_burst = connection_event_burst_len(uninterrupted.len());
22774 uninterrupted.drain(..second_burst);
22775 assert!(uninterrupted.is_empty());
22776
22777 let (frame_tx, mut frame_rx) =
22778 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22779 frame_tx
22780 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22781 request_id: 77,
22782 request: NodeRequest::Snapshot,
22783 })))
22784 .unwrap();
22785 let burst_len = connection_event_burst_len(pending.len());
22786 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22787 pending.drain(..burst_len);
22788 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22789 assert!(matches!(
22790 frame_rx.try_recv(),
22791 Ok(Ok(ClientFrame::Request(RequestEnvelope {
22792 request_id: 77,
22793 request: NodeRequest::Snapshot,
22794 })))
22795 ));
22796 }
22797
22798 #[test]
22799 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22800 let mut pending = Vec::new();
22801 for sequence in [7_u64, 8] {
22802 let mut address = terminal_address(1);
22803 address.session.instance_id = AgentInstanceId(sequence);
22804 queue_connection_event(
22805 &mut pending,
22806 NodeEventEnvelope {
22807 sequence,
22808 event: NodeEvent::TerminalFrame {
22809 address,
22810 frame: terminal_frame(sequence),
22811 },
22812 },
22813 0,
22814 );
22815 }
22816 let mut discard_through = 0;
22817 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22818 assert!(pending.is_empty());
22819
22820 let replayed = NodeEventEnvelope {
22821 sequence: 8,
22822 event: NodeEvent::TerminalFrame {
22823 address: terminal_address(1),
22824 frame: terminal_frame(8),
22825 },
22826 };
22827 let advanced = NodeEventEnvelope {
22828 sequence: 9,
22829 event: NodeEvent::TerminalFrame {
22830 address: terminal_address(2),
22831 frame: terminal_frame(1),
22832 },
22833 };
22834 queue_connection_event_batch(
22835 &mut pending,
22836 &[replayed, advanced.clone()],
22837 discard_through,
22838 );
22839 assert_eq!(pending, vec![advanced]);
22840 }
22841
22842 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22843 ManagedSessionRecord {
22844 record_id: SessionRecordId::new(record_id).unwrap(),
22845 display_name: format!("{provider} record"),
22846 provider: agent(provider),
22847 mode: SessionMode::Pty,
22848 state: ManagedSessionState::Unavailable,
22849 workspace_id: WorkspaceId::new("primary").unwrap(),
22850 canonical_root: opaque_windows_path(
22851 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22852 ),
22853 provider_session: None,
22854 active_session: None,
22855 environment_profile: None,
22856 bundle: None,
22857 context_id: None,
22858 context: None,
22859 exported_context: None,
22860 task_binding: None,
22861 created_at_unix_ms: 1,
22862 updated_at_unix_ms: 1,
22863 last_error: None,
22864 }
22865 }
22866
22867 fn retention_test_record(
22868 workspace_id: &str,
22869 record_id: &str,
22870 state: ManagedSessionState,
22871 updated_at_unix_ms: u64,
22872 ) -> ManagedSessionRecord {
22873 ManagedSessionRecord {
22874 record_id: SessionRecordId::new(record_id).unwrap(),
22875 display_name: format!("{record_id} display"),
22876 provider: agent("claude"),
22877 mode: SessionMode::Pty,
22878 state,
22879 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22880 canonical_root: opaque_windows_path(
22881 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22882 ),
22883 provider_session: None,
22884 active_session: None,
22885 environment_profile: None,
22886 bundle: None,
22887 context_id: None,
22888 context: None,
22889 exported_context: None,
22890 task_binding: None,
22891 created_at_unix_ms: updated_at_unix_ms,
22892 updated_at_unix_ms,
22893 last_error: None,
22894 }
22895 }
22896
22897 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22905 vec![
22906 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22907 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22908 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22909 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22910 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22911 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22912 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22913 retention_test_record(
22914 "workspace-a",
22915 "decoy-pending",
22916 ManagedSessionState::IdentityPending,
22917 now - 500_000,
22918 ),
22919 retention_test_record(
22920 "workspace-a",
22921 "decoy-dormant",
22922 ManagedSessionState::Dormant,
22923 now - 500_000,
22924 ),
22925 ]
22926 }
22927
22928 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22929 let root = temporary_workspace_root("session-record-retention");
22930 std::fs::create_dir_all(&root).unwrap();
22931 let catalog = active_registry().unwrap();
22932 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22933 let workspace_a =
22934 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22935 let workspace_b =
22936 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22937 let mut shared = NodeShared::new(
22938 handle,
22939 "fixture-token".to_owned(),
22940 NodeId::new("node-retention-test").unwrap(),
22941 vec![workspace_a, workspace_b],
22942 vec![agent("claude")],
22943 );
22944 shared.state_path = Some(root.join("node-state-v10.json"));
22945 let now = unix_time_ms();
22946 for record in retention_fixture_records(now) {
22947 shared.insert_record(record).unwrap();
22948 }
22949 shared
22950 .persist_state_locked()
22951 .expect("fixture-only, empty-history persist against a fresh temp state path");
22952 (shared, runtime, root, now)
22953 }
22954
22955 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22956 shared
22957 .history
22958 .lock()
22959 .unwrap_or_else(|poisoned| poisoned.into_inner())
22960 .events
22961 .iter()
22962 .filter_map(|envelope| match &envelope.event {
22963 NodeEvent::SessionRecordRemoved { record_id } => {
22964 Some(record_id.as_str().to_owned())
22965 }
22966 _ => None,
22967 })
22968 .collect()
22969 }
22970
22971 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22972 shared
22973 .session_records
22974 .lock()
22975 .unwrap_or_else(|poisoned| poisoned.into_inner())
22976 .records
22977 .keys()
22978 .map(|record_id| record_id.as_str().to_owned())
22979 .collect()
22980 }
22981
22982 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22983 values.iter().map(|value| (*value).to_owned()).collect()
22984 }
22985
22986 #[tokio::test]
22987 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22988 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22989 shared.session_record_retention = SessionRecordRetentionConfig {
22990 age_ms: 0,
22991 keep_per_workspace: 1,
22992 };
22993 shared.retire_unavailable_session_records().await;
22994
22995 assert_eq!(
22996 retention_remaining_record_ids(&shared),
22997 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22998 );
22999 assert_eq!(
23000 retention_removed_record_ids(&shared),
23001 owned(&["a1", "a2", "b1", "b2"]),
23002 );
23003 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23004
23005 drop(shared);
23006 drop(runtime);
23007 std::fs::remove_dir_all(root).unwrap();
23008 }
23009
23010 #[tokio::test]
23016 async fn session_record_retention_both_set_retires_only_the_intersection() {
23017 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23018 shared.session_record_retention = SessionRecordRetentionConfig {
23019 age_ms: 250_000,
23020 keep_per_workspace: 1,
23021 };
23022 shared.retire_unavailable_session_records().await;
23023
23024 assert_eq!(
23025 retention_remaining_record_ids(&shared),
23026 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23027 );
23028 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23029 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23030
23031 drop(shared);
23032 drop(runtime);
23033 std::fs::remove_dir_all(root).unwrap();
23034 }
23035
23036 #[tokio::test]
23037 async fn session_record_retention_both_disabled_is_a_no_op() {
23038 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23039 shared.session_record_retention = SessionRecordRetentionConfig::default();
23040 shared.retire_unavailable_session_records().await;
23041
23042 assert_eq!(
23043 retention_remaining_record_ids(&shared),
23044 owned(&[
23045 "a1", "a2", "a3", "b1", "b2", "b3",
23046 "decoy-live", "decoy-pending", "decoy-dormant",
23047 ]),
23048 );
23049 assert!(retention_removed_record_ids(&shared).is_empty());
23050 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23051
23052 drop(shared);
23053 drop(runtime);
23054 std::fs::remove_dir_all(root).unwrap();
23055 }
23056
23057 #[test]
23058 fn session_record_context_export_selects_one_exact_history_identity() {
23059 let candidates = vec![
23060 HistoryCandidateSummary {
23061 id: "candidate-other".to_owned(),
23062 session_id_hint: "provider-other".to_owned(),
23063 modified_at_unix_ms: Some(1),
23064 },
23065 HistoryCandidateSummary {
23066 id: "candidate-exact".to_owned(),
23067 session_id_hint: "provider-exact".to_owned(),
23068 modified_at_unix_ms: Some(2),
23069 },
23070 ];
23071 assert_eq!(
23072 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23073 "candidate-exact",
23074 );
23075 }
23076
23077 #[test]
23078 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23079 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23080 id: id.to_owned(),
23081 session_id_hint: hint.to_owned(),
23082 modified_at_unix_ms: None,
23083 };
23084 let missing = select_history_candidate(
23085 &[candidate("candidate-other", "provider-other")],
23086 Some("provider-exact"),
23087 )
23088 .unwrap_err();
23089 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23090 let ambiguous = select_history_candidate(
23091 &[
23092 candidate("candidate-a", "provider-exact"),
23093 candidate("candidate-b", "provider-exact"),
23094 ],
23095 Some("provider-exact"),
23096 )
23097 .unwrap_err();
23098 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23099 }
23100
23101 #[test]
23109 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23110 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23111 id: id.to_owned(),
23112 session_id_hint: hint.to_owned(),
23113 modified_at_unix_ms: None,
23114 };
23115 assert_eq!(
23116 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23117 .unwrap(),
23118 "candidate-sole",
23119 );
23120 let missing = select_history_candidate(&[], None).unwrap_err();
23121 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23122 let ambiguous = select_history_candidate(
23123 &[
23124 candidate("candidate-a", "sidecar-session-a"),
23125 candidate("candidate-b", "sidecar-session-b"),
23126 ],
23127 None,
23128 )
23129 .unwrap_err();
23130 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23131 }
23132
23133 #[test]
23134 fn session_record_context_export_revalidation_rejects_record_change() {
23135 let session = terminal_address(1);
23136 let identity = ProviderSessionIdentity {
23137 key: ProviderSessionKey::SessionId,
23138 id: "provider-exact".to_owned(),
23139 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23140 };
23141 let mut expected = record("claude", "record-context-export");
23142 expected.state = ManagedSessionState::Live;
23143 expected.provider_session = Some(identity.clone());
23144 expected.active_session = Some(session.clone());
23145 let binding = SessionBinding {
23146 workspace_id: session.workspace_id.clone(),
23147 generation: session.session.generation,
23148 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23149 pending_resume: None,
23150 record_id: Some(expected.record_id.clone()),
23151 managed_worktree_lease_id: None,
23152 environment_profile: None,
23153 bundle: None,
23154 context: None,
23155 materialization_id: None,
23156 };
23157 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23158 assert!(session_record_context_export_binding_is_exact(
23159 &expected,
23160 &expected,
23161 Some(&identity),
23162 &session,
23163 &binding,
23164 &expected.provider,
23165 ¤t_root,
23166 false,
23167 ));
23168 let mut changed = expected.clone();
23169 changed.updated_at_unix_ms += 1;
23170 assert!(!session_record_context_export_binding_is_exact(
23171 &expected,
23172 &changed,
23173 Some(&identity),
23174 &session,
23175 &binding,
23176 &expected.provider,
23177 ¤t_root,
23178 false,
23179 ));
23180 assert!(!session_record_context_export_binding_is_exact(
23185 &expected,
23186 &changed,
23187 Some(&identity),
23188 &session,
23189 &binding,
23190 &expected.provider,
23191 ¤t_root,
23192 true,
23193 ));
23194 let mut detached = expected.clone();
23195 detached.state = ManagedSessionState::Dormant;
23196 detached.active_session = None;
23197 detached.updated_at_unix_ms += 1;
23198 assert!(!session_record_context_export_binding_is_exact(
23199 &expected,
23200 &detached,
23201 Some(&identity),
23202 &session,
23203 &binding,
23204 &expected.provider,
23205 ¤t_root,
23206 false,
23207 ));
23208 assert!(session_record_context_export_binding_is_exact(
23209 &expected,
23210 &detached,
23211 Some(&identity),
23212 &session,
23213 &binding,
23214 &expected.provider,
23215 ¤t_root,
23216 true,
23217 ));
23218 }
23219
23220 #[test]
23227 fn session_record_context_export_binding_is_exact_without_identity() {
23228 let session = terminal_address(1);
23229 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23230 expected.state = ManagedSessionState::Live;
23231 expected.active_session = Some(session.clone());
23232 let binding = SessionBinding {
23233 workspace_id: session.workspace_id.clone(),
23234 generation: session.session.generation,
23235 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23236 pending_resume: None,
23237 record_id: Some(expected.record_id.clone()),
23238 managed_worktree_lease_id: None,
23239 environment_profile: None,
23240 bundle: None,
23241 context: None,
23242 materialization_id: None,
23243 };
23244 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23245 assert!(session_record_context_export_binding_is_exact(
23246 &expected,
23247 &expected,
23248 None,
23249 &session,
23250 &binding,
23251 &expected.provider,
23252 ¤t_root,
23253 false,
23254 ));
23255 let mut drifted = expected.clone();
23259 drifted.provider_session = Some(ProviderSessionIdentity {
23260 key: ProviderSessionKey::SessionId,
23261 id: "unexpected-identity".to_owned(),
23262 transcript_path: None,
23263 });
23264 assert!(!session_record_context_export_binding_is_exact(
23265 &expected,
23266 &drifted,
23267 None,
23268 &session,
23269 &binding,
23270 &expected.provider,
23271 ¤t_root,
23272 false,
23273 ));
23274 }
23275
23276 #[test]
23277 fn session_record_context_export_preflight_requires_live_usable_source() {
23278 let mut source = record("claude", "record-context-export-preflight");
23279 source.state = ManagedSessionState::Live;
23280 assert!(session_record_context_export_source_is_usable(
23281 &source,
23282 &SessionStatus::Running,
23283 false,
23284 ));
23285 assert!(session_record_context_export_source_is_usable(
23286 &source,
23287 &SessionStatus::Exited { exit_code: Some(0) },
23288 false,
23289 ));
23290
23291 source.state = ManagedSessionState::Dormant;
23292 assert!(!session_record_context_export_source_is_usable(
23293 &source,
23294 &SessionStatus::Running,
23295 false,
23296 ));
23297 assert!(session_record_context_export_source_is_usable(
23301 &source,
23302 &SessionStatus::Exited { exit_code: Some(0) },
23303 true,
23304 ));
23305 assert!(!session_record_context_export_source_is_usable(
23306 &source,
23307 &SessionStatus::Exited { exit_code: Some(0) },
23308 false,
23309 ));
23310 source.state = ManagedSessionState::Live;
23311 assert!(!session_record_context_export_source_is_usable(
23312 &source,
23313 &SessionStatus::Starting,
23314 false,
23315 ));
23316 }
23317
23318 #[test]
23319 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23320 let catalog = active_registry().unwrap();
23321 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23322 let workspace = WorkspaceConfig::new(
23323 WorkspaceId::new("primary").unwrap(),
23324 std::env::current_dir().unwrap(),
23325 )
23326 .unwrap();
23327 let shared = NodeShared::new(
23328 handle,
23329 "fixture-token".to_owned(),
23330 NodeId::new("node-context-export-conflict").unwrap(),
23331 vec![workspace],
23332 vec![agent("claude")],
23333 );
23334 let history = HistorySessionRecord {
23335 session_id: "provider-exact".to_owned(),
23336 title: None,
23337 cwd: None,
23338 model: None,
23339 message_count: 1,
23340 completed_turn_count: None,
23341 total_tokens: 0,
23342 messages: vec![gate4agent_types::HistoryMessageRecord {
23343 role: gate4agent_types::HistoryMessageRole::User,
23344 text: "bounded context".to_owned(),
23345 }],
23346 };
23347 let pack = NodeContextPack::export(
23348 ContextPackLineageReceipt {
23349 source_node_id: shared.node_id.clone(),
23350 source_session: terminal_address(1),
23351 source_provider: agent("claude"),
23352 },
23353 &history,
23354 )
23355 .unwrap();
23356 let context_id = pack.receipt().id.clone();
23357 let session = terminal_address(1);
23358 let identity = ProviderSessionIdentity {
23359 key: ProviderSessionKey::SessionId,
23360 id: history.session_id.clone(),
23361 transcript_path: None,
23362 };
23363 let mut expected = record("claude", "record-context-export-final-conflict");
23364 expected.state = ManagedSessionState::Live;
23365 expected.provider_session = Some(identity.clone());
23366 expected.active_session = Some(session.clone());
23367 let mut changed = expected.clone();
23368 changed.updated_at_unix_ms += 1;
23369 shared.insert_record(changed).unwrap();
23370
23371 let error = shared
23372 .commit_context_pack_for_session_record(
23373 &expected,
23374 Some(&identity),
23375 &session,
23376 "candidate-exact",
23377 &history,
23378 pack,
23379 false,
23380 )
23381 .unwrap_err();
23382
23383 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23384 assert!(shared
23385 .context_catalog
23386 .read()
23387 .unwrap_or_else(|poisoned| poisoned.into_inner())
23388 .get(&context_id)
23389 .is_none());
23390 }
23391
23392 #[tokio::test]
23393 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23394 let root = temporary_workspace_root("session-task-binding");
23395 std::fs::create_dir_all(&root).unwrap();
23396 let state_path = root.join("node-state.json");
23397 let catalog = active_registry().unwrap();
23398 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23399 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23400 let node_id = NodeId::new("node-1").unwrap();
23401 let mut shared = NodeShared::new(
23402 handle,
23403 "fixture-token".to_owned(),
23404 node_id.clone(),
23405 vec![workspace],
23406 vec![agent("claude")],
23407 );
23408 shared.state_path = Some(state_path.clone());
23409 let record = record("claude", "sr-task");
23410 let record_id = record.record_id.clone();
23411 shared.insert_record(record).unwrap();
23412 shared.persist_state().unwrap();
23413 shared
23414 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23415 .unwrap();
23416
23417 let minted = process_request_inner(
23418 &shared,
23419 77,
23420 ClientRole::Operator,
23421 NodeRequest::SetSessionTask {
23422 record_id: record_id.clone(),
23423 expected_revision: 0,
23424 target: SessionTaskTargetV1::New,
23425 },
23426 )
23427 .await
23428 .unwrap();
23429 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23430 panic!("session task mutation returned another response");
23431 };
23432 let minted_binding = minted.task_binding.clone().unwrap();
23433 let minted_task_id = minted_binding.task_id.clone().unwrap();
23434 assert_eq!(minted_binding.revision, 1);
23435 assert!(minted_task_id.as_str().starts_with("task-"));
23436 assert_eq!(minted_task_id.as_str().len(), 29);
23437
23438 let event_count = shared.current_sequence();
23439 let repeated = process_request_inner(
23440 &shared,
23441 77,
23442 ClientRole::Operator,
23443 NodeRequest::SetSessionTask {
23444 record_id: record_id.clone(),
23445 expected_revision: 1,
23446 target: SessionTaskTargetV1::Existing {
23447 task_id: minted_task_id.clone(),
23448 },
23449 },
23450 )
23451 .await
23452 .unwrap();
23453 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23454 assert_eq!(shared.current_sequence(), event_count);
23455
23456 let stale = process_request_inner(
23457 &shared,
23458 77,
23459 ClientRole::Operator,
23460 NodeRequest::SetSessionTask {
23461 record_id: record_id.clone(),
23462 expected_revision: 0,
23463 target: SessionTaskTargetV1::Existing {
23464 task_id: minted_task_id,
23465 },
23466 },
23467 )
23468 .await
23469 .unwrap_err();
23470 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23471
23472 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23473 let assigned = process_request_inner(
23474 &shared,
23475 77,
23476 ClientRole::Operator,
23477 NodeRequest::SetSessionTask {
23478 record_id: record_id.clone(),
23479 expected_revision: 1,
23480 target: SessionTaskTargetV1::Existing {
23481 task_id: assigned_id.clone(),
23482 },
23483 },
23484 )
23485 .await
23486 .unwrap();
23487 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23488 panic!("session task assignment returned another response");
23489 };
23490 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23491 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23492
23493 let cleared = process_request_inner(
23494 &shared,
23495 77,
23496 ClientRole::Operator,
23497 NodeRequest::SetSessionTask {
23498 record_id: record_id.clone(),
23499 expected_revision: 2,
23500 target: SessionTaskTargetV1::Clear,
23501 },
23502 )
23503 .await
23504 .unwrap();
23505 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23506 panic!("session task clear returned another response");
23507 };
23508 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23509 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23510 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23511 assert_eq!(
23512 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23513 cleared.updated_at_unix_ms,
23514 );
23515
23516 let persisted = std::fs::read(&state_path).unwrap();
23517 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23518 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23519 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23520 std::fs::remove_dir_all(root).unwrap();
23521 }
23522
23523 #[test]
23524 fn session_task_capability_strips_snapshots_events_and_replies() {
23525 let mut bound = record("claude", "sr-task-projection");
23526 bound.updated_at_unix_ms = 2;
23527 bound.task_binding = Some(SessionTaskBindingV1 {
23528 revision: 1,
23529 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23530 changed_at_unix_ms: 2,
23531 });
23532 let request = NodeRequest::SetSessionTask {
23533 record_id: bound.record_id.clone(),
23534 expected_revision: 1,
23535 target: SessionTaskTargetV1::Clear,
23536 };
23537 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23538 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23539 }));
23540 assert!(!request_uses_unnegotiated_capability(
23541 &request,
23542 &baseline_capabilities().unwrap(),
23543 ));
23544 assert!(request_uses_unnegotiated_capability(&request, &[]));
23545
23546 let mut snapshot = terminal_test_shared().snapshot();
23547 snapshot.session_records.push(bound.clone());
23548 let mut reply = ResponseEnvelope {
23549 request_id: 1,
23550 result: Ok(NodeResponse::Resync {
23551 event_sequence: 1,
23552 oldest_available_sequence: 1,
23553 snapshot,
23554 events: vec![NodeEventEnvelope {
23555 sequence: 1,
23556 event: NodeEvent::SessionRecordUpserted {
23557 record: bound.clone(),
23558 },
23559 }],
23560 }),
23561 };
23562 project_response_without_session_task_binding(&mut reply);
23563 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23564 panic!("task projection changed the response kind");
23565 };
23566 assert!(snapshot.session_records[0].task_binding.is_none());
23567 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23568 panic!("task projection changed the event kind");
23569 };
23570 assert!(record.task_binding.is_none());
23571
23572 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23573 sequence: 2,
23574 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23575 });
23576 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23577 panic!("task event projection changed the event kind");
23578 };
23579 assert!(record.task_binding.is_none());
23580
23581 let mut direct = ResponseEnvelope {
23582 request_id: 2,
23583 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23584 };
23585 project_response_without_session_task_binding(&mut direct);
23586 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23587 panic!("task reply projection changed the response kind");
23588 };
23589 assert!(record.task_binding.is_none());
23590 }
23591
23592 #[test]
23593 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23594 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23595 let workspace = WorkspaceConfig::new(
23596 WorkspaceId::new("primary").unwrap(),
23597 std::env::current_dir().unwrap(),
23598 )
23599 .unwrap();
23600 let config = NodeServerConfig::new(
23601 DEFAULT_NODE_ENDPOINT,
23602 "fixture-token",
23603 NodeId::new("node-1").unwrap(),
23604 [workspace],
23605 )
23606 .unwrap();
23607 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23608 }
23609
23610 #[cfg(all(feature = "fixture", windows))]
23611 #[test]
23612 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23613 let root = temporary_workspace_root("provider-bundle-argv-hold");
23614 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23615 std::fs::create_dir_all(&root).unwrap();
23616 std::fs::create_dir_all(&outside).unwrap();
23617 let proof_path = root.join("provider.proof");
23618 let release_signal = root.join("release.signal");
23619 let config = NodeServerConfig::new(
23620 format!(
23621 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23622 std::process::id(),
23623 ),
23624 "fixture-token",
23625 NodeId::new("provider-bundle-hold-node").unwrap(),
23626 [WorkspaceConfig::new(
23627 WorkspaceId::new("primary").unwrap(),
23628 std::env::current_dir().unwrap(),
23629 )
23630 .unwrap()],
23631 )
23632 .unwrap()
23633 .with_spawn_profiles(SpawnProfileRegistry::new([
23634 crate::protocol::SpawnProfileDefaults {
23635 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23636 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23637 .unwrap(),
23638 provider: agent("kimi"),
23639 mode: SessionMode::Pty,
23640 terminal_size: gate4agent_types::TerminalSize {
23641 rows: 24,
23642 columns: 80,
23643 },
23644 prompt: None,
23645 bundle_id: None,
23646 context_id: None,
23647 environment_profile_id: None,
23648 },
23649 ])
23650 .unwrap());
23651 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23652 config,
23653 agent("kimi"),
23654 proof_path.clone(),
23655 release_signal.clone(),
23656 )
23657 .unwrap();
23658 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23659
23660 let spec = NodeServer::provider_bundle_argv_fixture_spec(
23661 agent("kimi"),
23662 proof_path.clone(),
23663 Some(release_signal.clone()),
23664 )
23665 .unwrap();
23666 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23667 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23668 let release_wait = wrapper
23669 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23670 .unwrap();
23671 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23672 assert!(proof_write < release_wait);
23673 assert!(release_wait < fixture_ready);
23674 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23675 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23676 assert_eq!(
23677 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23678 &[
23679 proof_path.to_string_lossy().into_owned(),
23680 release_signal.to_string_lossy().into_owned(),
23681 ],
23682 );
23683
23684 let error = NodeServer::provider_bundle_argv_fixture_spec(
23685 agent("kimi"),
23686 proof_path,
23687 Some(outside.join("release.signal")),
23688 )
23689 .unwrap_err();
23690 assert!(matches!(
23691 error,
23692 NodeServerError::Registry(message)
23693 if message == "provider bundle argv release path must share the proof parent"
23694 ));
23695 drop(server);
23696 std::fs::remove_dir_all(root).unwrap();
23697 std::fs::remove_dir_all(outside).unwrap();
23698 }
23699
23700 #[cfg(feature = "fixture")]
23701 #[test]
23702 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23703 let root = temporary_workspace_root("context-fixture-registry");
23704 std::fs::create_dir_all(&root).unwrap();
23705 let proof_path = root.join("context.proof");
23706 let config = NodeServerConfig::new(
23707 format!(
23708 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23709 std::process::id(),
23710 ),
23711 "fixture-token",
23712 NodeId::new("context-fixture-node").unwrap(),
23713 [WorkspaceConfig::new(
23714 WorkspaceId::new("primary").unwrap(),
23715 std::env::current_dir().unwrap(),
23716 )
23717 .unwrap()],
23718 )
23719 .unwrap();
23720 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23721 let mut enabled = server
23722 .shared
23723 .enabled_providers
23724 .iter()
23725 .map(AgentId::as_str)
23726 .collect::<Vec<_>>();
23727 enabled.sort_unstable();
23728 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23729 for provider in &server.shared.enabled_providers {
23730 assert!(server
23731 .shared
23732 .provider_adapter_contracts
23733 .iter()
23734 .any(|contract| {
23735 &contract.provider == provider
23736 && contract.family == AdapterFamily::History
23737 }));
23738 }
23739 drop(server);
23740 std::fs::remove_dir_all(root).unwrap();
23741 }
23742
23743 #[cfg(feature = "fixture")]
23744 #[test]
23745 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23746 let root = temporary_workspace_root("monitoring-context-fixture");
23747 std::fs::create_dir_all(&root).unwrap();
23748 let proof_path = root.join("context.proof");
23749 let standard = NodeServer::context_pack_fixture_catalog(
23750 proof_path.clone(),
23751 None,
23752 false,
23753 ).unwrap();
23754 let monitoring = NodeServer::context_pack_fixture_catalog(
23755 proof_path.clone(),
23756 None,
23757 true,
23758 ).unwrap();
23759 let claude = AgentId::new("claude").unwrap();
23760 let standard_claude = standard.get(&claude).unwrap();
23761 let monitoring_claude = monitoring.get(&claude).unwrap();
23762 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23766 assert_eq!(
23767 monitoring_claude.capabilities.adapters.history,
23768 standard_claude.capabilities.adapters.history,
23769 );
23770 assert_ne!(monitoring_claude.launch, standard_claude.launch);
23771 let codex = AgentId::new("codex").unwrap();
23772 let standard_codex = standard.get(&codex).unwrap();
23773 let monitoring_codex = monitoring.get(&codex).unwrap();
23774 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23775 assert_eq!(
23776 monitoring_codex.capabilities.adapters.history,
23777 standard_codex.capabilities.adapters.history,
23778 );
23779 assert_ne!(monitoring_codex.launch, standard_codex.launch);
23780 let codex_validation = monitoring_codex.launch.fixed_args.iter()
23781 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23782 .unwrap();
23783 for required in [
23784 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23785 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23786 "@('role', 'text')",
23787 "g4a-private-provider-session-canary",
23788 "private-provider-login-identity",
23789 "$rawProof",
23790 "$env:GATE4AGENT_HOOK_URL",
23791 "g4a-private-provider-session-canary",
23792 ] {
23793 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23794 }
23795 for provider in ["grok", "kimi"] {
23796 let provider = AgentId::new(provider).unwrap();
23797 assert_eq!(monitoring.get(&provider), standard.get(&provider));
23798 }
23799
23800 let config = NodeServerConfig::new(
23801 format!(
23802 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23803 std::process::id(),
23804 ),
23805 "fixture-token",
23806 NodeId::new("monitoring-context-fixture-node").unwrap(),
23807 [WorkspaceConfig::new(
23808 WorkspaceId::new("primary").unwrap(),
23809 std::env::current_dir().unwrap(),
23810 )
23811 .unwrap()],
23812 )
23813 .unwrap();
23814 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23815 assert!(server.shared.fixture_semantic_hook_policy);
23816 drop(server);
23817 std::fs::remove_dir_all(root).unwrap();
23818 }
23819
23820 #[cfg(feature = "fixture")]
23821 #[test]
23822 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23823 let root = temporary_workspace_root("kimi-context-only-fixture");
23824 std::fs::create_dir_all(&root).unwrap();
23825 let proof_path = root.join("context.proof");
23826 let standard = NodeServer::context_pack_fixture_catalog(
23827 proof_path.clone(),
23828 None,
23829 false,
23830 ).unwrap();
23831 let kimi = AgentId::new("kimi").unwrap();
23832 let context_only = NodeServer::context_pack_fixture_catalog(
23833 proof_path,
23834 Some(&kimi),
23835 false,
23836 )
23837 .unwrap();
23838
23839 let codex = AgentId::new("codex").unwrap();
23840 assert_eq!(
23841 standard.get(&codex).unwrap().launch.fixed_args,
23842 context_only.get(&codex).unwrap().launch.fixed_args,
23843 );
23844 let script = context_only
23845 .get(&kimi)
23846 .unwrap()
23847 .launch
23848 .fixed_args
23849 .iter()
23850 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23851 .expect("Kimi context-only validation script is missing");
23852 for required in [
23853 "$bundleArgs.Count -ne 0",
23854 "GATE4AGENT_CONTEXT_ROOT",
23855 "context-pack.json",
23856 "$entries.Count -ne 1",
23857 "g4a-context-pack-v1",
23858 "$document.PSObject.Properties['cwd']",
23859 "'claude', 'codex', 'grok', 'kimi'",
23860 "$hasUser",
23861 "$hasAssistant",
23862 "SHA256",
23863 "context-only",
23864 ] {
23865 assert!(script.contains(required), "missing validation: {required}");
23866 }
23867 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23868 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23869 }
23870 assert!(!standard
23871 .get(&kimi)
23872 .unwrap()
23873 .launch
23874 .fixed_args
23875 .iter()
23876 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23877
23878 std::fs::remove_dir_all(root).unwrap();
23879 }
23880
23881 #[cfg(all(feature = "fixture", windows))]
23882 #[tokio::test]
23883 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23884 let root = temporary_workspace_root("kimi-context-only-spawn");
23885 let workspace_root = root.join("workspace");
23886 std::fs::create_dir_all(&workspace_root).unwrap();
23887 let materialization_root = root.join("private-materializations");
23888 let proof_path = root.join("context.proof");
23889 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23890 let workspace_id = WorkspaceId::new("primary").unwrap();
23891 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23892 let endpoint = format!(
23893 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23894 std::process::id(),
23895 unix_time_ms(),
23896 );
23897 let spawn_profiles = SpawnProfileRegistry::new([
23898 crate::protocol::SpawnProfileDefaults {
23899 profile_id: profile_id.clone(),
23900 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23901 provider: agent("kimi"),
23902 mode: SessionMode::Pty,
23903 terminal_size: gate4agent_types::TerminalSize {
23904 rows: 30,
23905 columns: 120,
23906 },
23907 prompt: None,
23908 bundle_id: None,
23909 context_id: None,
23910 environment_profile_id: None,
23911 },
23912 ])
23913 .unwrap();
23914 let mut config = NodeServerConfig::new(
23915 &endpoint,
23916 "fixture-token",
23917 node_id.clone(),
23918 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23919 )
23920 .unwrap()
23921 .with_spawn_profiles(spawn_profiles)
23922 .with_session_environment_materialization(
23923 materialization_root,
23924 Arc::new(FixtureSecretResolver {
23925 deny: Arc::new(AtomicBool::new(false)),
23926 }),
23927 )
23928 .unwrap();
23929 config.fixture_raw_pty_runtime = true;
23930 let server = NodeServer::new_context_only_proof_fixture(
23931 config,
23932 agent("kimi"),
23933 proof_path.clone(),
23934 )
23935 .unwrap();
23936 let shared = Arc::clone(&server.shared);
23937 let shutdown = server.shutdown_handle();
23938
23939 let history = HistorySessionRecord {
23940 session_id: "context-only-source-history".to_owned(),
23941 title: None,
23942 cwd: None,
23943 model: None,
23944 message_count: 2,
23945 completed_turn_count: None,
23946 total_tokens: 0,
23947 messages: vec![
23948 gate4agent_types::HistoryMessageRecord {
23949 role: gate4agent_types::HistoryMessageRole::User,
23950 text: "continue the bounded parent run through native C2".to_owned(),
23951 },
23952 gate4agent_types::HistoryMessageRecord {
23953 role: gate4agent_types::HistoryMessageRole::Assistant,
23954 text: "the exact parent context is ready for the child run".to_owned(),
23955 },
23956 ],
23957 };
23958 let pack = NodeContextPack::export(
23959 ContextPackLineageReceipt {
23960 source_node_id: node_id.clone(),
23961 source_session: SessionAddress {
23962 workspace_id: workspace_id.clone(),
23963 session: SessionKey {
23964 instance_id: AgentInstanceId(71),
23965 generation: SessionGeneration(1),
23966 },
23967 },
23968 source_provider: agent("codex"),
23969 },
23970 &history,
23971 )
23972 .unwrap();
23973 let expected_context_hash = digest(&SHA256, pack.bytes())
23974 .as_ref()
23975 .iter()
23976 .map(|byte| format!("{byte:02x}"))
23977 .collect::<String>();
23978 let context = shared
23979 .context_catalog
23980 .write()
23981 .unwrap_or_else(|poisoned| poisoned.into_inner())
23982 .insert(pack)
23983 .unwrap();
23984 let spec = SpawnSpec {
23985 target: crate::protocol::SpawnTarget {
23986 node_id,
23987 workspace_id: workspace_id.clone(),
23988 worktree_id: None,
23989 },
23990 profile_id,
23991 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23992 "target-kimi-r1",
23993 ).unwrap(),
23994 overrides: crate::protocol::SpawnOverrides {
23995 context_id: crate::protocol::SpawnOverride::Set {
23996 value: context.id.clone(),
23997 },
23998 ..crate::protocol::SpawnOverrides::default()
23999 },
24000 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24001 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24002 required_capabilities: SpawnRequiredCapabilities::default(),
24003 };
24004 let resolved = match shared.resolve_spawn_spec(&spec) {
24005 Ok(resolved) => resolved,
24006 Err(error) => panic!(
24007 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24008 error.code,
24009 error.message,
24010 ),
24011 };
24012 let resolved_context = match shared.resolve_context(&resolved) {
24013 Ok(Some(resolved_context)) => resolved_context,
24014 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24015 Err(error) => panic!(
24016 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24017 error.code,
24018 error.message,
24019 ),
24020 };
24021 assert_eq!(resolved_context, context);
24022 let runtime_policy = match shared
24023 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24024 .await
24025 {
24026 Ok(runtime_policy) => runtime_policy,
24027 Err(error) => panic!(
24028 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24029 error.code,
24030 error.message,
24031 ),
24032 };
24033 assert!(runtime_policy.raw_pty_lifecycle);
24034
24035 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24036 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24037 agent("kimi"),
24038 TransportKind::Pty,
24039 vec![EnvMutation {
24040 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24041 value: Some(OsString::from("must-remain-rejected")),
24042 }],
24043 )
24044 .unwrap();
24045 assert_eq!(
24046 launch_control
24047 .install_native_launch_environment_overlay(
24048 AgentInstanceId(72),
24049 generic_overlay,
24050 )
24051 .unwrap_err(),
24052 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24053 );
24054
24055 let diagnostic_address = SessionAddress {
24056 workspace_id: workspace_id.clone(),
24057 session: SessionKey {
24058 instance_id: AgentInstanceId(73),
24059 generation: SessionGeneration(1),
24060 },
24061 };
24062 let (diagnostic_overlay, diagnostic_materialization) = match shared
24063 .prepare_session_materialization(
24064 &diagnostic_address,
24065 &agent("kimi"),
24066 SessionMode::Pty,
24067 None,
24068 None,
24069 Some(&resolved_context),
24070 None,
24071 )
24072 {
24073 Ok(Some(prepared)) => prepared,
24074 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24075 Err(error) => panic!(
24076 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24077 error.code,
24078 error.message,
24079 ),
24080 };
24081 let diagnostic_overlay = diagnostic_overlay
24082 .expect("Kimi context-only materialization omitted its environment overlay");
24083 assert!(matches!(
24084 &diagnostic_overlay,
24085 PreparedNativeLaunchOverlay::Environment(_),
24086 ));
24087 match shared.install_prepared_launch_overlay(
24088 diagnostic_address.session.instance_id,
24089 diagnostic_overlay,
24090 ) {
24091 Ok(None) => {}
24092 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24093 Err(error) => panic!(
24094 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24095 error.code,
24096 error.message,
24097 ),
24098 }
24099 assert!(launch_control.clear_native_instance_launch_overlay(
24100 diagnostic_address.session.instance_id,
24101 ));
24102 drop(diagnostic_materialization);
24103 assert!(shared
24104 .materializations
24105 .lock()
24106 .unwrap_or_else(|poisoned| poisoned.into_inner())
24107 .is_empty());
24108
24109 let node_task = tokio::spawn(server.run());
24110 let ready_client = timeout(Duration::from_secs(5), async {
24111 loop {
24112 match gate4agent_node_wire::LocalNodeClient::connect(
24113 &endpoint,
24114 &shared.node_id,
24115 ClientRole::Observer,
24116 "fixture-token",
24117 )
24118 .await
24119 {
24120 Ok(client) => break client,
24121 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24122 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24123 }
24124 }
24125 })
24126 .await
24127 .expect("Kimi context-only Node did not become ready");
24128 assert_eq!(
24129 ready_client
24130 .hello()
24131 .snapshot
24132 .provider_runtime_statuses
24133 .iter()
24134 .find(|status| status.provider().as_str() == "kimi")
24135 .expect("Kimi runtime status is missing")
24136 .mode(),
24137 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24138 );
24139 drop(ready_client);
24140
24141 let receipt = match shared.spawn_from_spec(spec).await {
24142 Ok(receipt) => receipt,
24143 Err(error) => panic!(
24144 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24145 error.code,
24146 error.message,
24147 ),
24148 };
24149 assert_eq!(receipt.provider, agent("kimi"));
24150 assert_eq!(receipt.context.as_ref(), Some(&context));
24151 assert!(receipt.context_binding_is_valid());
24152 timeout(Duration::from_secs(5), async {
24153 while !proof_path.is_file() {
24154 sleep(Duration::from_millis(10)).await;
24155 }
24156 })
24157 .await
24158 .expect("Kimi context-only fixture did not write its proof");
24159
24160 let proof = std::fs::read_to_string(&proof_path).unwrap();
24161 let lines = proof.lines().collect::<Vec<_>>();
24162 assert_eq!(lines.len(), 7);
24163 assert_eq!(lines[0], "context-only");
24164 assert!(Path::new(lines[1]).is_absolute());
24165 assert_ne!(
24166 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24167 std::fs::canonicalize(&workspace_root).unwrap(),
24168 );
24169 assert_eq!(
24170 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24171 std::fs::canonicalize(&workspace_root).unwrap(),
24172 );
24173 assert_eq!(lines[3], expected_context_hash);
24174 assert_eq!(lines[4], "g4a-context-pack-v1");
24175 assert_eq!(lines[5], "codex");
24176 assert_eq!(lines[6], "2");
24177 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24178 session.instance_id == receipt.session.session.instance_id
24179 && session.agent_id.as_str() == "kimi"
24180 }));
24181
24182 shutdown.request_shutdown().await.unwrap();
24183 timeout(Duration::from_secs(5), node_task)
24184 .await
24185 .unwrap()
24186 .unwrap()
24187 .unwrap();
24188 drop(shutdown);
24189 drop(shared);
24190 std::fs::remove_dir_all(root).unwrap();
24191 }
24192
24193 #[cfg(feature = "fixture")]
24194 #[test]
24195 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24196 let root = temporary_workspace_root("clean-exit-constructor");
24197 std::fs::create_dir_all(&root).unwrap();
24198 let config = construction_test_config("clean-exit-constructor")
24199 .with_state_path(root.join("node-state.json"))
24200 .unwrap();
24201 let server = NodeServer::new_clean_exit_fixture(
24202 config,
24203 root.clone(),
24204 root.join("started.marker"),
24205 root.join("release.signal"),
24206 )
24207 .unwrap();
24208 assert_eq!(
24209 server
24210 .shared
24211 .enabled_providers
24212 .iter()
24213 .map(AgentId::as_str)
24214 .collect::<Vec<_>>(),
24215 ["claude"],
24216 );
24217 drop(server);
24218 std::fs::remove_dir_all(root).unwrap();
24219 }
24220
24221 #[test]
24222 fn production_registry_provider_contract_manifest_is_exact() {
24223 let registry = active_registry().unwrap();
24224 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24225 assert_eq!(
24226 providers
24227 .iter()
24228 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24229 .collect::<Vec<_>>(),
24230 vec![
24231 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24232 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24233 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24234 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24235 ]
24236 );
24237 assert_eq!(
24238 adapters
24239 .iter()
24240 .map(|contract| (
24241 contract.provider.as_str(),
24242 contract.family,
24243 contract.adapter_id.as_str(),
24244 contract.revision.as_str(),
24245 ))
24246 .collect::<Vec<_>>(),
24247 vec![
24248 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24249 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24250 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24251 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24252 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24253 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24254 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24255 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24256 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24257 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24258 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24259 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24260 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24261 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24262 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24263 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24264 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24265 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24266 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24267 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24268 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24269 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24270 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24271 ]
24272 );
24273 }
24274
24275 fn construction_test_config(name: &str) -> NodeServerConfig {
24276 let workspace = WorkspaceConfig::new(
24277 WorkspaceId::new("primary").unwrap(),
24278 std::env::current_dir().unwrap(),
24279 )
24280 .unwrap();
24281 NodeServerConfig::new(
24282 format!(r"\\.\pipe\gate4agent-{name}"),
24283 "fixture-token",
24284 NodeId::new(name).unwrap(),
24285 [workspace],
24286 )
24287 .unwrap()
24288 }
24289
24290 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24291 let adapter = AdapterBinding::new(
24292 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24293 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24294 gate4agent_types::AdapterVerification::SyntheticFixture,
24295 )
24296 .unwrap();
24297 let agent_ids = ["claude", "codex", "kimi"]
24298 .into_iter()
24299 .map(|agent_id| AgentId::new(agent_id).unwrap())
24300 .collect::<Vec<_>>();
24301 let adapters = gate4agent_catalog::AdapterRegistry::new(
24302 [
24303 AdapterFamily::PtySemantic,
24304 AdapterFamily::Pipe,
24305 AdapterFamily::OneShot,
24306 AdapterFamily::Acp,
24307 AdapterFamily::Hook,
24308 AdapterFamily::ManagedHook,
24309 AdapterFamily::History,
24310 AdapterFamily::Resume,
24311 AdapterFamily::SessionOptions,
24312 AdapterFamily::CapabilityProbe,
24313 ]
24314 .into_iter()
24315 .map(|family| gate4agent_catalog::AdapterDescriptor {
24316 family,
24317 binding: adapter.clone(),
24318 agents: agent_ids.clone(),
24319 }),
24320 )
24321 .unwrap();
24322 let specs = agent_ids
24323 .iter()
24324 .map(|agent_id| {
24325 let agent_id = agent_id.as_str();
24326 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24327 spec.revision =
24328 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24329 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24330 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24331 adapter: adapter.clone(),
24332 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24333 launch_override: None,
24334 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24335 });
24336 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24337 adapter: adapter.clone(),
24338 launch_override: None,
24339 });
24340 spec.capabilities.adapters.hook = Some(adapter.clone());
24341 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24342 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24343 spec.capabilities.adapters.history = Some(adapter.clone());
24344 spec.capabilities.adapters.resume = Some(adapter.clone());
24345 spec.capabilities.adapters.session_options = Some(adapter.clone());
24346 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24347 spec
24348 })
24349 .collect::<Vec<_>>();
24350 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24351 }
24352
24353 #[test]
24361 fn node_server_construction_accepts_the_production_manifest() {
24362 let registry = active_registry().unwrap();
24363 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24364 assert_eq!(providers.len(), registry.iter().count());
24365 assert!(!providers.is_empty());
24366 let server = NodeServer::new_with_registry(
24367 construction_test_config("manifest-production-bound"),
24368 registry,
24369 )
24370 .unwrap();
24371 drop(server);
24372 }
24373
24374 #[test]
24375 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24376 let registry = maximum_size_provider_manifest_registry();
24377 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24378 assert_eq!(providers.len(), 3);
24379 assert_eq!(adapters.len(), 30);
24380 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24381 let error = match NodeServer::new_with_registry(
24382 construction_test_config("manifest-handshake-overflow"),
24383 registry,
24384 ) {
24385 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24386 Err(error) => error,
24387 };
24388 assert!(matches!(
24389 error,
24390 NodeServerError::ProviderContractManifest(message)
24391 if message.contains("authentication binding length")
24392 && message.contains("8192-byte limit")
24393 ));
24394 }
24395
24396 #[test]
24397 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24398 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24399 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24400 let registry = AgentRegistry::new([spec]).unwrap();
24401 assert!(matches!(
24402 provider_contract_manifest(®istry),
24403 Err(NodeServerError::ProviderContractManifest(message))
24404 if message.contains("exceeds the 128-byte limit")
24405 ));
24406 }
24407
24408 #[test]
24409 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24410 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24411 spec.revision = "invalid revision".to_owned();
24412 let registry = AgentRegistry::new([spec]).unwrap();
24413 assert!(matches!(
24414 provider_contract_manifest(®istry),
24415 Err(NodeServerError::ProviderContractManifest(message))
24416 if message.contains("bounded lowercase ASCII")
24417 ));
24418 }
24419
24420 #[test]
24421 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24422 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24423 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24424 let conflicting_binding = builtin_registry()
24425 .get_by_id("codex")
24426 .unwrap()
24427 .capabilities
24428 .adapters
24429 .one_shot
24430 .clone()
24431 .unwrap();
24432 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24433 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24434 pipe.adapter = transport_binding;
24435 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24436 let registry = AgentRegistry::new([spec]).unwrap();
24437 assert!(matches!(
24438 provider_contract_manifest(®istry),
24439 Err(NodeServerError::ProviderContractManifest(message))
24440 if message.contains("conflicting OneShot adapter bindings")
24441 ));
24442 }
24443
24444 #[test]
24445 fn public_node_failures_expose_only_fixed_categories() {
24446 let provider = failure(
24447 NodeFailureCode::BackendOperationFailed,
24448 r"provider bearer-secret failed at C:\private\session.jsonl",
24449 );
24450 assert_eq!(provider.message, "backend-operation-failed");
24451 assert!(!provider.message.contains("bearer-secret"));
24452 assert!(!provider.message.contains(r"C:\private"));
24453 let persistence = persistence_failure(io::Error::new(
24454 io::ErrorKind::Other,
24455 r"state write failed at C:\private\state-v1.json",
24456 ));
24457 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24458 assert!(!persistence.message.contains(r"C:\private"));
24459
24460 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24461 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24462 assert_eq!(
24463 generic.to_string(),
24464 "node durable state failed: durable-state-load-failed",
24465 );
24466 }
24467 }
24468
24469 #[test]
24470 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24471 let sequence = b"\x1b[1;5D".to_vec();
24472 assert_eq!(
24473 terminal_bytes_action(sequence.clone()).unwrap(),
24474 InputAction::TerminalBytes(sequence),
24475 );
24476 assert_eq!(
24477 terminal_bytes_action(Vec::new()).unwrap_err().code,
24478 NodeFailureCode::InvalidRequest,
24479 );
24480 assert_eq!(
24481 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24482 .unwrap_err()
24483 .code,
24484 NodeFailureCode::InvalidRequest,
24485 );
24486 }
24487
24488 #[test]
24489 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24490 let root = temporary_workspace_root("exclusive-state-owner");
24491 std::fs::create_dir_all(&root).unwrap();
24492 let workspace = WorkspaceConfig::new(
24493 WorkspaceId::new("primary").unwrap(),
24494 &root,
24495 )
24496 .unwrap();
24497 let state_path = root.join("state-v1.json");
24498 let endpoint = format!(
24499 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24500 std::process::id(),
24501 std::time::SystemTime::now()
24502 .duration_since(std::time::UNIX_EPOCH)
24503 .unwrap()
24504 .as_nanos(),
24505 );
24506 let config = NodeServerConfig::new(
24507 endpoint,
24508 "fixture-token",
24509 NodeId::new("node-1").unwrap(),
24510 [workspace],
24511 )
24512 .unwrap()
24513 .with_state_path(&state_path)
24514 .unwrap();
24515
24516 let first = NodeServer::new(config.clone()).unwrap();
24517 let error = match NodeServer::new(config.clone()) {
24518 Ok(_) => panic!("a second node server acquired the same durable state path"),
24519 Err(error) => error,
24520 };
24521 assert!(matches!(
24522 &error,
24523 NodeServerError::DurableState(error)
24524 if error.kind() == io::ErrorKind::WouldBlock
24525 ));
24526 assert_eq!(
24527 error.to_string(),
24528 "node durable state failed: durable-state-lock-failed",
24529 );
24530 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24531 drop(first);
24532 let second = NodeServer::new(config).unwrap();
24533 drop(second);
24534 std::fs::remove_dir_all(root).unwrap();
24535 }
24536
24537 #[test]
24538 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24539 let catalog = active_registry().unwrap();
24540 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24541 let workspace_id = WorkspaceId::new("primary").unwrap();
24542 let workspace = WorkspaceConfig::new(
24543 workspace_id.clone(),
24544 std::env::current_dir().unwrap(),
24545 )
24546 .unwrap();
24547 let shared = NodeShared::new(
24548 handle,
24549 "fixture-token".to_owned(),
24550 NodeId::new("node-1").unwrap(),
24551 vec![workspace.clone()],
24552 vec![agent("claude"), agent("codex"), agent("kimi")],
24553 );
24554 let instance_id = AgentInstanceId(17);
24555 let generation = SessionGeneration(3);
24556 assert!(shared.address_for(instance_id, generation).is_none());
24557 shared.bind_session(instance_id, workspace_id.clone(), generation);
24558 let address = SessionAddress {
24559 workspace_id,
24560 session: SessionKey { instance_id, generation },
24561 };
24562 assert_eq!(
24563 shared.workspace_root(&address.workspace_id).unwrap(),
24564 workspace.canonical_root,
24565 );
24566 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24567 shared.remove_binding(&address);
24568 assert!(shared.address_for(instance_id, generation).is_none());
24569 }
24570
24571 #[test]
24572 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24573 let address = SessionAddress {
24574 workspace_id: WorkspaceId::new("primary").unwrap(),
24575 session: SessionKey {
24576 instance_id: AgentInstanceId(7),
24577 generation: SessionGeneration(2),
24578 },
24579 };
24580 let record_id = SessionRecordId::new("record-a").unwrap();
24581 let size = gate4agent_types::TerminalSize {
24582 rows: 24,
24583 columns: 80,
24584 };
24585 let address_requests = [
24586 NodeRequest::Resume {
24587 session: address.clone(),
24588 terminal_size: size,
24589 initial_prompt: None,
24590 },
24591 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24592 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24593 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24594 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24595 NodeRequest::TerminalControl {
24596 session: address.clone(),
24597 control: TerminalControl::Enter,
24598 },
24599 NodeRequest::Resize { session: address.clone(), size },
24600 NodeRequest::Interrupt { session: address.clone() },
24601 NodeRequest::Stop { session: address.clone(), force: false },
24602 NodeRequest::Remove { session: address },
24603 ];
24604 for request in &address_requests {
24605 assert!(request_requires_open_provider_ids_with(
24606 request,
24607 |_| Some(agent("third-party-agent")),
24608 |_| Some(agent("claude")),
24609 ));
24610 assert!(!request_requires_open_provider_ids_with(
24611 request,
24612 |_| Some(agent("claude")),
24613 |_| Some(agent("claude")),
24614 ));
24615 assert!(request_requires_open_provider_ids_with(
24616 request,
24617 |_| None,
24618 |_| Some(agent("claude")),
24619 ));
24620 }
24621
24622 let record_requests = [
24623 NodeRequest::RenameSessionRecord {
24624 record_id: record_id.clone(),
24625 display_name: "renamed".to_owned(),
24626 },
24627 NodeRequest::ResumeSessionRecord {
24628 record_id: record_id.clone(),
24629 terminal_size: size,
24630 initial_prompt: None,
24631 },
24632 NodeRequest::ForgetSessionRecord { record_id },
24633 ];
24634 for request in &record_requests {
24635 assert!(request_requires_open_provider_ids_with(
24636 request,
24637 |_| Some(agent("claude")),
24638 |_| Some(agent("grok")),
24639 ));
24640 assert!(!request_requires_open_provider_ids_with(
24641 request,
24642 |_| Some(agent("claude")),
24643 |_| Some(agent("kimi")),
24644 ));
24645 assert!(request_requires_open_provider_ids_with(
24646 request,
24647 |_| Some(agent("claude")),
24648 |_| None,
24649 ));
24650 }
24651
24652 assert!(request_requires_open_provider_ids_with(
24653 &NodeRequest::Spawn {
24654 workspace_id: WorkspaceId::new("primary").unwrap(),
24655 provider: agent("third-party-agent"),
24656 mode: SessionMode::Pty,
24657 terminal_size: size,
24658 initial_prompt: None,
24659 },
24660 |_| None,
24661 |_| None,
24662 ));
24663 assert!(!request_requires_open_provider_ids_with(
24664 &NodeRequest::Snapshot,
24665 |_| None,
24666 |_| None,
24667 ));
24668 assert!(request_requires_open_provider_ids_with(
24669 &NodeRequest::ForgetContextPack {
24670 context_id: SpawnContextId::new("ctx-forget").unwrap(),
24671 },
24672 |_| None,
24673 |_| None,
24674 ));
24675 }
24676
24677 #[test]
24678 fn legacy_projection_filters_open_record_replies_and_event_history() {
24679 let catalog = active_registry().unwrap();
24680 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24681 let workspace = WorkspaceConfig::new(
24682 WorkspaceId::new("primary").unwrap(),
24683 std::env::current_dir().unwrap(),
24684 )
24685 .unwrap();
24686 let shared = NodeShared::new(
24687 handle,
24688 "fixture-token".to_owned(),
24689 NodeId::new("node-1").unwrap(),
24690 vec![workspace],
24691 vec![agent("claude"), agent("grok")],
24692 );
24693 let legacy = record("claude", "legacy-record");
24694 let open = record("grok", "open-record");
24695 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24696 record: legacy.clone(),
24697 });
24698 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24699 record: open.clone(),
24700 });
24701 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24702 record_id: legacy.record_id.clone(),
24703 });
24704 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24705 record_id: open.record_id.clone(),
24706 });
24707 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24708 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24709 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24710 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24711
24712 let mut record_reply = ResponseEnvelope {
24713 request_id: 1,
24714 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24715 };
24716 project_response_legacy_provider_ids(&shared, &mut record_reply);
24717 assert!(matches!(
24718 record_reply.result,
24719 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24720 ));
24721
24722 let mut resync = ResponseEnvelope {
24723 request_id: 2,
24724 result: Ok(NodeResponse::Resync {
24725 event_sequence: open_removed.sequence,
24726 oldest_available_sequence: 1,
24727 snapshot: shared.snapshot(),
24728 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24729 }),
24730 };
24731 project_response_legacy_provider_ids(&shared, &mut resync);
24732 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24733 panic!("legacy projection changed the resync response kind");
24734 };
24735 assert!(snapshot.session_records.iter().all(|item| {
24736 provider_id_is_legacy(&item.provider)
24737 }));
24738 assert_eq!(events.len(), 2);
24739 assert!(events.iter().all(|event| matches!(
24740 &event.event,
24741 NodeEvent::SessionRecordUpserted { record }
24742 if provider_id_is_legacy(&record.provider)
24743 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24744 }
24745
24746 #[test]
24747 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24748 let catalog = active_registry().unwrap();
24749 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24750 let workspace_id = WorkspaceId::new("primary").unwrap();
24751 let workspace = WorkspaceConfig::new(
24752 workspace_id.clone(),
24753 std::env::current_dir().unwrap(),
24754 )
24755 .unwrap();
24756 let shared = NodeShared::new(
24757 handle,
24758 "fixture-token".to_owned(),
24759 NodeId::new("node-1").unwrap(),
24760 vec![workspace],
24761 vec![agent("claude")],
24762 );
24763 for index in 0..CONTROL_SESSIONS_MAX {
24764 shared.bind_session(
24765 AgentInstanceId((index + 1) as u64),
24766 workspace_id.clone(),
24767 SessionGeneration(1),
24768 );
24769 }
24770 let error = shared.ensure_binding_capacity().unwrap_err();
24771 assert_eq!(error.code, NodeFailureCode::BackendBusy);
24772 }
24773
24774 #[tokio::test]
24775 async fn provider_spawn_admission_rejects_before_session_mutation() {
24776 let catalog = active_registry().unwrap();
24777 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24778 let workspace = WorkspaceConfig::new(
24779 WorkspaceId::new("primary").unwrap(),
24780 std::env::current_dir().unwrap(),
24781 )
24782 .unwrap();
24783 let mut shared = NodeShared::new(
24784 handle,
24785 "fixture-token".to_owned(),
24786 NodeId::new("node-provider-admission").unwrap(),
24787 vec![workspace],
24788 vec![agent("claude")],
24789 );
24790 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24791 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24792 ])
24793 .unwrap();
24794 let terminal_size = gate4agent_types::TerminalSize {
24795 rows: 24,
24796 columns: 80,
24797 };
24798
24799 let unavailable = shared
24800 .spawn_session(
24801 WorkspaceId::new("primary").unwrap(),
24802 agent("claude"),
24803 SessionMode::Pty,
24804 terminal_size,
24805 None,
24806 )
24807 .await
24808 .unwrap_err();
24809 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24810 assert_eq!(unavailable.message, "backend-operation-failed");
24811
24812 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24813 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24814 agent("claude"),
24815 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24816 ),
24817 ])
24818 .unwrap();
24819 let semantic = shared
24820 .spawn_session(
24821 WorkspaceId::new("primary").unwrap(),
24822 agent("claude"),
24823 SessionMode::Inline,
24824 terminal_size,
24825 None,
24826 )
24827 .await
24828 .unwrap_err();
24829 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24830 assert_eq!(semantic.message, "unsupported-capability");
24831
24832 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24833 assert!(shared
24834 .session_bindings
24835 .lock()
24836 .unwrap_or_else(|poisoned| poisoned.into_inner())
24837 .is_empty());
24838 assert!(shared
24839 .session_records
24840 .lock()
24841 .unwrap_or_else(|poisoned| poisoned.into_inner())
24842 .records
24843 .is_empty());
24844 assert!(shared.handle.snapshot().sessions.is_empty());
24845 }
24846
24847 #[tokio::test]
24848 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24849 let catalog = active_registry().unwrap();
24850 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24851 let workspace_id = WorkspaceId::new("primary").unwrap();
24852 let workspace = WorkspaceConfig::new(
24853 workspace_id.clone(),
24854 std::env::current_dir().unwrap(),
24855 )
24856 .unwrap();
24857 let shared = NodeShared::new(
24858 handle,
24859 "fixture-token".to_owned(),
24860 NodeId::new("node-raw-spawn-record").unwrap(),
24861 vec![workspace],
24862 vec![agent("claude")],
24863 );
24864 let address = SessionAddress {
24865 workspace_id,
24866 session: SessionKey {
24867 instance_id: AgentInstanceId(71),
24868 generation: SessionGeneration::default(),
24869 },
24870 };
24871
24872 let record_id = shared
24873 .bind_spawn_session(
24874 &address,
24875 agent("claude"),
24876 SessionMode::Pty,
24877 ProviderRuntimePolicy::raw_pty(),
24878 SpawnRecordPolicy::Always,
24879 None,
24880 )
24881 .unwrap()
24882 .unwrap();
24883 shared
24884 .handle
24885 .dispatch(shared.prepare_command(ControlCommand::Register {
24886 instance_id: address.session.instance_id,
24887 agent_id: agent("claude"),
24888 transport: TransportKind::Pty,
24889 }))
24890 .unwrap();
24891 runtime.tick().await;
24892
24893 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24894 session.instance_id == address.session.instance_id
24895 && session.generation == address.session.generation
24896 }));
24897 let record = shared.record(&record_id).unwrap();
24898 assert_eq!(record.display_name, "claude #1");
24899 assert_eq!(record.state, ManagedSessionState::Live);
24900 assert_eq!(record.active_session.as_ref(), Some(&address));
24901 assert!(record.provider_session.is_none());
24902 let binding_record_id = shared
24903 .session_bindings
24904 .lock()
24905 .unwrap_or_else(|poisoned| poisoned.into_inner())
24906 .get(&address.session.instance_id)
24907 .and_then(|binding| binding.record_id.clone());
24908 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24909
24910 let renamed = shared
24911 .rename_session_record(&record_id, "raw review".to_owned())
24912 .unwrap();
24913 assert_eq!(renamed.record_id, record_id);
24914 assert_eq!(renamed.display_name, "raw review");
24915 shared.reconcile_managed_record(
24916 &record_id,
24917 &address,
24918 &ControlEvent {
24919 sequence: 1,
24920 command_id: None,
24921 instance_id: address.session.instance_id,
24922 generation: address.session.generation,
24923 event: ControlEventKind::Running { process_id: None },
24924 },
24925 false,
24926 false,
24927 );
24928 let running = shared.record(&record_id).unwrap();
24929 assert_eq!(running.state, ManagedSessionState::Live);
24930 assert_eq!(running.active_session.as_ref(), Some(&address));
24931 assert_eq!(running.display_name, "raw review");
24932
24933 shared.reconcile_managed_record(
24934 &record_id,
24935 &address,
24936 &ControlEvent {
24937 sequence: 2,
24938 command_id: None,
24939 instance_id: address.session.instance_id,
24940 generation: address.session.generation,
24941 event: ControlEventKind::Exited {
24942 exit_code: Some(0),
24943 forced: false,
24944 },
24945 },
24946 false,
24947 false,
24948 );
24949 let detached = shared.record(&record_id).unwrap();
24950 assert_eq!(detached.state, ManagedSessionState::Unavailable);
24951 assert!(detached.active_session.is_none());
24952 assert_eq!(detached.display_name, "raw review");
24953 let renamed_detached = shared
24954 .rename_session_record(&record_id, "raw review detached".to_owned())
24955 .unwrap();
24956 assert_eq!(renamed_detached.record_id, record_id);
24957 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24958 assert!(renamed_detached.active_session.is_none());
24959 assert_eq!(renamed_detached.display_name, "raw review detached");
24960 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24961 }
24962
24963 #[test]
24964 fn legacy_raw_spawn_binding_remains_untracked() {
24965 let catalog = active_registry().unwrap();
24966 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24967 let workspace_id = WorkspaceId::new("primary").unwrap();
24968 let workspace = WorkspaceConfig::new(
24969 workspace_id.clone(),
24970 std::env::current_dir().unwrap(),
24971 )
24972 .unwrap();
24973 let shared = NodeShared::new(
24974 handle,
24975 "fixture-token".to_owned(),
24976 NodeId::new("node-legacy-raw-spawn").unwrap(),
24977 vec![workspace],
24978 vec![agent("claude")],
24979 );
24980 let address = SessionAddress {
24981 workspace_id,
24982 session: SessionKey {
24983 instance_id: AgentInstanceId(72),
24984 generation: SessionGeneration::default(),
24985 },
24986 };
24987
24988 let record_id = shared
24989 .bind_spawn_session(
24990 &address,
24991 agent("claude"),
24992 SessionMode::Pty,
24993 ProviderRuntimePolicy::raw_pty(),
24994 SpawnRecordPolicy::ProviderIdentityOnly,
24995 None,
24996 )
24997 .unwrap();
24998
24999 assert!(record_id.is_none());
25000 let binding_record_id = shared
25001 .session_bindings
25002 .lock()
25003 .unwrap_or_else(|poisoned| poisoned.into_inner())
25004 .get(&address.session.instance_id)
25005 .and_then(|binding| binding.record_id.clone());
25006 assert!(binding_record_id.is_none());
25007 assert!(shared.snapshot().session_records.is_empty());
25008 }
25009
25010 #[tokio::test]
25011 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25012 let catalog = active_registry().unwrap();
25013 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25014 let workspace_id = WorkspaceId::new("primary").unwrap();
25015 let workspace = WorkspaceConfig::new(
25016 workspace_id.clone(),
25017 std::env::current_dir().unwrap(),
25018 )
25019 .unwrap();
25020 let mut shared = NodeShared::new(
25021 handle,
25022 "fixture-token".to_owned(),
25023 NodeId::new("node-raw-semantic-gate").unwrap(),
25024 vec![workspace],
25025 vec![agent("claude")],
25026 );
25027 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25028 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25029 ])
25030 .unwrap();
25031 shared
25032 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25033 .unwrap();
25034 let address = SessionAddress {
25035 workspace_id,
25036 session: SessionKey {
25037 instance_id: AgentInstanceId(72),
25038 generation: SessionGeneration::default(),
25039 },
25040 };
25041 shared.bind_session_with_policy(
25042 &address,
25043 ProviderRuntimePolicy::raw_pty(),
25044 None,
25045 );
25046 shared
25047 .handle
25048 .dispatch(shared.prepare_command(ControlCommand::Register {
25049 instance_id: address.session.instance_id,
25050 agent_id: agent("claude"),
25051 transport: TransportKind::Pty,
25052 }))
25053 .unwrap();
25054 runtime.tick().await;
25055 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25056 let revision_before = shared.handle.snapshot().revision;
25057 let history_sequence_before = shared.current_sequence();
25058 let terminal_size = gate4agent_types::TerminalSize {
25059 rows: 24,
25060 columns: 80,
25061 };
25062
25063 for request in [
25064 NodeRequest::Prompt {
25065 session: address.clone(),
25066 text: "prompt".to_owned(),
25067 },
25068 NodeRequest::Paste {
25069 session: address.clone(),
25070 text: "paste".to_owned(),
25071 },
25072 NodeRequest::Resume {
25078 session: address.clone(),
25079 terminal_size,
25080 initial_prompt: Some("resume".to_owned()),
25081 },
25082 ] {
25083 let error = process_request_inner(
25084 &shared,
25085 77,
25086 ClientRole::Operator,
25087 request,
25088 )
25089 .await
25090 .unwrap_err();
25091 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25092 }
25093
25094 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25095 assert_eq!(shared.handle.snapshot().revision, revision_before);
25096 assert_eq!(shared.current_sequence(), history_sequence_before);
25097 let bindings = shared
25098 .session_bindings
25099 .lock()
25100 .unwrap_or_else(|poisoned| poisoned.into_inner());
25101 let binding = bindings.get(&address.session.instance_id).unwrap();
25102 assert_eq!(binding.generation, address.session.generation);
25103 assert!(binding.pending_resume.is_none());
25104 assert!(binding.record_id.is_none());
25105 }
25106
25107 #[tokio::test]
25108 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25109 let catalog = active_registry().unwrap();
25110 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25111 let primary = WorkspaceConfig::new(
25112 WorkspaceId::new("primary").unwrap(),
25113 std::env::current_dir().unwrap(),
25114 )
25115 .unwrap();
25116 let shared = NodeShared::new(
25117 handle,
25118 "fixture-token".to_owned(),
25119 NodeId::new("node-1").unwrap(),
25120 vec![primary],
25121 vec![agent("claude")],
25122 );
25123 shared
25124 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25125 .unwrap();
25126 let before = shared.snapshot();
25127 let error = process_request_inner(
25128 &shared,
25129 77,
25130 ClientRole::Operator,
25131 NodeRequest::RegisterWorkspace {
25132 workspace_id: WorkspaceId::new("foreign").unwrap(),
25133 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25134 },
25135 )
25136 .await
25137 .unwrap_err();
25138 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25139 assert_eq!(error.message, "invalid-request");
25140 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25141 }
25142
25143 #[tokio::test]
25144 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25145 let catalog = active_registry().unwrap();
25146 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25147 let primary_id = WorkspaceId::new("primary").unwrap();
25148 let primary = WorkspaceConfig::new(
25149 primary_id.clone(),
25150 std::env::current_dir().unwrap(),
25151 )
25152 .unwrap();
25153 let shared = NodeShared::new(
25154 handle,
25155 "fixture-token".to_owned(),
25156 NodeId::new("node-1").unwrap(),
25157 vec![primary.clone()],
25158 vec![agent("claude")],
25159 );
25160 let secondary_id = WorkspaceId::new("secondary").unwrap();
25161 let secondary = shared
25162 .register_workspace(
25163 secondary_id.clone(),
25164 std::env::temp_dir().to_string_lossy().into_owned(),
25165 )
25166 .await
25167 .unwrap();
25168 assert_eq!(secondary.workspace_id, secondary_id);
25169 assert!(secondary.sessions.is_empty());
25170 assert_eq!(shared.snapshot().workspaces.len(), 2);
25171
25172 let duplicate_id = shared
25173 .register_workspace(
25174 secondary_id.clone(),
25175 primary.canonical_root().to_owned(),
25176 )
25177 .await
25178 .unwrap_err();
25179 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25180 let duplicate_root = shared
25181 .register_workspace(
25182 WorkspaceId::new("duplicate-root").unwrap(),
25183 windows_path_text(&secondary.canonical_root).to_owned(),
25184 )
25185 .await
25186 .unwrap_err();
25187 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25188
25189 let address = SessionAddress {
25190 workspace_id: secondary_id.clone(),
25191 session: SessionKey {
25192 instance_id: AgentInstanceId(41),
25193 generation: SessionGeneration(1),
25194 },
25195 };
25196 shared.bind_session(
25197 address.session.instance_id,
25198 secondary_id.clone(),
25199 address.session.generation,
25200 );
25201 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25202 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25203 shared.remove_binding(&address);
25204 shared.unregister_workspace(&secondary_id).unwrap();
25205 assert_eq!(shared.snapshot().workspaces.len(), 1);
25206
25207 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25208 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25209 let history = shared
25210 .history
25211 .lock()
25212 .unwrap_or_else(|poisoned| poisoned.into_inner());
25213 assert!(matches!(
25214 &history.events[0],
25215 NodeEventEnvelope {
25216 sequence: 1,
25217 event: NodeEvent::WorkspaceAdded { workspace },
25218 } if workspace.workspace_id == secondary_id
25219 ));
25220 assert!(matches!(
25221 &history.events[1],
25222 NodeEventEnvelope {
25223 sequence: 2,
25224 event: NodeEvent::WorkspaceRemoved { workspace_id },
25225 } if workspace_id == &secondary_id
25226 ));
25227 }
25228
25229 #[tokio::test]
25230 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25231 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25232 std::fs::create_dir_all(&secondary_root).unwrap();
25233 let catalog = active_registry().unwrap();
25234 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25235 let primary_id = WorkspaceId::new("primary").unwrap();
25236 let secondary_id = WorkspaceId::new("secondary").unwrap();
25237 let shared = NodeShared::new(
25238 handle,
25239 "fixture-token".to_owned(),
25240 NodeId::new("node-session-reference-index").unwrap(),
25241 vec![
25242 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25243 .unwrap(),
25244 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25245 ],
25246 vec![agent("claude"), agent("codex")],
25247 );
25248 let identity = ProviderSessionIdentity {
25249 key: gate4agent_types::ProviderSessionKey::SessionId,
25250 id: "provider-session-42".to_owned(),
25251 transcript_path: None,
25252 };
25253 let index_request = |workspace_id: WorkspaceId,
25254 provider: AgentId,
25255 identity: ProviderSessionIdentity,
25256 display_name: &str| {
25257 NodeRequest::IndexProviderSession {
25258 workspace_id,
25259 provider,
25260 identity,
25261 display_name: display_name.to_owned(),
25262 }
25263 };
25264 let request = index_request(
25265 primary_id.clone(),
25266 agent("claude"),
25267 identity.clone(),
25268 "release shepherd",
25269 );
25270 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25271 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25272 }));
25273 let observer = process_request_inner(
25274 &shared,
25275 77,
25276 ClientRole::Observer,
25277 request.clone(),
25278 )
25279 .await
25280 .unwrap_err();
25281 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25282 let uncontrolled = process_request_inner(
25283 &shared,
25284 77,
25285 ClientRole::Operator,
25286 request.clone(),
25287 )
25288 .await
25289 .unwrap_err();
25290 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25291 shared
25292 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25293 .unwrap();
25294
25295 let indexed = process_request_inner(
25296 &shared,
25297 77,
25298 ClientRole::Operator,
25299 request.clone(),
25300 )
25301 .await
25302 .unwrap();
25303 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25304 panic!("provider session reference index returned another response");
25305 };
25306 assert_eq!(record.state, ManagedSessionState::Dormant);
25307 assert_eq!(record.mode, SessionMode::Pty);
25308 assert_eq!(record.workspace_id, primary_id);
25309 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25310 assert!(record.active_session.is_none());
25311 assert!(record.environment_profile.is_none());
25312 assert!(record.bundle.is_none());
25313 assert!(record.context.is_none());
25314
25315 let repeated = process_request_inner(
25316 &shared,
25317 77,
25318 ClientRole::Operator,
25319 index_request(
25320 primary_id.clone(),
25321 agent("claude"),
25322 identity.clone(),
25323 "ignored replacement",
25324 ),
25325 )
25326 .await
25327 .unwrap();
25328 assert!(matches!(
25329 repeated,
25330 NodeResponse::ProviderSessionIndexed { record: ref existing }
25331 if existing.record_id == record.record_id
25332 && existing.display_name == "release shepherd"
25333 ));
25334 let conflicting_workspace = process_request_inner(
25335 &shared,
25336 77,
25337 ClientRole::Operator,
25338 index_request(
25339 secondary_id,
25340 agent("claude"),
25341 identity.clone(),
25342 "release shepherd",
25343 ),
25344 )
25345 .await
25346 .unwrap_err();
25347 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25348 let conflicting_provider = process_request_inner(
25349 &shared,
25350 77,
25351 ClientRole::Operator,
25352 index_request(
25353 primary_id.clone(),
25354 agent("codex"),
25355 identity.clone(),
25356 "release shepherd",
25357 ),
25358 )
25359 .await
25360 .unwrap_err();
25361 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25362 let transcript_rejected = process_request_inner(
25363 &shared,
25364 77,
25365 ClientRole::Operator,
25366 index_request(
25367 primary_id,
25368 agent("claude"),
25369 ProviderSessionIdentity {
25370 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25371 ..identity
25372 },
25373 "release shepherd",
25374 ),
25375 )
25376 .await
25377 .unwrap_err();
25378 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25379 let upserts = shared
25380 .history
25381 .lock()
25382 .unwrap_or_else(|poisoned| poisoned.into_inner())
25383 .events
25384 .iter()
25385 .filter(|event| matches!(
25386 &event.event,
25387 NodeEvent::SessionRecordUpserted { record: published }
25388 if published.record_id == record.record_id
25389 ))
25390 .count();
25391 assert_eq!(upserts, 1);
25392 std::fs::remove_dir_all(secondary_root).unwrap();
25393 }
25394
25395 #[tokio::test]
25396 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25397 let fixture_root = temporary_workspace_root("standalone-independent");
25398 let target = fixture_root.join("independent");
25399 std::fs::create_dir_all(&fixture_root).unwrap();
25400 let catalog = active_registry().unwrap();
25401 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25402 let selected = WorkspaceConfig::new(
25403 WorkspaceId::new("primary").unwrap(),
25404 std::env::current_dir().unwrap(),
25405 )
25406 .unwrap();
25407 let selected_root = selected.canonical_root().to_owned();
25408 let shared = NodeShared::new(
25409 handle,
25410 "fixture-token".to_owned(),
25411 NodeId::new("node-standalone").unwrap(),
25412 vec![selected],
25413 vec![agent("claude")],
25414 );
25415 let workspace_id = WorkspaceId::new("independent").unwrap();
25416 let request = NodeRequest::CreateStandaloneWorkspace {
25417 workspace_id: workspace_id.clone(),
25418 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25419 initial_branch: Some("main".to_owned()),
25420 };
25421 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25422 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25423 }));
25424 assert!(!request_uses_unnegotiated_capability(
25425 &request,
25426 &baseline_capabilities().unwrap(),
25427 ));
25428 assert!(request_uses_unnegotiated_capability(&request, &[]));
25429
25430 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25431 .await
25432 .unwrap_err();
25433 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25434 assert!(!target.exists());
25435 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25436 .await
25437 .unwrap_err();
25438 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25439 assert!(!target.exists());
25440 shared
25441 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25442 .unwrap();
25443
25444 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25445 .await
25446 .unwrap();
25447 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25448 panic!("standalone workspace creation returned another response");
25449 };
25450 assert_eq!(workspace.workspace_id, workspace_id);
25451 assert!(target.join(".git").is_dir());
25452 let branch = run_git_bounded(
25453 target.to_str().unwrap(),
25454 &["branch", "--show-current"],
25455 GIT_OUTPUT_MAX_BYTES,
25456 )
25457 .await
25458 .unwrap();
25459 assert!(branch.success);
25460 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25461 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25462 assert_eq!(worktrees.len(), 1);
25463 let listed_root = WorkspaceConfig::new(
25464 WorkspaceId::new("listed-independent").unwrap(),
25465 &worktrees[0].path,
25466 )
25467 .unwrap();
25468 assert!(platform::roots_equal(
25469 listed_root.canonical_root(),
25470 windows_path_text(&workspace.canonical_root),
25471 ));
25472 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25473 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25474 snapshot.workspace_id == workspace_id
25475 && snapshot.canonical_root == workspace.canonical_root
25476 }));
25477 assert!(shared
25478 .history
25479 .lock()
25480 .unwrap_or_else(|poisoned| poisoned.into_inner())
25481 .events
25482 .iter()
25483 .any(|event| matches!(
25484 &event.event,
25485 NodeEvent::WorkspaceAdded { workspace }
25486 if workspace.workspace_id == workspace_id
25487 )));
25488 std::fs::remove_dir_all(fixture_root).unwrap();
25489 }
25490
25491 #[tokio::test]
25492 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25493 let fixture_root = temporary_workspace_root("standalone-compensation");
25494 let created_target = fixture_root.join("created-target");
25495 let existing_target = fixture_root.join("existing-target");
25496 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25497 let invalid_state_path = invalid_state_parent.join("state.json");
25498 std::fs::create_dir_all(&existing_target).unwrap();
25499 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25500 let catalog = active_registry().unwrap();
25501 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25502 let primary = WorkspaceConfig::new(
25503 WorkspaceId::new("primary").unwrap(),
25504 std::env::current_dir().unwrap(),
25505 )
25506 .unwrap();
25507 let mut shared = NodeShared::new(
25508 handle,
25509 "fixture-token".to_owned(),
25510 NodeId::new("node-standalone-compensation").unwrap(),
25511 vec![primary],
25512 vec![agent("claude")],
25513 );
25514 shared.state_path = Some(invalid_state_path);
25515
25516 let created_recovery = shared
25517 .create_standalone_workspace(
25518 WorkspaceId::new("compensated").unwrap(),
25519 created_target.to_string_lossy().into_owned(),
25520 Some("main".to_owned()),
25521 )
25522 .await
25523 .unwrap_err();
25524 assert_eq!(
25525 created_recovery.code,
25526 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25527 );
25528 assert!(created_target.join(".git").is_dir());
25529 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25530 workspace.workspace_id.as_str() != "compensated"
25531 }));
25532
25533 let existing_recovery = shared
25534 .create_standalone_workspace(
25535 WorkspaceId::new("restored").unwrap(),
25536 existing_target.to_string_lossy().into_owned(),
25537 None,
25538 )
25539 .await
25540 .unwrap_err();
25541 assert_eq!(
25542 existing_recovery.code,
25543 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25544 );
25545 assert!(existing_target.is_dir());
25546 assert!(existing_target.join(".git").is_dir());
25547 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25548 workspace.workspace_id.as_str() != "restored"
25549 }));
25550
25551 let registered_empty = fixture_root.join("registered-empty");
25552 std::fs::create_dir(®istered_empty).unwrap();
25553 let catalog = active_registry().unwrap();
25554 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25555 let registered = WorkspaceConfig::new(
25556 WorkspaceId::new("registered-empty").unwrap(),
25557 ®istered_empty,
25558 )
25559 .unwrap();
25560 let duplicate_shared = NodeShared::new(
25561 handle,
25562 "fixture-token".to_owned(),
25563 NodeId::new("node-standalone-duplicate-root").unwrap(),
25564 vec![registered],
25565 vec![agent("claude")],
25566 );
25567 let duplicate = duplicate_shared
25568 .create_standalone_workspace(
25569 WorkspaceId::new("duplicate-root").unwrap(),
25570 registered_empty.to_string_lossy().into_owned(),
25571 None,
25572 )
25573 .await
25574 .unwrap_err();
25575 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25576 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25577 std::fs::remove_dir_all(fixture_root).unwrap();
25578 }
25579
25580 #[test]
25581 fn managed_record_id_collision_preserves_the_existing_record() {
25582 let catalog = active_registry().unwrap();
25583 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25584 let workspace = WorkspaceConfig::new(
25585 WorkspaceId::new("primary").unwrap(),
25586 std::env::current_dir().unwrap(),
25587 )
25588 .unwrap();
25589 let shared = NodeShared::new(
25590 handle,
25591 "fixture-token".to_owned(),
25592 NodeId::new("node-1").unwrap(),
25593 vec![workspace.clone()],
25594 vec![agent("claude")],
25595 );
25596 let record_id = SessionRecordId::new("sr-collision").unwrap();
25597 let original = ManagedSessionRecord {
25598 record_id: record_id.clone(),
25599 display_name: "original".to_owned(),
25600 provider: agent("claude"),
25601 mode: SessionMode::Pty,
25602 state: ManagedSessionState::Dormant,
25603 workspace_id: workspace.workspace_id,
25604 canonical_root: opaque_windows_path(workspace.canonical_root),
25605 provider_session: None,
25606 active_session: None,
25607 environment_profile: None,
25608 bundle: None,
25609 context_id: None,
25610 context: None,
25611 exported_context: None,
25612 task_binding: None,
25613 created_at_unix_ms: 1,
25614 updated_at_unix_ms: 1,
25615 last_error: None,
25616 };
25617 shared.insert_record(original.clone()).unwrap();
25618 let mut collision = original.clone();
25619 collision.display_name = "replacement".to_owned();
25620 let error = shared.insert_record(collision).unwrap_err();
25621 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25622 assert_eq!(shared.record(&record_id).unwrap(), original);
25623 }
25624
25625 #[test]
25626 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25627 let catalog = active_registry().unwrap();
25628 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25629 let workspace = WorkspaceConfig::new(
25630 WorkspaceId::new("primary").unwrap(),
25631 std::env::current_dir().unwrap(),
25632 )
25633 .unwrap();
25634 let shared = NodeShared::new(
25635 handle,
25636 "fixture-token".to_owned(),
25637 NodeId::new("node-1").unwrap(),
25638 vec![workspace.clone()],
25639 vec![agent("claude")],
25640 );
25641 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25642 shared
25643 .insert_record(ManagedSessionRecord {
25644 record_id: record_id.clone(),
25645 display_name: "runtime error".to_owned(),
25646 provider: agent("claude"),
25647 mode: SessionMode::Pty,
25648 state: ManagedSessionState::IdentityPending,
25649 workspace_id: workspace.workspace_id,
25650 canonical_root: opaque_windows_path(workspace.canonical_root),
25651 provider_session: None,
25652 active_session: None,
25653 environment_profile: None,
25654 bundle: None,
25655 context_id: None,
25656 context: None,
25657 exported_context: None,
25658 task_binding: None,
25659 created_at_unix_ms: 1,
25660 updated_at_unix_ms: 1,
25661 last_error: None,
25662 })
25663 .unwrap();
25664
25665 shared
25666 .mark_record_error(
25667 &record_id,
25668 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25669 )
25670 .unwrap();
25671 let record = shared.record(&record_id).unwrap();
25672 assert_eq!(record.state, ManagedSessionState::Unavailable);
25673 assert_eq!(
25674 record.last_error.as_deref(),
25675 Some("provider-runtime-failed"),
25676 );
25677 let snapshot = shared.snapshot();
25678 assert_eq!(snapshot.session_records, vec![record]);
25679 }
25680
25681 #[test]
25690 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25691 let catalog = active_registry().unwrap();
25692 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25693 let workspace = WorkspaceConfig::new(
25694 WorkspaceId::new("primary").unwrap(),
25695 std::env::current_dir().unwrap(),
25696 )
25697 .unwrap();
25698 let shared = NodeShared::new(
25699 handle,
25700 "fixture-token".to_owned(),
25701 NodeId::new("node-1").unwrap(),
25702 vec![workspace],
25703 vec![agent("claude")],
25704 );
25705
25706 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25707
25708 shared.record_rejected_command(
25709 CommandId(1),
25710 "agent 'claude' does not support transport Acp".to_owned(),
25711 true,
25712 );
25713 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25715
25716 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25717 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25718 assert!(reason.unsupported_transport);
25719
25720 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25722
25723 shared.record_rejected_command(
25728 CommandId(3),
25729 "backend busy".to_owned(),
25730 false,
25731 );
25732 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25733 assert!(!unclassified.unsupported_transport);
25734 }
25735
25736 #[test]
25737 fn provider_identity_update_is_ignored_without_exact_session_policy() {
25738 let catalog = active_registry().unwrap();
25739 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25740 let workspace = WorkspaceConfig::new(
25741 WorkspaceId::new("primary").unwrap(),
25742 std::env::current_dir().unwrap(),
25743 )
25744 .unwrap();
25745 let shared = NodeShared::new(
25746 handle,
25747 "fixture-token".to_owned(),
25748 NodeId::new("node-identity-policy").unwrap(),
25749 vec![workspace.clone()],
25750 vec![agent("claude")],
25751 );
25752 let address = SessionAddress {
25753 workspace_id: workspace.workspace_id.clone(),
25754 session: SessionKey {
25755 instance_id: AgentInstanceId(90),
25756 generation: SessionGeneration(1),
25757 },
25758 };
25759 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25760 shared
25761 .insert_record(ManagedSessionRecord {
25762 record_id: record_id.clone(),
25763 display_name: "policy denied".to_owned(),
25764 provider: agent("claude"),
25765 mode: SessionMode::Pty,
25766 state: ManagedSessionState::IdentityPending,
25767 workspace_id: workspace.workspace_id,
25768 canonical_root: opaque_windows_path(workspace.canonical_root),
25769 provider_session: None,
25770 active_session: Some(address.clone()),
25771 environment_profile: None,
25772 bundle: None,
25773 context_id: None,
25774 context: None,
25775 exported_context: None,
25776 task_binding: None,
25777 created_at_unix_ms: 1,
25778 updated_at_unix_ms: 1,
25779 last_error: None,
25780 })
25781 .unwrap();
25782 let event = ControlEvent {
25783 sequence: 1,
25784 command_id: None,
25785 instance_id: address.session.instance_id,
25786 generation: address.session.generation,
25787 event: ControlEventKind::ProviderEvent {
25788 sequence: 1,
25789 source: gate4agent_types::ProviderSource {
25790 family: AdapterFamily::PtySemantic,
25791 binding: AdapterBinding::new(
25792 gate4agent_types::AdapterId::new("claude").unwrap(),
25793 "fixture/v1",
25794 gate4agent_types::AdapterVerification::SyntheticFixture,
25795 )
25796 .unwrap(),
25797 },
25798 source_sequence: 1,
25799 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25800 identity: gate4agent_types::ProviderSessionIdentity {
25801 key: gate4agent_types::ProviderSessionKey::SessionId,
25802 id: "provider-session".to_owned(),
25803 transcript_path: None,
25804 },
25805 },
25806 },
25807 };
25808
25809 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25810
25811 let record = shared.record(&record_id).unwrap();
25812 assert!(record.provider_session.is_none());
25813 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25814 assert_eq!(record.active_session.as_ref(), Some(&address));
25815 }
25816
25817 #[test]
25818 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25819 let secondary_root = temporary_workspace_root("identity-scope");
25820 std::fs::create_dir_all(&secondary_root).unwrap();
25821 let catalog = active_registry().unwrap();
25822 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25823 let primary = WorkspaceConfig::new(
25824 WorkspaceId::new("primary").unwrap(),
25825 std::env::current_dir().unwrap(),
25826 )
25827 .unwrap();
25828 let secondary = WorkspaceConfig::new(
25829 WorkspaceId::new("secondary").unwrap(),
25830 &secondary_root,
25831 )
25832 .unwrap();
25833 let shared = NodeShared::new(
25834 handle,
25835 "fixture-token".to_owned(),
25836 NodeId::new("node-1").unwrap(),
25837 vec![primary.clone(), secondary.clone()],
25838 vec![agent("claude")],
25839 );
25840 let identity = gate4agent_types::ProviderSessionIdentity {
25841 key: gate4agent_types::ProviderSessionKey::SessionId,
25842 id: "provider-session".to_owned(),
25843 transcript_path: None,
25844 };
25845 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25846 shared
25847 .insert_record(ManagedSessionRecord {
25848 record_id: dormant_id.clone(),
25849 display_name: "dormant".to_owned(),
25850 provider: agent("claude"),
25851 mode: SessionMode::Pty,
25852 state: ManagedSessionState::Dormant,
25853 workspace_id: primary.workspace_id,
25854 canonical_root: opaque_windows_path(primary.canonical_root),
25855 provider_session: Some(identity.clone()),
25856 active_session: None,
25857 environment_profile: None,
25858 bundle: None,
25859 context_id: None,
25860 context: None,
25861 exported_context: None,
25862 task_binding: None,
25863 created_at_unix_ms: 1,
25864 updated_at_unix_ms: 1,
25865 last_error: None,
25866 })
25867 .unwrap();
25868 let address = SessionAddress {
25869 workspace_id: secondary.workspace_id.clone(),
25870 session: SessionKey {
25871 instance_id: AgentInstanceId(91),
25872 generation: SessionGeneration(1),
25873 },
25874 };
25875 let current_id = SessionRecordId::new("sr-current").unwrap();
25876 shared
25877 .insert_record(ManagedSessionRecord {
25878 record_id: current_id.clone(),
25879 display_name: "current".to_owned(),
25880 provider: agent("claude"),
25881 mode: SessionMode::Pty,
25882 state: ManagedSessionState::IdentityPending,
25883 workspace_id: secondary.workspace_id,
25884 canonical_root: opaque_windows_path(secondary.canonical_root),
25885 provider_session: None,
25886 active_session: Some(address.clone()),
25887 environment_profile: None,
25888 bundle: None,
25889 context_id: None,
25890 context: None,
25891 exported_context: None,
25892 task_binding: None,
25893 created_at_unix_ms: 2,
25894 updated_at_unix_ms: 2,
25895 last_error: None,
25896 })
25897 .unwrap();
25898 shared.bind_managed_session(
25899 &address,
25900 current_id.clone(),
25901 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25902 None,
25903 );
25904 let event = ControlEvent {
25905 sequence: 1,
25906 command_id: None,
25907 instance_id: address.session.instance_id,
25908 generation: address.session.generation,
25909 event: ControlEventKind::ProviderEvent {
25910 sequence: 1,
25911 source: gate4agent_types::ProviderSource {
25912 family: gate4agent_types::AdapterFamily::PtySemantic,
25913 binding: gate4agent_types::AdapterBinding::new(
25914 gate4agent_types::AdapterId::new("claude").unwrap(),
25915 "fixture/v1",
25916 gate4agent_types::AdapterVerification::SyntheticFixture,
25917 )
25918 .unwrap(),
25919 },
25920 source_sequence: 1,
25921 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25922 identity,
25923 },
25924 },
25925 };
25926 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
25927
25928 let binding = shared
25929 .session_bindings
25930 .lock()
25931 .unwrap_or_else(|poisoned| poisoned.into_inner())
25932 .get(&address.session.instance_id)
25933 .cloned()
25934 .unwrap();
25935 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
25936 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25937 let current = shared.record(¤t_id).unwrap();
25938 assert_eq!(current.state, ManagedSessionState::Unavailable);
25939 assert_eq!(current.active_session.as_ref(), Some(&address));
25940 assert_eq!(
25941 current.last_error.as_deref(),
25942 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25943 );
25944 std::fs::remove_dir_all(secondary_root).unwrap();
25945 }
25946
25947 #[tokio::test]
25948 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25949 let root = temporary_workspace_root("semantic-provider-session");
25950 std::fs::create_dir_all(&root).unwrap();
25951 let state_path = root.join("state-v1.json");
25952 let catalog = active_registry().unwrap();
25953 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25954 let workspace = WorkspaceConfig::new(
25955 WorkspaceId::new("primary").unwrap(),
25956 &root,
25957 )
25958 .unwrap();
25959 let node_id = NodeId::new("node-semantic-identity").unwrap();
25960 let shared = NodeShared::new_with_incarnation(
25961 handle,
25962 "fixture-token".to_owned(),
25963 node_id.clone(),
25964 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25965 vec![workspace.clone()],
25966 vec![agent("claude")],
25967 ProviderRuntimeStatuses::default(),
25968 None,
25969 Vec::new(),
25970 Vec::new(),
25971 SpawnProfileRegistry::default(),
25972 None,
25973 None,
25974 None,
25975 Some(state_path.clone()),
25976 Vec::new(),
25977 Vec::new(),
25978 Vec::new(),
25979 Vec::new(),
25980 None,
25981 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25982 );
25983 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25984 key: gate4agent_types::ProviderSessionKey::SessionId,
25985 id: "provider-session".to_owned(),
25986 transcript_path: None,
25987 };
25988 let durable_id = SessionRecordId::new("sr-durable").unwrap();
25989 shared
25990 .insert_record(ManagedSessionRecord {
25991 record_id: durable_id.clone(),
25992 display_name: "durable".to_owned(),
25993 provider: agent("claude"),
25994 mode: SessionMode::Pty,
25995 state: ManagedSessionState::Dormant,
25996 workspace_id: workspace.workspace_id.clone(),
25997 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25998 provider_session: Some(persisted_identity.clone()),
25999 active_session: None,
26000 environment_profile: None,
26001 bundle: None,
26002 context_id: None,
26003 context: None,
26004 exported_context: None,
26005 task_binding: None,
26006 created_at_unix_ms: 1,
26007 updated_at_unix_ms: 1,
26008 last_error: None,
26009 })
26010 .unwrap();
26011 let address = SessionAddress {
26012 workspace_id: workspace.workspace_id.clone(),
26013 session: SessionKey {
26014 instance_id: AgentInstanceId(92),
26015 generation: SessionGeneration(2),
26016 },
26017 };
26018 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26019 shared
26020 .insert_record(ManagedSessionRecord {
26021 record_id: pending_id.clone(),
26022 display_name: "pending".to_owned(),
26023 provider: agent("claude"),
26024 mode: SessionMode::Pty,
26025 state: ManagedSessionState::IdentityPending,
26026 workspace_id: workspace.workspace_id.clone(),
26027 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26028 provider_session: None,
26029 active_session: Some(address.clone()),
26030 environment_profile: None,
26031 bundle: None,
26032 context_id: None,
26033 context: None,
26034 exported_context: None,
26035 task_binding: None,
26036 created_at_unix_ms: 2,
26037 updated_at_unix_ms: 2,
26038 last_error: None,
26039 })
26040 .unwrap();
26041 shared.bind_managed_session(
26042 &address,
26043 pending_id.clone(),
26044 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26045 None,
26046 );
26047 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26048 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26049 ..persisted_identity
26050 };
26051 let event = ControlEvent {
26052 sequence: 1,
26053 command_id: None,
26054 instance_id: address.session.instance_id,
26055 generation: address.session.generation,
26056 event: ControlEventKind::ProviderEvent {
26057 sequence: 1,
26058 source: gate4agent_types::ProviderSource {
26059 family: gate4agent_types::AdapterFamily::PtySemantic,
26060 binding: gate4agent_types::AdapterBinding::new(
26061 gate4agent_types::AdapterId::new("claude").unwrap(),
26062 "fixture/v1",
26063 gate4agent_types::AdapterVerification::SyntheticFixture,
26064 )
26065 .unwrap(),
26066 },
26067 source_sequence: 1,
26068 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26069 identity: observed_identity.clone(),
26070 },
26071 },
26072 };
26073 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26074
26075 let snapshot = shared.snapshot();
26076 assert_eq!(snapshot.session_records.len(), 1);
26077 let live = &snapshot.session_records[0];
26078 assert_eq!(live.record_id, durable_id);
26079 assert_eq!(live.state, ManagedSessionState::Live);
26080 assert_eq!(live.active_session.as_ref(), Some(&address));
26081 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26082 let binding = shared
26083 .session_bindings
26084 .lock()
26085 .unwrap_or_else(|poisoned| poisoned.into_inner())
26086 .get(&address.session.instance_id)
26087 .cloned()
26088 .unwrap();
26089 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26090
26091 let mut duplicate = live.clone();
26092 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26093 duplicate.display_name = "duplicate".to_owned();
26094 duplicate.state = ManagedSessionState::Dormant;
26095 duplicate.active_session = None;
26096 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26097 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26098 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26099 assert_eq!(shared.snapshot().session_records.len(), 1);
26100
26101 let terminal_size = gate4agent_types::TerminalSize {
26102 rows: 24,
26103 columns: 80,
26104 };
26105 let (first_busy, second_busy) = tokio::join!(
26106 shared.resume_session_record(&durable_id, terminal_size, None),
26107 shared.resume_session_record(&durable_id, terminal_size, None),
26108 );
26109 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26110 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26111 assert_eq!(busy.message, "session-record-busy");
26112 }
26113
26114 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26115 assert_eq!(loaded.records.len(), 1);
26116 assert_eq!(loaded.records[0].record_id, durable_id);
26117 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26118 assert_eq!(
26119 loaded.records[0]
26120 .provider_session
26121 .as_ref()
26122 .unwrap()
26123 .transcript_path,
26124 None,
26125 );
26126 let catalog = active_registry().unwrap();
26127 let (reloaded_handle, _reloaded_runtime) =
26128 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26129 let reloaded = NodeShared::new_with_incarnation(
26130 reloaded_handle,
26131 "fixture-token".to_owned(),
26132 node_id,
26133 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26134 vec![workspace],
26135 vec![agent("claude")],
26136 ProviderRuntimeStatuses::default(),
26137 None,
26138 Vec::new(),
26139 Vec::new(),
26140 SpawnProfileRegistry::default(),
26141 None,
26142 None,
26143 None,
26144 None,
26145 loaded.records,
26146 loaded.managed_worktrees,
26147 loaded.managed_worktree_tombstones,
26148 loaded.materializations,
26149 loaded.warning,
26150 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26151 );
26152 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26153 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26154 std::fs::remove_dir_all(root).unwrap();
26155 }
26156
26157 #[tokio::test]
26158 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26159 let secondary_root = temporary_workspace_root("workspace-restore");
26160 let other_root = temporary_workspace_root("workspace-conflict");
26161 std::fs::create_dir_all(&secondary_root).unwrap();
26162 std::fs::create_dir_all(&other_root).unwrap();
26163 let catalog = active_registry().unwrap();
26164 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26165 let primary = WorkspaceConfig::new(
26166 WorkspaceId::new("primary").unwrap(),
26167 std::env::current_dir().unwrap(),
26168 )
26169 .unwrap();
26170 let secondary = WorkspaceConfig::new(
26171 WorkspaceId::new("secondary").unwrap(),
26172 &secondary_root,
26173 )
26174 .unwrap();
26175 let shared = NodeShared::new(
26176 handle,
26177 "fixture-token".to_owned(),
26178 NodeId::new("node-1").unwrap(),
26179 vec![primary, secondary.clone()],
26180 vec![agent("claude")],
26181 );
26182 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26183 shared
26184 .insert_record(ManagedSessionRecord {
26185 record_id: record_id.clone(),
26186 display_name: "workspace record".to_owned(),
26187 provider: agent("claude"),
26188 mode: SessionMode::Pty,
26189 state: ManagedSessionState::Dormant,
26190 workspace_id: secondary.workspace_id.clone(),
26191 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26192 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26193 key: gate4agent_types::ProviderSessionKey::SessionId,
26194 id: "provider-session".to_owned(),
26195 transcript_path: None,
26196 }),
26197 active_session: None,
26198 environment_profile: None,
26199 bundle: None,
26200 context_id: None,
26201 context: None,
26202 exported_context: None,
26203 task_binding: None,
26204 created_at_unix_ms: 1,
26205 updated_at_unix_ms: 1,
26206 last_error: None,
26207 })
26208 .unwrap();
26209 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26210 let unavailable = shared.record(&record_id).unwrap();
26211 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26212 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26213
26214 let conflict = shared
26215 .register_workspace(
26216 secondary.workspace_id.clone(),
26217 other_root.to_string_lossy().into_owned(),
26218 )
26219 .await
26220 .unwrap_err();
26221 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26222 shared
26223 .register_workspace(
26224 secondary.workspace_id,
26225 secondary_root.to_string_lossy().into_owned(),
26226 )
26227 .await
26228 .unwrap();
26229 let restored = shared.record(&record_id).unwrap();
26230 assert_eq!(restored.state, ManagedSessionState::Dormant);
26231 assert!(restored.last_error.is_none());
26232 std::fs::remove_dir_all(secondary_root).unwrap();
26233 std::fs::remove_dir_all(other_root).unwrap();
26234 }
26235
26236 #[test]
26237 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26238 let error = validate_workspace_request_root(
26239 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26240 )
26241 .unwrap_err();
26242 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26243 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26244 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26245 }
26246
26247 #[test]
26248 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26249 let catalog = active_registry().unwrap();
26250 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26251 let workspace = WorkspaceConfig::new(
26252 WorkspaceId::new("primary").unwrap(),
26253 std::env::current_dir().unwrap(),
26254 )
26255 .unwrap();
26256 let shared = NodeShared::new(
26257 handle,
26258 "fixture-token".to_owned(),
26259 NodeId::new("node-1").unwrap(),
26260 vec![workspace],
26261 vec![agent("claude")],
26262 );
26263 let publishers = 4_u64;
26264 let events_per_publisher = 256_u64;
26265 let start = std::sync::Barrier::new(publishers as usize + 1);
26266 let finished = AtomicU64::new(0);
26267
26268 std::thread::scope(|scope| {
26269 for _ in 0..publishers {
26270 scope.spawn(|| {
26271 start.wait();
26272 for _ in 0..events_per_publisher {
26273 shared.publish(NodeEvent::ControllerChanged { controller: None });
26274 }
26275 finished.fetch_add(1, Ordering::Release);
26276 });
26277 }
26278 start.wait();
26279 while finished.load(Ordering::Acquire) != publishers {
26280 assert_resync_history_is_linearized(shared.resync(0));
26281 std::thread::yield_now();
26282 }
26283 });
26284 let response = shared.resync(0);
26285 assert_resync_history_is_linearized(response.clone());
26286 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26287 unreachable!("resync helper returned another response");
26288 };
26289 assert_eq!(event_sequence, publishers * events_per_publisher);
26290 assert_eq!(events.len() as u64, event_sequence);
26291 }
26292
26293 fn assert_resync_history_is_linearized(response: NodeResponse) {
26294 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26295 unreachable!("resync helper returned another response");
26296 };
26297 assert!(events.windows(2).all(|pair| {
26298 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26299 }));
26300 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26301 }
26302
26303 #[test]
26304 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26305 assert_eq!(
26306 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26307 r"C:\repo\worktree",
26308 );
26309 assert_eq!(
26310 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26311 r"\\server\share\repo",
26312 );
26313 assert!(validate_workspace_root(
26314 &WorkspaceId::new("network").unwrap(),
26315 r"\\server\share\repo",
26316 )
26317 .is_err());
26318 let workspace = WorkspaceConfig::new(
26319 WorkspaceId::new("primary").unwrap(),
26320 std::env::current_dir().unwrap(),
26321 )
26322 .unwrap();
26323 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26324 }
26325
26326 #[test]
26327 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26328 let catalog = active_registry().unwrap();
26329 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26330 let workspace_id = WorkspaceId::new("primary").unwrap();
26331 let workspace = WorkspaceConfig::new(
26332 workspace_id.clone(),
26333 std::env::current_dir().unwrap(),
26334 )
26335 .unwrap();
26336 let shared = NodeShared::new(
26337 handle,
26338 "fixture-token".to_owned(),
26339 NodeId::new("node-1").unwrap(),
26340 vec![workspace],
26341 vec![agent("claude")],
26342 );
26343 let instance_id = AgentInstanceId(17);
26344 let original = SessionAddress {
26345 workspace_id,
26346 session: SessionKey {
26347 instance_id,
26348 generation: SessionGeneration(3),
26349 },
26350 };
26351 shared.bind_session(
26352 instance_id,
26353 original.workspace_id.clone(),
26354 original.session.generation,
26355 );
26356 shared
26357 .arm_resume(
26358 &original,
26359 CommandId(41),
26360 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26361 )
26362 .unwrap();
26363
26364 shared.publish_control(ControlEvent {
26365 sequence: 1,
26366 command_id: None,
26367 instance_id,
26368 generation: SessionGeneration(4),
26369 event: ControlEventKind::Running { process_id: None },
26370 });
26371 assert_eq!(
26372 shared.address_for(instance_id, SessionGeneration(3)),
26373 Some(original.clone()),
26374 );
26375 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26376
26377 shared.publish_control(ControlEvent {
26378 sequence: 2,
26379 command_id: Some(CommandId(42)),
26380 instance_id,
26381 generation: SessionGeneration(3),
26382 event: ControlEventKind::CommandRejected {
26383 message: "unrelated command".to_owned(),
26384 },
26385 });
26386
26387 shared.publish_control(ControlEvent {
26388 sequence: 3,
26389 command_id: None,
26390 instance_id,
26391 generation: SessionGeneration(4),
26392 event: ControlEventKind::ResumeAuthorized {
26393 session: gate4agent_types::ResumeSessionSummary {
26394 key: gate4agent_types::ProviderSessionKey::SessionId,
26395 id: "fixture-session".to_owned(),
26396 },
26397 },
26398 });
26399 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26400 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26401 assert_eq!(rebound.workspace_id, original.workspace_id);
26402 let history = shared
26403 .history
26404 .lock()
26405 .unwrap_or_else(|poisoned| poisoned.into_inner());
26406 assert!(history.events.iter().any(|envelope| matches!(
26407 &envelope.event,
26408 NodeEvent::Control { address, event }
26409 if address == &rebound
26410 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26411 )));
26412 }
26413
26414 #[test]
26415 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26416 for agent in ["claude", "codex", "kimi"] {
26417 assert_eq!(
26418 prompt_framing(&AgentId::new(agent).unwrap()),
26419 PromptFraming::BracketedPaste
26420 );
26421 }
26422 }
26423
26424 fn generous_walk_deadline() -> Instant {
26425 Instant::now() + Duration::from_secs(30)
26426 }
26427
26428 #[test]
26429 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26430 let root = temporary_workspace_root("tree");
26431 std::fs::create_dir_all(root.join("src")).unwrap();
26432 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26433 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26434 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26435 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26436 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26437 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26443 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26444 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26448 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26449
26450 let (entries, truncated, _budget) =
26451 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26452 assert!(!truncated);
26453 assert!(entries.iter().any(|entry| {
26454 entry.relative_path.as_utf8() == Some("src")
26455 && entry.kind == WorkspaceEntryKind::Directory
26456 }));
26457 assert!(entries.iter().any(|entry| {
26458 entry.relative_path.as_utf8() == Some("src/lib.rs")
26459 && entry.kind == WorkspaceEntryKind::File
26460 }));
26461 assert!(entries.iter().any(|entry| {
26462 entry.relative_path.as_utf8() == Some("target-notes")
26463 && entry.kind == WorkspaceEntryKind::Directory
26464 }));
26465 assert!(entries.iter().any(|entry| {
26466 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26467 && entry.kind == WorkspaceEntryKind::File
26468 }));
26469 assert!(entries.iter().all(|entry| {
26470 let path = entry.relative_path.as_utf8().unwrap();
26471 !path.starts_with(".git")
26472 && !(path == "target" || path.starts_with("target/"))
26473 && !(path == "target-a2" || path.starts_with("target-a2/"))
26474 && !path.starts_with("node_modules")
26475 }));
26476
26477 std::fs::remove_dir_all(&root).unwrap();
26478 }
26479
26480 #[test]
26481 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26482 let root = temporary_workspace_root("tree-entry-cap");
26483 std::fs::create_dir_all(&root).unwrap();
26484 for index in 0..20 {
26485 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26486 }
26487
26488 let (entries, truncated, budget) =
26489 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26490 assert!(truncated);
26491 assert!(budget.entry_cap_exceeded);
26492 assert!(!budget.time_budget_exceeded);
26493 assert!(entries.len() <= 5);
26494
26495 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26496 .expect("an entry-cap-truncated walk must report a truncation marker");
26497 assert!(inspection_truncation.walk_entry_cap_exceeded);
26498 assert!(!inspection_truncation.walk_time_budget_exceeded);
26499 assert!(!inspection_truncation.git_time_budget_exceeded);
26500 assert!(inspection_truncation.entries_visited >= 5);
26501
26502 std::fs::remove_dir_all(&root).unwrap();
26503 }
26504
26505 #[test]
26506 fn workspace_walk_within_budget_reports_no_truncation() {
26507 let root = temporary_workspace_root("tree-no-truncation");
26508 std::fs::create_dir_all(&root).unwrap();
26509 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26510
26511 let (_entries, truncated, budget) =
26512 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26513 assert!(!truncated);
26514 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26515
26516 std::fs::remove_dir_all(&root).unwrap();
26517 }
26518
26519 #[test]
26520 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26521 let root = temporary_workspace_root("tree-prefix-siblings");
26522 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26523 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26524 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26525 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26526
26527 let (entries, truncated, _budget) =
26528 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26529 assert!(!truncated);
26530 let paths = entries
26531 .iter()
26532 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26533 .collect::<Vec<_>>();
26534 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26535 assert_eq!(
26536 paths,
26537 vec![
26538 "gate4agent-c2".to_owned(),
26539 "gate4agent-c2-client".to_owned(),
26540 "gate4agent-c2-client/lib.rs".to_owned(),
26541 "gate4agent-c2/src".to_owned(),
26542 "gate4agent-c2/src/lib.rs".to_owned(),
26543 ],
26544 );
26545
26546 std::fs::remove_dir_all(&root).unwrap();
26547 }
26548
26549 #[test]
26550 fn git_parsers_cap_status_and_commit_results() {
26551 let mut status_output = Vec::new();
26552 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26553 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26554 }
26555 let mut snapshot = git_snapshot_for_parser();
26556 parse_git_status(&status_output, &mut snapshot);
26557 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26558 assert!(snapshot.truncated);
26559 assert_eq!(snapshot.status[0].index_status, " ");
26560 assert_eq!(snapshot.status[0].worktree_status, "M");
26561
26562 snapshot.truncated = false;
26563 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26564 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26565 .collect::<String>();
26566 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26567 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26568 assert!(snapshot.truncated);
26569 }
26570
26571 #[test]
26572 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26573 let mut snapshot = git_snapshot_for_parser();
26574 parse_git_status(
26575 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26576 &mut snapshot,
26577 );
26578
26579 assert!(!snapshot.truncated);
26580 assert_eq!(snapshot.status.len(), 3);
26581 assert_eq!(snapshot.status[0].index_status, "R");
26582 assert_eq!(snapshot.status[0].worktree_status, " ");
26583 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26584 assert_eq!(
26585 snapshot.status[0]
26586 .previous_path
26587 .as_ref()
26588 .and_then(RepositoryPath::as_utf8),
26589 Some("src/previous name.rs"),
26590 );
26591 assert_eq!(snapshot.status[1].index_status, " ");
26592 assert_eq!(snapshot.status[1].worktree_status, "C");
26593 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26594 assert_eq!(
26595 snapshot.status[1]
26596 .previous_path
26597 .as_ref()
26598 .and_then(RepositoryPath::as_utf8),
26599 Some("src/original.rs"),
26600 );
26601 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26602 assert!(snapshot.status[2].previous_path.is_none());
26603 }
26604
26605 #[test]
26606 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26607 let mut snapshot = git_snapshot_for_parser();
26608 parse_git_status(
26609 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
26610 &mut snapshot,
26611 );
26612
26613 assert!(snapshot.truncated);
26614 assert_eq!(snapshot.status.len(), 1);
26615 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26616 assert!(snapshot.status[0].previous_path.is_none());
26617 }
26618
26619 #[tokio::test]
26620 async fn observer_can_browse_host_directories_without_controller() {
26621 let root = temporary_workspace_root("host-directory-observer");
26622 std::fs::create_dir_all(root.join("directory-b")).unwrap();
26623 std::fs::create_dir_all(root.join("directory-a")).unwrap();
26624 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26625 let workspace_id = WorkspaceId::new("primary").unwrap();
26626 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26627 let catalog = active_registry().unwrap();
26628 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26629 let shared = NodeShared::new(
26630 handle,
26631 "fixture-token".to_owned(),
26632 NodeId::new("node-1").unwrap(),
26633 vec![workspace],
26634 vec![agent("claude")],
26635 );
26636 let request = NodeRequest::BrowseHostDirectories {
26637 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26638 after: None,
26639 };
26640 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26641 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26642 }));
26643 assert!(!request_uses_unnegotiated_capability(
26644 &request,
26645 &baseline_capabilities().unwrap(),
26646 ));
26647 assert!(request_uses_unnegotiated_capability(&request, &[]));
26648
26649 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26650 .await
26651 .unwrap();
26652 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26653 panic!("host directory browse returned another response");
26654 };
26655 assert_eq!(
26656 listing
26657 .entries
26658 .iter()
26659 .map(|entry| entry.display_name.as_str())
26660 .collect::<Vec<_>>(),
26661 vec!["directory-a", "directory-b"],
26662 );
26663 assert!(listing.entries.iter().all(|entry| !entry.is_link));
26664 assert!(!listing.incomplete);
26665 std::fs::remove_dir_all(root).unwrap();
26666 }
26667
26668 #[tokio::test]
26669 async fn observer_can_inspect_only_a_registered_workspace() {
26670 let root = temporary_workspace_root("observer");
26671 std::fs::create_dir_all(root.join("src")).unwrap();
26672 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26673 let workspace_id = WorkspaceId::new("primary").unwrap();
26674 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26675 let catalog = active_registry().unwrap();
26676 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26677 let shared = NodeShared::new(
26678 handle,
26679 "fixture-token".to_owned(),
26680 NodeId::new("node-1").unwrap(),
26681 vec![workspace],
26682 vec![agent("claude")],
26683 );
26684
26685 let response = process_request_inner(
26686 &shared,
26687 77,
26688 ClientRole::Observer,
26689 NodeRequest::InspectWorkspace {
26690 workspace_id: workspace_id.clone(),
26691 },
26692 )
26693 .await
26694 .unwrap();
26695 let NodeResponse::WorkspaceInspected { inspection } = response else {
26696 panic!("workspace inspection returned another response");
26697 };
26698 assert_eq!(inspection.workspace_id, workspace_id);
26699 assert!(inspection.entries.iter().any(|entry| {
26700 entry.relative_path.as_utf8() == Some("src/main.rs")
26701 }));
26702
26703 let unknown = process_request_inner(
26704 &shared,
26705 77,
26706 ClientRole::Observer,
26707 NodeRequest::InspectWorkspace {
26708 workspace_id: WorkspaceId::new("unknown").unwrap(),
26709 },
26710 )
26711 .await
26712 .unwrap_err();
26713 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26714 std::fs::remove_dir_all(&root).unwrap();
26715 }
26716
26717 #[test]
26718 fn context_pack_source_accepts_only_running_or_successful_exit() {
26719 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26720 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26721 exit_code: Some(0),
26722 }));
26723 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26724 exit_code: Some(1),
26725 }));
26726 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26727 exit_code: None,
26728 }));
26729 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26730 message: "provider failed".to_owned(),
26731 }));
26732 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26733 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26734 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26735 }
26736
26737 #[test]
26738 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26739 let git = git_snapshot_for_parser();
26740 let first = ContextPackRepositoryObservation {
26741 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26742 git: git.clone(),
26743 files: Vec::new(),
26744 };
26745 let second = ContextPackRepositoryObservation {
26746 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26747 git,
26748 files: Vec::new(),
26749 };
26750
26751 let error = stable_context_pack_repository(first, second).unwrap_err();
26752
26753 assert_eq!(
26754 error.code,
26755 NodeFailureCode::ContextPackMaterializationFailed,
26756 );
26757 }
26758
26759 #[test]
26760 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26761 let git = git_snapshot_for_parser();
26762 let first = ContextPackRepositoryObservation {
26763 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26764 git: git.clone(),
26765 files: vec![ContextPackRepositoryFileSource::utf8(
26766 "README.md",
26767 "first\n".to_owned(),
26768 6,
26769 )],
26770 };
26771 let second = ContextPackRepositoryObservation {
26772 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26773 git,
26774 files: vec![ContextPackRepositoryFileSource::utf8(
26775 "README.md",
26776 "other\n".to_owned(),
26777 6,
26778 )],
26779 };
26780
26781 let error = stable_context_pack_repository(first, second).unwrap_err();
26782
26783 assert_eq!(
26784 error.code,
26785 NodeFailureCode::ContextPackMaterializationFailed,
26786 );
26787 }
26788
26789 #[tokio::test]
26790 async fn native_session_catalog_zero_active_session_fixture() {
26791 let workspace_root = temporary_workspace_root("native-catalog-workspace");
26792 let history_root = temporary_workspace_root("native-catalog-history");
26793 let outside_root = temporary_workspace_root("native-catalog-outside");
26794 std::fs::create_dir_all(&workspace_root).unwrap();
26795 std::fs::create_dir_all(&history_root).unwrap();
26796 std::fs::create_dir_all(&outside_root).unwrap();
26797 let transcript = |session_id: &str, cwd: &Path, title: &str| {
26798 [
26799 serde_json::json!({
26800 "type": "user",
26801 "sessionId": session_id,
26802 "cwd": cwd.to_str().unwrap(),
26803 "message": { "content": "raw user text must not cross the catalog wire" }
26804 }),
26805 serde_json::json!({
26806 "type": "assistant",
26807 "sessionId": session_id,
26808 "cwd": cwd.to_str().unwrap(),
26809 "message": { "model": "claude-sonnet", "content": "raw answer" }
26810 }),
26811 serde_json::json!({
26812 "type": "ai-title",
26813 "sessionId": session_id,
26814 "aiTitle": title
26815 }),
26816 ]
26817 .into_iter()
26818 .map(|record| record.to_string())
26819 .collect::<Vec<_>>()
26820 .join("\n")
26821 };
26822 std::fs::write(
26823 history_root.join("stable-session.jsonl"),
26824 transcript("stable-session", &workspace_root, "Workspace review"),
26825 )
26826 .unwrap();
26827 std::fs::write(
26828 history_root.join("outside-session.jsonl"),
26829 transcript("outside-session", &outside_root, "Outside review"),
26830 )
26831 .unwrap();
26832 let history = NativeHistoryConfig::new(vec![
26833 NativeHistoryRoot::new(
26834 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26835 HistorySourceLayout::SingleNdjson,
26836 history_root.clone(),
26837 )
26838 .unwrap(),
26839 ])
26840 .unwrap();
26841 let catalog = active_registry().unwrap();
26842 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26843 assert!(handle.snapshot().sessions.is_empty());
26844 let workspace_id = WorkspaceId::new("primary").unwrap();
26845 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26846 let mut shared = NodeShared::new(
26847 handle,
26848 "fixture-token".to_owned(),
26849 NodeId::new("node-native-catalog").unwrap(),
26850 vec![workspace],
26851 vec![agent("claude")],
26852 );
26853 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26854 NativeSessionCatalogAuthority::new(history),
26855 )));
26856 let route = NativeSessionCatalogRoute::workspace(
26857 workspace_id.clone(),
26858 agent("claude"),
26859 );
26860 let request = NodeRequest::CatalogNativeSessions {
26861 route: route.clone(),
26862 limit: 2,
26863 };
26864 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26865 .await
26866 .unwrap_err();
26867 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26868 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26869 .await
26870 .unwrap();
26871 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26872 panic!("native catalog returned a different response");
26873 };
26874 let summary = summary.expect("node must emit native session catalog summary metadata");
26875 assert_eq!(entries.len(), 1);
26876 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26877 assert_eq!(entries[0].external_group, None);
26878 assert_eq!(entries[0].record_id, None);
26879 assert_eq!(entries[0].title, None);
26880 assert_eq!(entries[0].model, None);
26881 assert_eq!(entries[0].message_count, 0);
26882 let encoded_entries = serde_json::to_string(&entries).unwrap();
26883 assert!(!encoded_entries.contains("stable-session"));
26884 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26885 let page_request = NodeRequest::PageNativeSessions {
26886 route: route.clone(),
26887 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26888 catalog_revision: summary.catalog_revision,
26889 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26890 after_selection_id: None,
26891 limit: 2,
26892 };
26893 let observer = process_request_inner(
26894 &shared,
26895 7,
26896 ClientRole::Observer,
26897 page_request.clone(),
26898 )
26899 .await
26900 .unwrap_err();
26901 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26902 let page_response = process_request_inner(
26903 &shared,
26904 7,
26905 ClientRole::Operator,
26906 page_request.clone(),
26907 )
26908 .await
26909 .unwrap();
26910 assert!(matches!(
26911 page_response,
26912 NodeResponse::NativeSessionsPaged { page, .. }
26913 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26914 ));
26915 let stale = process_request_inner(
26916 &shared,
26917 7,
26918 ClientRole::Operator,
26919 NodeRequest::PageNativeSessions {
26920 route: route.clone(),
26921 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26922 catalog_revision: summary.catalog_revision.wrapping_add(1),
26923 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26924 after_selection_id: None,
26925 limit: 2,
26926 },
26927 )
26928 .await
26929 .unwrap_err();
26930 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26931 let selection = NativeSessionSelection {
26932 route: route.clone(),
26933 catalog_revision: summary.catalog_revision,
26934 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26935 selection_id: entries[0].selection_id.clone(),
26936 };
26937 let preview_response = process_request_inner(
26938 &shared,
26939 7,
26940 ClientRole::Operator,
26941 NodeRequest::PreviewNativeSession {
26942 selection: selection.clone(),
26943 message_limit: 1,
26944 },
26945 )
26946 .await
26947 .unwrap();
26948 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26949 panic!("native preview returned a different response");
26950 };
26951 assert_eq!(preview.messages.len(), 1);
26952 assert!(preview.message_count_exact);
26953 assert_eq!(preview.messages[0].text, "raw answer");
26954 assert!(preview.truncated);
26955
26956 let index_request = NodeRequest::IndexNativeSession {
26957 selection: selection.clone(),
26958 display_name: "Saved Claude".to_owned(),
26959 };
26960 let observer = process_request_inner(
26961 &shared,
26962 7,
26963 ClientRole::Observer,
26964 index_request.clone(),
26965 )
26966 .await
26967 .unwrap_err();
26968 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26969 let without_controller = process_request_inner(
26970 &shared,
26971 7,
26972 ClientRole::Operator,
26973 index_request.clone(),
26974 )
26975 .await
26976 .unwrap_err();
26977 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26978 let acquired = process_request_inner(
26979 &shared,
26980 7,
26981 ClientRole::Operator,
26982 NodeRequest::AcquireController { lease_ms: 5_000 },
26983 )
26984 .await
26985 .unwrap();
26986 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26987 let indexed = process_request_inner(
26988 &shared,
26989 7,
26990 ClientRole::Operator,
26991 index_request,
26992 )
26993 .await
26994 .unwrap();
26995 assert!(indexed.native_session_index_contract_is_valid());
26996 let NodeResponse::NativeSessionIndexed {
26997 selection: echoed_selection,
26998 record,
26999 } = indexed else {
27000 panic!("native session index returned a different response");
27001 };
27002 assert_eq!(echoed_selection, selection);
27003 let recataloged = process_request_inner(
27004 &shared,
27005 7,
27006 ClientRole::Operator,
27007 NodeRequest::CatalogNativeSessions {
27008 route,
27009 limit: 2,
27010 },
27011 )
27012 .await
27013 .unwrap();
27014 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27015 panic!("native catalog returned a different response after indexing");
27016 };
27017 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27018
27019 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27020 let external_catalog = process_request_inner(
27021 &shared,
27022 7,
27023 ClientRole::Operator,
27024 NodeRequest::CatalogNativeSessions {
27025 route: unregistered_route.clone(),
27026 limit: 2,
27027 },
27028 )
27029 .await
27030 .unwrap();
27031 let external_json = serde_json::to_string(&external_catalog).unwrap();
27032 assert!(!external_json.contains("outside-session"));
27033 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27034 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27035 assert!(!external_json.contains("\"session_id\""));
27036 assert!(!external_json.contains("\"cwd\""));
27037 assert!(!external_json.contains("\"path\""));
27038 let NodeResponse::NativeSessionsCataloged {
27039 route: echoed_route,
27040 entries: external_entries,
27041 summary: Some(external_summary),
27042 } = external_catalog
27043 else {
27044 panic!("unregistered native catalog returned a different response");
27045 };
27046 assert_eq!(echoed_route, unregistered_route);
27047 assert_eq!(external_entries.len(), 1);
27048 assert_eq!(external_entries[0].record_id, None);
27049 let external_group = external_entries[0]
27050 .external_group
27051 .as_ref()
27052 .expect("unregistered session must have an external group");
27053 assert_eq!(
27054 external_group.kind,
27055 gate4agent_types::NativeSessionExternalGroupKind::Project,
27056 );
27057 assert!(external_group.group_id.starts_with("external-"));
27058 assert_eq!(
27059 external_group.display_name,
27060 outside_root.file_name().unwrap().to_string_lossy(),
27061 );
27062 let external_selection = NativeSessionSelection {
27063 route: unregistered_route,
27064 catalog_revision: external_summary.catalog_revision,
27065 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27066 selection_id: external_entries[0].selection_id.clone(),
27067 };
27068 let external_preview = process_request_inner(
27069 &shared,
27070 7,
27071 ClientRole::Operator,
27072 NodeRequest::PreviewNativeSession {
27073 selection: external_selection.clone(),
27074 message_limit: 1,
27075 },
27076 )
27077 .await
27078 .unwrap();
27079 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27080 assert!(!external_preview_json.contains("outside-session"));
27081 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27082 assert!(!external_preview_json.contains("\"session_id\""));
27083 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27084 panic!("unregistered native preview returned a different response");
27085 };
27086 assert_eq!(preview.messages.len(), 1);
27087 let external_index = process_request_inner(
27088 &shared,
27089 7,
27090 ClientRole::Operator,
27091 NodeRequest::IndexNativeSession {
27092 selection: external_selection,
27093 display_name: "External Claude".to_owned(),
27094 },
27095 )
27096 .await
27097 .unwrap_err();
27098 assert_eq!(
27099 external_index.code,
27100 NodeFailureCode::WorkspaceRegistrationRequired,
27101 );
27102 let record_response = process_request_inner(
27103 &shared,
27104 7,
27105 ClientRole::Operator,
27106 NodeRequest::PreviewSessionRecord {
27107 record_id: record.record_id.clone(),
27108 message_limit: 2,
27109 },
27110 )
27111 .await
27112 .unwrap();
27113 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27114 panic!("record preview returned a different response");
27115 };
27116 assert_eq!(record_id, record.record_id);
27117 assert_eq!(preview.messages.len(), 2);
27118 let encoded = serde_json::to_string(&preview).unwrap();
27119 assert!(!encoded.contains("stable-session"));
27120 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27121 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27122 panic!("record preview history summary returned a different response");
27123 };
27124 let summaries = events
27125 .iter()
27126 .filter_map(|envelope| match &envelope.event {
27127 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27128 if record_id == &record.record_id =>
27129 {
27130 Some(*summary)
27131 }
27132 _ => None,
27133 })
27134 .collect::<Vec<_>>();
27135 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27136 let summary_json = serde_json::to_string(&summaries).unwrap();
27137 for private in [
27138 "stable-session",
27139 "raw user text must not cross the catalog wire",
27140 "raw answer",
27141 "Workspace review",
27142 "claude-sonnet",
27143 ] {
27144 assert!(!summary_json.contains(private), "{private}");
27145 }
27146 let identity = record.provider_session.clone().unwrap();
27147 assert_eq!(
27148 shared.revalidate_session_record_preview(&record, &identity),
27149 Ok(())
27150 );
27151 shared.remove_record_memory(&record.record_id);
27152 assert_eq!(
27153 shared
27154 .revalidate_session_record_preview(&record, &identity)
27155 .unwrap_err()
27156 .code,
27157 NodeFailureCode::UnknownSessionRecord
27158 );
27159 let released = process_request_inner(
27160 &shared,
27161 7,
27162 ClientRole::Operator,
27163 NodeRequest::ReleaseController,
27164 )
27165 .await
27166 .unwrap();
27167 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27168 assert!(shared.controller_state().is_none());
27169 std::fs::remove_dir_all(workspace_root).unwrap();
27170 std::fs::remove_dir_all(history_root).unwrap();
27171 std::fs::remove_dir_all(outside_root).unwrap();
27172 }
27173
27174 #[tokio::test]
27175 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27176 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27177 std::fs::create_dir_all(&history_root).unwrap();
27178 let history = NativeHistoryConfig::new(vec![
27179 NativeHistoryRoot::new(
27180 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27181 HistorySourceLayout::SingleNdjson,
27182 history_root.clone(),
27183 )
27184 .unwrap(),
27185 ])
27186 .unwrap();
27187 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27188 let held = catalog.lock().unwrap();
27189 let result = timeout(
27190 Duration::from_secs(1),
27191 run_native_session_catalog_operation(
27192 Arc::clone(&catalog),
27193 "native session catalog",
27194 |_| Ok::<(), std::convert::Infallible>(()),
27195 ),
27196 )
27197 .await
27198 .expect("concurrent native catalog request must not wait for the operation deadline")
27199 .unwrap_err();
27200 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27201 drop(held);
27202 std::fs::remove_dir_all(history_root).unwrap();
27203 }
27204
27205 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27206 let unique = std::time::SystemTime::now()
27207 .duration_since(std::time::UNIX_EPOCH)
27208 .unwrap()
27209 .as_nanos();
27210 std::env::temp_dir().join(format!(
27211 "gate4agent-node-{label}-{}-{unique}",
27212 std::process::id(),
27213 ))
27214 }
27215
27216 fn git_snapshot_for_parser() -> GitSnapshot {
27217 GitSnapshot {
27218 is_repository: true,
27219 branch: Some("main".to_owned()),
27220 status: Vec::new(),
27221 recent_commits: Vec::new(),
27222 worktrees: Vec::new(),
27223 managed_worktree: None,
27224 truncated: false,
27225 diagnostic: None,
27226 }
27227 }
27228
27229 #[tokio::test]
27230 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27231 let root = temporary_workspace_root("managed-git-scope");
27232 std::fs::create_dir_all(&root).unwrap();
27233 run_test_git(&root, &["init"]);
27234 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27235 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27236 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27237 run_test_git(&root, &["add", "seed.txt"]);
27238 run_test_git(&root, &["commit", "-m", "seed"]);
27239 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27240
27241 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27242 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27243 let catalog = active_registry().unwrap();
27244 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27245 let shared = NodeShared::new(
27246 handle,
27247 "fixture-token".to_owned(),
27248 NodeId::new("node-1").unwrap(),
27249 vec![workspace],
27250 vec![agent("claude")],
27251 );
27252 let mut lease = ManagedWorktreeLeaseRecord {
27253 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27254 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27255 workspace_id: workspace_id.clone(),
27256 profile_id: WorktreeProfileId::new("default").unwrap(),
27257 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27258 target_root: root.to_string_lossy().into_owned(),
27259 branch: "gate4agent/a".to_owned(),
27260 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27261 expected_head: None,
27262 retention: ManagedWorktreeRetention::Retain,
27263 state: ManagedWorktreeLeaseState::InUse,
27264 session_holders: vec![ManagedWorktreeSessionHolder {
27265 incarnation_id: shared.incarnation_id,
27266 instance_id: AgentInstanceId(7),
27267 generation: SessionGeneration(1),
27268 }],
27269 record_holders: Vec::new(),
27270 cleanup_failure: None,
27271 created_at_unix_ms: 1,
27272 updated_at_unix_ms: 1,
27273 };
27274 *shared
27275 .managed_worktrees
27276 .lock()
27277 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27278 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27279
27280 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27281 let scope = inspection.git.managed_worktree.unwrap();
27282 assert_eq!(scope.lease_id, lease.lease_id);
27283 assert_eq!(scope.branch, "gate4agent/a");
27284 assert_eq!(scope.active_session_count, 1);
27285
27286 lease.branch = "gate4agent/b".to_owned();
27287 *shared
27288 .managed_worktrees
27289 .lock()
27290 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27291 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27292 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27293 assert!(mismatch.git.managed_worktree.is_none());
27294 std::fs::remove_dir_all(root).unwrap();
27295 }
27296
27297 #[tokio::test]
27298 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27299 let root = temporary_workspace_root("workspace-file-write-controller");
27300 std::fs::create_dir_all(root.join("src")).unwrap();
27301 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27302 let workspace_id = WorkspaceId::new("primary").unwrap();
27303 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27304 let catalog = active_registry().unwrap();
27305 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27306 let shared = NodeShared::new(
27307 handle,
27308 "fixture-token".to_owned(),
27309 NodeId::new("node-1").unwrap(),
27310 vec![workspace],
27311 vec![agent("claude")],
27312 );
27313 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27314 let expected_revision = workspace_file_revision(b"old\n");
27315 let request = NodeRequest::WriteWorkspaceFile {
27316 workspace_id: workspace_id.clone(),
27317 path: path.clone(),
27318 expected_revision: expected_revision.clone(),
27319 text: "new\n".to_owned(),
27320 };
27321 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27322 .await
27323 .unwrap_err();
27324 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27325 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27326 .await
27327 .unwrap_err();
27328 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27329 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27330 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27331 let NodeResponse::WorkspaceFileWritten { file } = response else {
27332 panic!("workspace file write returned another response");
27333 };
27334 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27335 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27336 let stale = process_request_inner(
27337 &shared,
27338 77,
27339 ClientRole::Operator,
27340 NodeRequest::WriteWorkspaceFile {
27341 workspace_id,
27342 path,
27343 expected_revision,
27344 text: "lost update\n".to_owned(),
27345 },
27346 )
27347 .await
27348 .unwrap_err();
27349 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27350 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27351 std::fs::remove_dir_all(root).unwrap();
27352 }
27353
27354 #[tokio::test]
27355 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27356 let root = temporary_workspace_root("workspace-entry-create-controller");
27357 std::fs::create_dir_all(root.join("src")).unwrap();
27358 let workspace_id = WorkspaceId::new("primary").unwrap();
27359 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27360 let catalog = active_registry().unwrap();
27361 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27362 let shared = NodeShared::new(
27363 handle,
27364 "fixture-token".to_owned(),
27365 NodeId::new("node-1").unwrap(),
27366 vec![workspace],
27367 vec![agent("claude")],
27368 );
27369 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27370 let request = NodeRequest::CreateWorkspaceFile {
27371 workspace_id: workspace_id.clone(),
27372 path: path.clone(),
27373 };
27374 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27375 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27376 }));
27377 assert!(!request_uses_unnegotiated_capability(
27378 &request,
27379 &baseline_capabilities().unwrap(),
27380 ));
27381 assert!(request_uses_unnegotiated_capability(&request, &[]));
27382 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27383 .await
27384 .unwrap_err();
27385 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27386 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27387 .await
27388 .unwrap_err();
27389 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27390 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27391
27392 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27393 .await
27394 .unwrap();
27395 let NodeResponse::WorkspaceFileCreated { file } = response else {
27396 panic!("workspace file create returned another response");
27397 };
27398 assert_eq!(file.workspace_id, workspace_id);
27399 assert_eq!(file.path, path);
27400 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27401 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27402
27403 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27404 .await
27405 .unwrap_err();
27406 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27407
27408 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27409 let response = process_request_inner(
27410 &shared,
27411 77,
27412 ClientRole::Operator,
27413 NodeRequest::CreateWorkspaceDirectory {
27414 workspace_id: workspace_id.clone(),
27415 path: directory_path.clone(),
27416 },
27417 )
27418 .await
27419 .unwrap();
27420 assert_eq!(
27421 response,
27422 NodeResponse::WorkspaceDirectoryCreated {
27423 workspace_id: workspace_id.clone(),
27424 entry: WorkspaceEntry {
27425 relative_path: directory_path,
27426 kind: WorkspaceEntryKind::Directory,
27427 },
27428 },
27429 );
27430 assert!(root.join("src/nested").is_dir());
27431
27432 let missing_parent = process_request_inner(
27433 &shared,
27434 77,
27435 ClientRole::Operator,
27436 NodeRequest::CreateWorkspaceFile {
27437 workspace_id: workspace_id.clone(),
27438 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27439 },
27440 )
27441 .await
27442 .unwrap_err();
27443 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27444 let non_directory_parent = process_request_inner(
27445 &shared,
27446 77,
27447 ClientRole::Operator,
27448 NodeRequest::CreateWorkspaceFile {
27449 workspace_id,
27450 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27451 },
27452 )
27453 .await
27454 .unwrap_err();
27455 assert_eq!(
27456 non_directory_parent.code,
27457 NodeFailureCode::RepositoryParentNotDirectory,
27458 );
27459 std::fs::remove_dir_all(root).unwrap();
27460 }
27461
27462 #[tokio::test]
27463 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27464 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27465 std::fs::create_dir_all(root.join("src")).unwrap();
27466 #[cfg(windows)]
27467 let root = std::fs::canonicalize(root).unwrap();
27468 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27469 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27470 let barrier = Arc::new(std::sync::Barrier::new(2));
27471 let done = Arc::new(AtomicBool::new(false));
27472 let task_commit_state = Arc::clone(&commit_state);
27473 let task_barrier = Arc::clone(&barrier);
27474 let task_done = Arc::clone(&done);
27475 let task_root = root.clone();
27476 let task = tokio::task::spawn_blocking(move || {
27477 task_barrier.wait();
27478 let result = create_workspace_file_on_disk(
27479 &task_root,
27480 &path,
27481 &task_commit_state,
27482 );
27483 task_done.store(true, Ordering::Release);
27484 result
27485 });
27486
27487 let error = settle_workspace_entry_create(
27488 task,
27489 Arc::clone(&commit_state),
27490 Duration::from_millis(10),
27491 "fixture deadline",
27492 "fixture task failed",
27493 )
27494 .await
27495 .unwrap_err();
27496 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27497 assert_eq!(
27498 commit_state.load(Ordering::Acquire),
27499 WORKSPACE_ENTRY_CREATE_CANCELED,
27500 );
27501
27502 barrier.wait();
27503 timeout(Duration::from_secs(1), async {
27504 while !done.load(Ordering::Acquire) {
27505 tokio::task::yield_now().await;
27506 }
27507 })
27508 .await
27509 .expect("canceled blocking create did not settle");
27510 assert!(!root.join("src/late.rs").exists());
27511 std::fs::remove_dir_all(root).unwrap();
27512 }
27513
27514}