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 let overlay = NativeMcpServerLaunchOverlay::new(
3690 provider.clone(),
3691 launch.server_name.as_str(),
3692 prepared.helper_program().as_os_str().to_os_string(),
3693 launch.args.iter().map(OsString::from).collect(),
3694 env_entries,
3695 launch.scrub_env.iter().map(OsString::from).collect(),
3696 ).map_err(|_| failure(
3697 NodeFailureCode::HarnessMcpUnavailable,
3698 "harness MCP launch overlay is unavailable",
3699 ))?;
3700 control
3701 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3702 .map_err(|_| failure(
3703 NodeFailureCode::HarnessMcpUnavailable,
3704 "harness MCP launch overlay could not be installed",
3705 ))?;
3706 tracing::info!(
3712 node_id = %self.node_id,
3713 instance_id = ?instance_id,
3714 provider = %provider,
3715 mode = ?mode,
3716 "harness MCP overlay installed for spawn",
3717 );
3718 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3719 } else {
3720 None
3721 };
3722 let prepared_materialization = self.prepare_session_materialization(
3723 &address,
3724 &provider,
3725 mode,
3726 environment_profile,
3727 bundle,
3728 context,
3729 managed_authority.cloned(),
3730 )?;
3731 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3732 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3733 None => (None, None),
3734 };
3735 let materialization_id = materialization_guard
3736 .as_ref()
3737 .and_then(SessionMaterializationGuard::id)
3738 .cloned();
3739 let mut environment_selection = SpawnPreparer::select_environment(self,
3740 instance_id,
3741 &provider,
3742 mode,
3743 environment_profile,
3744 )?;
3745 let provider_home = materialization_guard
3748 .as_ref()
3749 .and_then(SessionMaterializationGuard::provider_home);
3750 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3751 &provider,
3752 mode,
3753 approval_level,
3754 network_allowlist,
3755 provider_home,
3756 )?;
3757 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3758 (prepared, args) if args.is_empty() => prepared,
3759 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3760 overlay.append_extra_args(args).map_err(|_| {
3761 failure(
3762 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3763 "network allowlist overlay could not merge with instance launch args",
3764 )
3765 })?;
3766 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3767 }
3768 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3769 Some(PreparedNativeLaunchOverlay::Instance(
3770 overlay
3771 .into_instance_with_extra_args(args)
3772 .map_err(|_| {
3773 failure(
3774 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3775 "network allowlist overlay could not merge with environment overlay",
3776 )
3777 })?,
3778 ))
3779 }
3780 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3781 NativeInstanceLaunchOverlay::new(
3782 provider.clone(),
3783 match mode {
3784 SessionMode::Pty => TransportKind::Pty,
3785 SessionMode::Acp => TransportKind::Acp,
3786 SessionMode::Inline => TransportKind::Pipe,
3787 },
3788 Vec::new(),
3789 args,
3790 )
3791 .map_err(|_| {
3792 failure(
3793 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3794 "network allowlist overlay is invalid for this transport",
3795 )
3796 })?,
3797 )),
3798 };
3799 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3802 (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3803 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3804 match accounts.provision(m4a_provider, &session_key) {
3805 Ok(account) => {
3806 let identity = account.env_mutations();
3807 let merged = match environment_overlay {
3808 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3809 .map(PreparedNativeLaunchOverlay::Instance),
3810 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3811 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3812 }
3813 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3814 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3815 }
3816 }
3817 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3818 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3819 self.m4a_live
3820 .lock()
3821 .unwrap_or_else(|p| p.into_inner())
3822 .insert(account.nick.clone(), (instance_id, provider.clone()));
3823 Some(merged)
3824 }
3825 Err(error) => {
3826 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3827 environment_overlay
3828 }
3829 }
3830 }
3831 (SessionMode::Pty, Some(_), None) => {
3832 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3833 environment_overlay
3834 }
3835 _ => environment_overlay,
3836 };
3837 let mut instance_overlay = environment_overlay
3838 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3839 .transpose()?
3840 .flatten();
3841 Ok(PreparedSpawn {
3842 instance_overlay,
3843 environment_selection,
3844 materialization_guard,
3845 harness_mcp_overlay,
3846 #[cfg(feature = "dig2-station-probe")]
3847 browser_station_lease_guard,
3848 materialization_id,
3849 })
3850 }
3851}
3852
3853struct SessionRecords {
3854 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3855}
3856
3857struct SpawnIdempotencyCache {
3858 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3859}
3860
3861struct SpawnIdempotencyEntry {
3862 value: SpawnIdempotencyValue,
3863 expires_at: Instant,
3864}
3865
3866enum SpawnIdempotencyValue {
3867 Standard {
3868 spec: SpawnSpec,
3869 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3870 },
3871 Managed {
3872 request: ManagedWorktreeSpawnRequest,
3873 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3874 },
3875 HarnessMcp,
3876}
3877
3878enum DurableManagedSpawnReplayDecision {
3879 Reserved,
3880 PendingLinked,
3881 Committed(ManagedWorktreeSpawnReceipt),
3882}
3883
3884impl SpawnIdempotencyValue {
3885 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3886 match self {
3887 Self::Standard { result, .. } => result
3888 .as_ref()
3889 .ok()
3890 .and_then(|receipt| receipt.context.as_ref()),
3891 Self::Managed { result, .. } => result
3892 .as_ref()
3893 .ok()
3894 .and_then(|receipt| receipt.spawn.context.as_ref()),
3895 Self::HarnessMcp => None,
3896 }
3897 .is_some_and(|receipt| &receipt.id == context_id)
3898 }
3899}
3900
3901#[derive(Clone, Copy)]
3902struct ControllerLease {
3903 connection_id: u64,
3904 expires_at: Instant,
3905}
3906
3907struct NodeEventHistory {
3908 last_sequence: u64,
3909 replay_floor_sequence: u64,
3910 events: VecDeque<NodeEventEnvelope>,
3911 record_providers: BTreeMap<SessionRecordId, AgentId>,
3912 removed_record_providers: BTreeMap<u64, AgentId>,
3913}
3914
3915impl NodeEventHistory {
3916 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3917 Self {
3918 last_sequence: 0,
3919 replay_floor_sequence: 1,
3920 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3921 record_providers,
3922 removed_record_providers: BTreeMap::new(),
3923 }
3924 }
3925}
3926
3927impl NodeShared {
3928 fn new_with_incarnation(
3929 handle: Gate4AgentHandle,
3930 access_token: String,
3931 node_id: NodeId,
3932 incarnation_id: NodeIncarnationId,
3933 workspaces: Vec<WorkspaceConfig>,
3934 enabled_providers: Vec<AgentId>,
3935 provider_runtime_statuses: ProviderRuntimeStatuses,
3936 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3937 provider_contracts: Vec<ProviderContractSupport>,
3938 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3939 spawn_profiles: SpawnProfileRegistry,
3940 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3941 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3942 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3943 state_path: Option<PathBuf>,
3944 records: Vec<ManagedSessionRecord>,
3945 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3946 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3947 materializations: Vec<MaterializationOwnershipRecord>,
3948 persistence_warning: Option<String>,
3949 input_settle_timeout_ms: u64,
3950 ) -> Self {
3951 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3952 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3953 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3954 let (harness_mcp_proxy_event_tx, _) =
3955 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3956 let record_providers = records
3957 .iter()
3958 .map(|record| (record.record_id.clone(), record.provider.clone()))
3959 .collect();
3960 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3961 .iter()
3962 .map(|workspace| {
3963 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3964 })
3965 .collect();
3966 for lease in &managed_worktrees {
3967 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3968 }
3969 let managed_worktree_profiles = workspaces.iter()
3970 .map(|workspace| (
3971 workspace.workspace_id.clone(),
3972 workspace.managed_worktree_profiles.clone(),
3973 ))
3974 .collect();
3975 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3976 managed_worktrees,
3977 managed_worktree_tombstones,
3978 ).expect("durable managed worktree registry was validated while loading");
3979 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3980 managed_worktrees.reattach_record_holders(
3981 &managed_worktree_record_holders(&records),
3982 unix_time_ms(),
3983 );
3984 Self {
3985 handle,
3986 access_token,
3987 node_id,
3988 incarnation_id,
3989 started_at_unix_ms: std::time::SystemTime::now()
3990 .duration_since(std::time::UNIX_EPOCH)
3991 .unwrap_or_default()
3992 .as_millis()
3993 .min(u64::MAX as u128) as u64,
3994 workspaces: RwLock::new(
3995 workspaces
3996 .into_iter()
3997 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3998 .collect(),
3999 ),
4000 worktree_service_modes,
4001 managed_worktree_profiles,
4002 managed_worktrees: Mutex::new(managed_worktrees),
4003 enabled_providers,
4004 provider_runtime_statuses,
4005 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4006 provider_runtime_monitor,
4007 provider_contracts,
4008 provider_adapter_contracts,
4009 spawn_profiles,
4010 environment_profiles: RwLock::new(BTreeMap::new()),
4011 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4012 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4013 #[cfg(feature = "dig2-station-probe")]
4014 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4015 bundle_catalog: RwLock::new(BundleCatalog::default()),
4016 delivery_store: Mutex::new(None),
4017 context_pack_store: Mutex::new(None),
4018 context_catalog: RwLock::new(ContextPackCatalog::default()),
4019 native_launch_profile_control,
4020 harness_mcp_registry: None,
4021 native_session_catalog,
4022 session_environment_materializer,
4023 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4024 m4a_live: Mutex::new(BTreeMap::new()),
4025 materializations: Mutex::new(
4026 materializations
4027 .into_iter()
4028 .map(|record| (record.id().clone(), record))
4029 .collect(),
4030 ),
4031 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4032 entries: BTreeMap::new(),
4033 }),
4034 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4035 controller: Mutex::new(None),
4036 history: Mutex::new(NodeEventHistory::new(record_providers)),
4037 event_tx,
4038 terminal_event_tx,
4039 agent_stream_event_tx,
4040 harness_mcp_proxy_event_tx,
4041 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4042 pty_input_wake: tokio::sync::Notify::new(),
4043 pty_input_marks: Mutex::new(BTreeMap::new()),
4044 next_connection_id: AtomicU64::new(1),
4045 next_instance_id: AtomicU64::new(1),
4046 next_command_id: AtomicU64::new(1),
4047 shutdown: AtomicBool::new(false),
4048 shutdown_notify: Notify::new(),
4049 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4050 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4051 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4052 mutation_gate: AsyncMutex::new(()),
4053 session_bindings: Mutex::new(BTreeMap::new()),
4054 session_records: Mutex::new(SessionRecords {
4055 records: records
4056 .into_iter()
4057 .map(|record| (record.record_id.clone(), record))
4058 .collect(),
4059 }),
4060 state_path,
4061 persistence_error: RwLock::new(
4062 persistence_warning
4063 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4064 ),
4065 state_transaction: Mutex::new(()),
4066 input_settle_timeout_ms,
4067 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4068 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4069 shell_efficiency_profile: OnceLock::new(),
4070 native_session_gauge: AtomicUsize::new(0),
4071 connection_loop_iterations: AtomicU64::new(0),
4072 connection_events_sent: AtomicU64::new(0),
4073 rejected_commands_total: AtomicU64::new(0),
4074 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4075 drive_loop_iterations_total: AtomicU64::new(0),
4076 drive_loop_iterations_idle: AtomicU64::new(0),
4077 session_record_retention: SessionRecordRetentionConfig::default(),
4078 retired_records_total: AtomicU64::new(0),
4079 #[cfg(feature = "fixture")]
4080 fixture_semantic_hook_policy: false,
4081 #[cfg(feature = "fixture")]
4082 fixture_spawn_managed_worktree_v2_failure_probe:
4083 SpawnManagedWorktreeV2FailureProbe::default(),
4084 }
4085 }
4086
4087 #[cfg(test)]
4088 fn new(
4089 handle: Gate4AgentHandle,
4090 access_token: String,
4091 node_id: NodeId,
4092 workspaces: Vec<WorkspaceConfig>,
4093 enabled_providers: Vec<AgentId>,
4094 ) -> Self {
4095 Self::new_with_incarnation(
4096 handle,
4097 access_token,
4098 node_id,
4099 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4100 workspaces,
4101 enabled_providers,
4102 ProviderRuntimeStatuses::default(),
4103 None,
4104 Vec::new(),
4105 Vec::new(),
4106 SpawnProfileRegistry::default(),
4107 None,
4108 None,
4109 None,
4110 None,
4111 Vec::new(),
4112 Vec::new(),
4113 Vec::new(),
4114 Vec::new(),
4115 None,
4116 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4117 )
4118 }
4119
4120 fn persistence_error(&self) -> Option<String> {
4121 self.persistence_error
4122 .read()
4123 .unwrap_or_else(|poisoned| poisoned.into_inner())
4124 .clone()
4125 }
4126
4127 async fn admit_provider_runtime(
4128 &self,
4129 provider: &AgentId,
4130 requirement: ProviderRuntimeRequirement,
4131 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4132 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4133 let pipe_transport = monitor.supports_pipe_transport(provider);
4134 let acp_transport = monitor.supports_acp_transport(provider);
4135 let provider = provider.clone();
4136 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4137 let (status, admission) = timeout(
4138 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4139 refresh,
4140 )
4141 .await
4142 .map_err(|_| failure(
4143 NodeFailureCode::BackendBusy,
4144 "provider runtime probe exceeded its bounded deadline",
4145 ))?
4146 .map_err(|_| failure(
4147 NodeFailureCode::BackendOperationFailed,
4148 "provider runtime probe task failed",
4149 ))?;
4150 if let Some(status) = status {
4151 self
4152 .provider_runtime_status_updates
4153 .write()
4154 .unwrap_or_else(|poisoned| poisoned.into_inner())
4155 .insert(status.provider().clone(), status);
4156 }
4157 admission.and_then(|policy| {
4158 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4159 })
4160 } else {
4161 crate::provider_runtime::admit_status(
4167 &self.provider_runtime_statuses,
4168 provider,
4169 false,
4170 false,
4171 requirement,
4172 )
4173 };
4174 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4175 admission.map_err(|error| match error {
4176 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4177 NodeFailureCode::BackendOperationFailed,
4178 "provider launcher is unavailable",
4179 ),
4180 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4181 NodeFailureCode::UnsupportedCapability,
4182 "provider semantic capability is not verified",
4183 ),
4184 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4185 NodeFailureCode::BackendBusy,
4186 "provider runtime probe is already in progress",
4187 ),
4188 })
4189 }
4190
4191 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4197 match self.provider_runtime_monitor.as_deref() {
4198 Some(monitor) => monitor.supports_pipe_transport(provider),
4199 None => false,
4200 }
4201 }
4202
4203 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4209 match self.provider_runtime_monitor.as_deref() {
4210 Some(monitor) => monitor.supports_acp_transport(provider),
4211 None => false,
4212 }
4213 }
4214
4215 fn admit_declared_hook_semantics(
4231 &self,
4232 provider: &AgentId,
4233 policy: ProviderRuntimePolicy,
4234 ) -> ProviderRuntimePolicy {
4235 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4236 return policy;
4237 }
4238 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4239 contract.provider == *provider && contract.family == AdapterFamily::Hook
4240 });
4241 if !declares_hook {
4242 return policy;
4243 }
4244 ProviderRuntimePolicy::new(
4245 policy.raw_pty_lifecycle,
4246 policy.semantic_readiness,
4247 policy.structured_prompt,
4248 policy.provider_session_identity,
4249 policy.semantic_resume,
4250 true,
4251 )
4252 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4253 }
4254
4255 fn resolve_network_allowlist_overlay_args(
4270 &self,
4271 provider: &AgentId,
4272 mode: SessionMode,
4273 approval_level: ApprovalLevel,
4274 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4275 provider_home: Option<&Path>,
4276 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4277 let Some(allowlist_id) = network_allowlist else {
4278 return Ok(Vec::new());
4279 };
4280 let catalog = self
4281 .network_allowlist_catalog
4282 .read()
4283 .unwrap_or_else(|poisoned| poisoned.into_inner());
4284 let Some(entry) = catalog.get(allowlist_id) else {
4285 return Err(failure(
4286 NodeFailureCode::UnknownNetworkAllowlist,
4287 "spawn network allowlist is unavailable on this node",
4288 ));
4289 };
4290
4291 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4293 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4294 |message| {
4295 failure(
4296 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4297 &message,
4298 )
4299 },
4300 )?;
4301 if !matches!(mode, SessionMode::Pty) {
4302 return Err(failure(
4303 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4304 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4305 ));
4306 }
4307 let Some(home) = provider_home else {
4308 return Err(failure(
4309 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4310 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4311 ));
4312 };
4313 let domains =
4314 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4315 if domains.is_empty() {
4316 return Err(failure(
4317 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4318 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4319 ));
4320 }
4321 let settings_path = home.join(
4322 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4323 );
4324 let json =
4325 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4326 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4327 |_| {
4328 failure(
4329 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4330 "Claude station network settings sidecar could not be written under ProviderHome",
4331 )
4332 },
4333 )?;
4334 let overlay =
4335 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4336 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4337 }
4338
4339 let Some(native) = entry.provider_native.as_ref() else {
4340 return Ok(Vec::new());
4341 };
4342 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4343 provider.as_str(),
4344 approval_level,
4345 native,
4346 )
4347 .map_err(|message| {
4348 failure(
4349 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4350 &message,
4351 )
4352 })?;
4353 if overlay.is_empty() {
4354 return Ok(Vec::new());
4355 }
4356 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4357 return Err(failure(
4358 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4359 "Codex network_access -c overlay requires PTY or ACP transport",
4360 ));
4361 }
4362 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4363 }
4364
4365 fn resolve_codex_network_access_overlay_args(
4367 &self,
4368 provider: &AgentId,
4369 mode: SessionMode,
4370 approval_level: ApprovalLevel,
4371 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4372 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4373 self.resolve_network_allowlist_overlay_args(
4374 provider,
4375 mode,
4376 approval_level,
4377 network_allowlist,
4378 None,
4379 )
4380 }
4381
4382 fn resolve_environment_profile(
4383 &self,
4384 resolved: &ResolvedSpawnSpec,
4385 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4386 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4392 let catalog = self
4393 .network_allowlist_catalog
4394 .read()
4395 .unwrap_or_else(|poisoned| poisoned.into_inner());
4396 let Some(entry) = catalog.get(allowlist_id) else {
4397 return Err(failure(
4398 NodeFailureCode::UnknownNetworkAllowlist,
4399 "spawn network allowlist is unavailable on this node",
4400 ));
4401 };
4402 if let Some(native) = entry.provider_native.as_ref() {
4405 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4406 resolved.provider.as_str(),
4407 resolved.approval_level,
4408 native,
4409 ) {
4410 return Err(failure(
4411 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4412 &message,
4413 ));
4414 }
4415 }
4416 }
4417 #[cfg(feature = "dig2-station-probe")]
4426 if resolved.browser_profile_id.is_some() {
4427 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4428 match probe_station_reachable() {
4429 Ok(()) => {}
4430 Err(StationProbeError::UnsupportedPlatform)
4431 | Err(StationProbeError::InvalidPipeSuffix) => {
4432 return Err(failure(
4433 NodeFailureCode::BrowserStationProbeUnavailable,
4434 "dig2browser station probe unavailable on this platform",
4435 ));
4436 }
4437 Err(StationProbeError::Unreachable) => {
4438 return Err(failure(
4439 NodeFailureCode::BrowserStationUnreachable,
4440 "dig2browser station named-pipe is unreachable",
4441 ));
4442 }
4443 }
4444 }
4445 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4446 return Ok(None);
4447 };
4448 let profiles = self
4449 .environment_profiles
4450 .read()
4451 .unwrap_or_else(|poisoned| poisoned.into_inner());
4452 let binding = profiles.get(profile_id).ok_or_else(|| {
4453 failure(
4454 NodeFailureCode::UnknownEnvironmentProfile,
4455 "spawn environment profile is unavailable on this node",
4456 )
4457 })?;
4458 if binding.provider != resolved.provider
4459 || binding.native_profile_id(resolved.mode).is_none()
4460 {
4461 return Err(failure(
4462 NodeFailureCode::EnvironmentProfileBindingMismatch,
4463 "spawn environment profile does not match the provider and mode",
4464 ));
4465 }
4466 Ok(Some(ResolvedEnvironmentProfileReceipt {
4470 profile_id: binding.id.clone(),
4471 profile_revision: binding.revision.clone(),
4472 network_allowlist: resolved.network_allowlist.clone(),
4473 browser_profile_id: resolved.browser_profile_id.clone(),
4474 }))
4475 }
4476
4477 fn resolve_bundle(
4478 &self,
4479 resolved: &ResolvedSpawnSpec,
4480 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4481 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4482 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4483 return Ok(None);
4484 };
4485 let catalog = self.bundle_catalog
4486 .read()
4487 .unwrap_or_else(|poisoned| poisoned.into_inner());
4488 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4489 failure(
4490 NodeFailureCode::UnknownBundle,
4491 "spawn bundle is unavailable on this node",
4492 )
4493 })?;
4494 self.bundle_layout(
4495 &resolved.provider,
4496 resolved.mode,
4497 environment_profile,
4498 bundle,
4499 )?;
4500 Ok(Some(bundle.receipt()))
4501 }
4502
4503 fn begin_delivery_stage(
4504 &self,
4505 manifest: DeliveryBundleManifestV2,
4506 ) -> Result<NodeResponse, NodeFailure> {
4507 let mut store = self
4508 .delivery_store
4509 .lock()
4510 .unwrap_or_else(|poisoned| poisoned.into_inner());
4511 let store = store.as_mut().ok_or_else(|| {
4512 failure(
4513 NodeFailureCode::DeliveryStageStorageFailed,
4514 "node delivery store is unavailable",
4515 )
4516 })?;
4517 let begun = store.begin(manifest).map_err(delivery_failure)?;
4518 Ok(NodeResponse::DeliveryStageBegun {
4519 stage_id: begun.stage_id,
4520 manifest_digest: begun.manifest_digest,
4521 missing_blobs: begun.missing_blobs,
4522 })
4523 }
4524
4525 fn put_delivery_blob_chunk(
4526 &self,
4527 stage_id: DeliveryStageId,
4528 blob_digest: DeliveryBlobDigestV1,
4529 offset: u64,
4530 chunk_hex: DeliveryBlobChunkHexV1,
4531 ) -> Result<NodeResponse, NodeFailure> {
4532 let mut store = self
4533 .delivery_store
4534 .lock()
4535 .unwrap_or_else(|poisoned| poisoned.into_inner());
4536 let store = store.as_mut().ok_or_else(|| {
4537 failure(
4538 NodeFailureCode::DeliveryStageStorageFailed,
4539 "node delivery store is unavailable",
4540 )
4541 })?;
4542 let accepted = store
4543 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4544 .map_err(delivery_failure)?;
4545 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4546 stage_id: accepted.stage_id,
4547 blob_digest: accepted.blob_digest,
4548 next_offset: accepted.next_offset,
4549 })
4550 }
4551
4552 fn commit_delivery_stage(
4553 &self,
4554 stage_id: DeliveryStageId,
4555 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4556 let mut store = self
4557 .delivery_store
4558 .lock()
4559 .unwrap_or_else(|poisoned| poisoned.into_inner());
4560 let store = store.as_mut().ok_or_else(|| {
4561 failure(
4562 NodeFailureCode::DeliveryStageStorageFailed,
4563 "node delivery store is unavailable",
4564 )
4565 })?;
4566 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4567 let mut catalog = self
4568 .bundle_catalog
4569 .write()
4570 .unwrap_or_else(|poisoned| poisoned.into_inner());
4571 let mut next_catalog = catalog.clone();
4572 next_catalog
4573 .insert_idempotent(prepared.bundle().clone())
4574 .map_err(|_| {
4575 failure(
4576 NodeFailureCode::DeliveryStageConflict,
4577 "delivery bundle identity conflicts with installed content",
4578 )
4579 })?;
4580 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4581 *catalog = next_catalog;
4582 Ok(receipt)
4583 }
4584
4585 fn abort_delivery_stage(
4586 &self,
4587 stage_id: &DeliveryStageId,
4588 ) -> Result<(), NodeFailure> {
4589 let mut store = self
4590 .delivery_store
4591 .lock()
4592 .unwrap_or_else(|poisoned| poisoned.into_inner());
4593 let store = store.as_mut().ok_or_else(|| {
4594 failure(
4595 NodeFailureCode::DeliveryStageStorageFailed,
4596 "node delivery store is unavailable",
4597 )
4598 })?;
4599 store.abort(stage_id).map_err(delivery_failure)
4600 }
4601
4602 fn resolve_context(
4603 &self,
4604 resolved: &ResolvedSpawnSpec,
4605 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4606 let Some(context_id) = resolved.context_id.as_ref() else {
4607 return Ok(None);
4608 };
4609 self.context_catalog
4610 .read()
4611 .unwrap_or_else(|poisoned| poisoned.into_inner())
4612 .get(context_id)
4613 .map(|context| context.receipt().clone())
4614 .ok_or_else(|| {
4615 failure(
4616 NodeFailureCode::UnknownContextPack,
4617 "spawn context pack is unavailable on this node",
4618 )
4619 })
4620 .map(Some)
4621 }
4622
4623 fn bundle_layout(
4624 &self,
4625 provider: &AgentId,
4626 mode: SessionMode,
4627 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4628 bundle: &NodeBundle,
4629 ) -> Result<BundleProviderLayout, NodeFailure> {
4630 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4631 failure(
4632 NodeFailureCode::BundleBindingMismatch,
4633 "spawn bundle does not match the provider and mode",
4634 )
4635 })?;
4636 if layout != BundleProviderLayout::Codex {
4637 return Ok(layout);
4638 }
4639 let profile = environment_profile.and_then(|receipt| {
4640 let profiles = self
4641 .environment_profiles
4642 .read()
4643 .unwrap_or_else(|poisoned| poisoned.into_inner());
4644 let binding = profiles.get(&receipt.profile_id)?;
4645 if binding.revision != receipt.profile_revision
4646 || &binding.provider != provider
4647 || binding.native_profile_id(mode).is_none()
4648 {
4649 return None;
4650 }
4651 self.environment_materialization_profiles
4652 .read()
4653 .unwrap_or_else(|poisoned| poisoned.into_inner())
4654 .get(&receipt.profile_id)
4655 .cloned()
4656 });
4657 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4658 return Err(failure(
4659 NodeFailureCode::BundleBindingMismatch,
4660 "Codex bundle requires an exact isolated CODEX_HOME profile",
4661 ));
4662 }
4663 Ok(layout)
4664 }
4665
4666 fn select_environment_profile(
4667 &self,
4668 instance_id: AgentInstanceId,
4669 provider: &AgentId,
4670 mode: SessionMode,
4671 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4672 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4673 let Some(environment_profile) = environment_profile else {
4674 return Ok(None);
4675 };
4676 let native_profile_id = {
4677 let profiles = self
4678 .environment_profiles
4679 .read()
4680 .unwrap_or_else(|poisoned| poisoned.into_inner());
4681 let binding = profiles
4682 .get(&environment_profile.profile_id)
4683 .ok_or_else(|| {
4684 failure(
4685 NodeFailureCode::UnknownEnvironmentProfile,
4686 "session environment profile is unavailable on this node",
4687 )
4688 })?;
4689 if &binding.revision != &environment_profile.profile_revision
4690 || &binding.provider != provider
4691 {
4692 return Err(failure(
4693 NodeFailureCode::EnvironmentProfileBindingMismatch,
4694 "session environment profile revision or provider changed",
4695 ));
4696 }
4697 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4698 failure(
4699 NodeFailureCode::EnvironmentProfileBindingMismatch,
4700 "session environment profile does not support this mode",
4701 )
4702 })?
4703 };
4704 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4705 failure(
4706 NodeFailureCode::BackendOperationFailed,
4707 "native environment profile controller is unavailable",
4708 )
4709 })?;
4710 control
4711 .select_native_launch_profile(instance_id, native_profile_id)
4712 .map_err(|_| {
4713 failure(
4714 NodeFailureCode::BackendOperationFailed,
4715 "native environment profile selection failed",
4716 )
4717 })?;
4718 Ok(Some(NativeEnvironmentSelectionGuard {
4719 control: Some(control),
4720 instance_id,
4721 }))
4722 }
4723
4724 fn install_prepared_launch_overlay(
4725 &self,
4726 instance_id: AgentInstanceId,
4727 overlay: PreparedNativeLaunchOverlay,
4728 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4729 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4730 failure(
4731 NodeFailureCode::BackendOperationFailed,
4732 "native launch profile controller is unavailable",
4733 )
4734 })?;
4735 match overlay {
4736 PreparedNativeLaunchOverlay::Environment(overlay) => {
4737 control
4738 .install_native_launch_environment_overlay(instance_id, overlay)
4739 .map_err(|_| failure(
4740 NodeFailureCode::BackendOperationFailed,
4741 "session environment overlay installation failed",
4742 ))?;
4743 Ok(None)
4744 }
4745 PreparedNativeLaunchOverlay::Instance(overlay) => {
4746 control
4747 .install_native_instance_launch_overlay(instance_id, overlay)
4748 .map_err(|_| failure(
4749 NodeFailureCode::BundleBindingMismatch,
4750 "session bundle launch overlay installation failed",
4751 ))?;
4752 Ok(Some(NativeInstanceOverlayGuard {
4753 control: Some(control),
4754 instance_id,
4755 }))
4756 }
4757 }
4758 }
4759
4760 fn environment_materialization_profile(
4761 &self,
4762 environment_profile: &ResolvedEnvironmentProfileReceipt,
4763 ) -> Option<NodeSessionMaterializationProfile> {
4764 self.environment_materialization_profiles
4765 .read()
4766 .unwrap_or_else(|poisoned| poisoned.into_inner())
4767 .get(&environment_profile.profile_id)
4768 .cloned()
4769 }
4770
4771 fn materialization_profile(
4772 &self,
4773 provider: &AgentId,
4774 mode: SessionMode,
4775 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4776 bundle: Option<&ResolvedBundleReceipt>,
4777 context: Option<&ResolvedContextPackReceipt>,
4778 ) -> Result<Option<(
4779 NodeSessionMaterializationProfile,
4780 bool,
4781 Option<BundleProviderLayout>,
4782 )>, NodeFailure> {
4783 let environment = environment_profile
4784 .and_then(|receipt| self.environment_materialization_profile(receipt));
4785 let (bundle, bundle_layout) = match bundle {
4786 Some(receipt) => {
4787 let catalog = self.bundle_catalog.read()
4788 .unwrap_or_else(|poisoned| poisoned.into_inner());
4789 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4790 NodeFailureCode::UnknownBundle,
4791 "session bundle is unavailable on this node",
4792 ))?;
4793 if installed.receipt() != *receipt {
4794 return Err(failure(
4795 NodeFailureCode::BundleBindingMismatch,
4796 "session bundle revision or digest changed",
4797 ));
4798 }
4799 let layout = self.bundle_layout(
4800 provider,
4801 mode,
4802 environment_profile,
4803 installed,
4804 )?;
4805 (Some(installed.clone()), Some(layout))
4806 }
4807 None => (None, None),
4808 };
4809 let has_environment_overlay = environment.is_some() || context.is_some();
4810 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4811 (None, None, None) => None,
4812 (Some(profile), None, None) => Some(Ok(profile)),
4813 (None, Some(bundle), Some(layout)) => {
4814 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4815 }
4816 (Some(profile), Some(bundle), Some(layout)) => {
4817 Some(profile.with_bundle(bundle, layout))
4818 }
4819 _ => {
4820 return Err(failure(
4821 NodeFailureCode::BundleBindingMismatch,
4822 "session bundle provider layout is unavailable",
4823 ));
4824 }
4825 };
4826 let profile = profile
4827 .transpose()
4828 .map_err(|_| failure(
4829 NodeFailureCode::BundleMaterializationFailed,
4830 "session bundle materialization profile is invalid",
4831 ))?;
4832 let profile = match (profile, context) {
4833 (Some(profile), Some(receipt)) => {
4834 let catalog = self.context_catalog
4835 .read()
4836 .unwrap_or_else(|poisoned| poisoned.into_inner());
4837 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4838 NodeFailureCode::UnknownContextPack,
4839 "session context pack is unavailable on this node",
4840 ))?;
4841 if pack.receipt() != receipt {
4842 return Err(failure(
4843 NodeFailureCode::ContextPackMaterializationFailed,
4844 "session context pack identity changed",
4845 ));
4846 }
4847 profile.with_context(pack)
4848 }
4849 (None, 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 NodeSessionMaterializationProfile::from_context(pack)
4864 }
4865 (Some(profile), None) => Ok(profile),
4866 (None, None) => return Ok(None),
4867 }
4868 .map_err(|_| failure(
4869 NodeFailureCode::ContextPackMaterializationFailed,
4870 "session context pack materialization profile is invalid",
4871 ))?;
4872 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4873 }
4874
4875 fn prepare_session_materialization<'a>(
4876 &'a self,
4877 address: &SessionAddress,
4878 provider: &AgentId,
4879 mode: SessionMode,
4880 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4881 bundle: Option<&ResolvedBundleReceipt>,
4882 context: Option<&ResolvedContextPackReceipt>,
4883 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4884 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4885 let Some((profile, has_environment_overlay, bundle_layout)) =
4886 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4887 else {
4888 return Ok(None);
4889 };
4890 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4891 failure(
4892 NodeFailureCode::BackendOperationFailed,
4893 "session environment materializer is unavailable",
4894 )
4895 })?;
4896 let materialization_failure_code = if context.is_some() {
4897 NodeFailureCode::ContextPackMaterializationFailed
4898 } else if bundle.is_some() {
4899 NodeFailureCode::BundleMaterializationFailed
4900 } else {
4901 NodeFailureCode::BackendOperationFailed
4902 };
4903 let id = MaterializationId::new(format!(
4904 "mat-{}-{}-{}",
4905 self.incarnation_id,
4906 address.session.instance_id.0,
4907 address.session.generation.0,
4908 ))
4909 .map_err(|_| {
4910 failure(
4911 NodeFailureCode::BackendOperationFailed,
4912 "session environment materialization identity failed",
4913 )
4914 })?;
4915 let owner = MaterializationOwner::Session {
4916 incarnation_id: self.incarnation_id,
4917 instance_id: address.session.instance_id,
4918 generation: address.session.generation,
4919 };
4920 let mut ownership = materializer
4921 .begin(
4922 id.clone(),
4923 environment_profile.cloned(),
4924 bundle.cloned(),
4925 context.cloned(),
4926 owner,
4927 managed_lease_id,
4928 &profile,
4929 unix_time_ms(),
4930 )
4931 .map_err(|_| {
4932 failure(
4933 materialization_failure_code,
4934 "session materialization preparation failed",
4935 )
4936 })?;
4937 {
4938 let _transaction = self
4939 .state_transaction
4940 .lock()
4941 .unwrap_or_else(|poisoned| poisoned.into_inner());
4942 let duplicate = self
4943 .materializations
4944 .lock()
4945 .unwrap_or_else(|poisoned| poisoned.into_inner())
4946 .insert(id.clone(), ownership.clone());
4947 if duplicate.is_some() {
4948 return Err(failure(
4949 NodeFailureCode::BackendOperationFailed,
4950 "session environment materialization identity conflict",
4951 ));
4952 }
4953 if let Err(error) = self.persist_state_locked() {
4954 self.materializations
4955 .lock()
4956 .unwrap_or_else(|poisoned| poisoned.into_inner())
4957 .remove(&id);
4958 return Err(persistence_failure(error));
4959 }
4960 }
4961 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4962 Ok(environment) => environment,
4963 Err(_) => {
4964 let transaction = self
4965 .state_transaction
4966 .lock()
4967 .unwrap_or_else(|poisoned| poisoned.into_inner());
4968 self.materializations
4969 .lock()
4970 .unwrap_or_else(|poisoned| poisoned.into_inner())
4971 .insert(id.clone(), ownership.clone());
4972 if self.persist_state_locked().is_err() {
4973 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4974 }
4975 drop(transaction);
4976 if ownership.state() == MaterializationState::CleanupRequired {
4977 let _ = self.cleanup_materialization(&id);
4978 }
4979 return Err(failure(
4980 materialization_failure_code,
4981 "session materialization failed",
4982 ));
4983 }
4984 };
4985 {
4986 let _transaction = self
4987 .state_transaction
4988 .lock()
4989 .unwrap_or_else(|poisoned| poisoned.into_inner());
4990 self.materializations
4991 .lock()
4992 .unwrap_or_else(|poisoned| poisoned.into_inner())
4993 .insert(id.clone(), ownership.clone());
4994 if let Err(error) = self.persist_state_locked() {
4995 drop(_transaction);
4996 let _ = self.cleanup_materialization(&id);
4997 return Err(persistence_failure(error));
4998 }
4999 }
5000 let guard = SessionMaterializationGuard {
5001 shared: self,
5002 id: Some(id),
5003 provider_home: Some(ownership.provider_home().to_path_buf()),
5004 };
5005 let bundle_arguments = match bundle {
5006 Some(receipt) => {
5007 let catalog = self.bundle_catalog.read()
5008 .unwrap_or_else(|poisoned| poisoned.into_inner());
5009 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5010 NodeFailureCode::UnknownBundle,
5011 "session bundle is unavailable on this node",
5012 ))?;
5013 if installed.receipt() != *receipt {
5014 return Err(failure(
5015 NodeFailureCode::BundleBindingMismatch,
5016 "session bundle revision or digest changed",
5017 ));
5018 }
5019 let layout = bundle_layout.ok_or_else(|| failure(
5020 NodeFailureCode::BundleBindingMismatch,
5021 "materialized bundle provider layout is unavailable",
5022 ))?;
5023 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5024 failure(
5025 NodeFailureCode::BundleMaterializationFailed,
5026 "session bundle byte revalidation failed",
5027 )
5028 })?;
5029 Some(bundle_launch_arguments(
5030 layout,
5031 ownership.bundle_root(),
5032 ownership.provider_home(),
5033 ).map_err(|_| failure(
5034 NodeFailureCode::BundleBindingMismatch,
5035 "session bundle launch binding is invalid",
5036 ))?)
5037 }
5038 None => None,
5039 };
5040 if let Some(receipt) = context {
5041 materializer
5042 .revalidate_context(&ownership, receipt)
5043 .map_err(|_| failure(
5044 NodeFailureCode::ContextPackMaterializationFailed,
5045 "session context pack byte revalidation failed",
5046 ))?;
5047 }
5048 let transport = match mode {
5049 SessionMode::Pty => TransportKind::Pty,
5050 SessionMode::Inline => TransportKind::Pipe,
5051 SessionMode::Acp => TransportKind::Acp,
5052 };
5053 let overlay = if let Some(extra_args) = bundle_arguments {
5054 Some(PreparedNativeLaunchOverlay::Instance(
5055 NativeInstanceLaunchOverlay::new(
5056 provider.clone(),
5057 transport,
5058 if has_environment_overlay {
5059 environment
5060 } else {
5061 Vec::new()
5062 },
5063 extra_args,
5064 )
5065 .map_err(|_| {
5066 failure(
5067 NodeFailureCode::BundleBindingMismatch,
5068 "session bundle launch overlay is invalid",
5069 )
5070 })?,
5071 ))
5072 } else if has_environment_overlay {
5073 let context_only = environment_profile.is_none()
5074 && bundle.is_none()
5075 && context.is_some();
5076 let overlay = if context_only {
5077 NativeLaunchEnvironmentOverlay::new_context_root(
5078 provider.clone(),
5079 transport,
5080 environment,
5081 )
5082 .map_err(|_| {
5083 failure(
5084 NodeFailureCode::ContextPackMaterializationFailed,
5085 "context-only launch overlay is invalid",
5086 )
5087 })?
5088 } else {
5089 NativeLaunchEnvironmentOverlay::new(
5090 provider.clone(),
5091 transport,
5092 environment,
5093 )
5094 .map_err(|_| {
5095 failure(
5096 NodeFailureCode::BackendOperationFailed,
5097 "session environment overlay is invalid",
5098 )
5099 })?
5100 };
5101 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5102 } else {
5103 None
5104 };
5105 Ok(Some((overlay, guard)))
5106 }
5107
5108 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5109 let Some(mut ownership) = self
5110 .materializations
5111 .lock()
5112 .unwrap_or_else(|poisoned| poisoned.into_inner())
5113 .get(id)
5114 .cloned()
5115 else {
5116 return Ok(());
5117 };
5118 if ownership.state() != MaterializationState::CleanupRequired {
5119 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5120 failure(
5121 NodeFailureCode::BackendOperationFailed,
5122 "session environment cleanup state is invalid",
5123 )
5124 })?;
5125 }
5126 {
5127 let _transaction = self
5128 .state_transaction
5129 .lock()
5130 .unwrap_or_else(|poisoned| poisoned.into_inner());
5131 let previous = self
5132 .materializations
5133 .lock()
5134 .unwrap_or_else(|poisoned| poisoned.into_inner())
5135 .insert(id.clone(), ownership.clone());
5136 if let Err(error) = self.persist_state_locked() {
5137 let mut materializations = self
5138 .materializations
5139 .lock()
5140 .unwrap_or_else(|poisoned| poisoned.into_inner());
5141 if let Some(previous) = previous {
5142 materializations.insert(id.clone(), previous);
5143 }
5144 return Err(persistence_failure(error));
5145 }
5146 }
5147 let cleanup = self
5148 .session_environment_materializer
5149 .as_ref()
5150 .ok_or(())
5151 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5152 if cleanup.is_err() {
5153 let _ = ownership.mark_recovery_required(unix_time_ms());
5154 self.materializations
5155 .lock()
5156 .unwrap_or_else(|poisoned| poisoned.into_inner())
5157 .insert(id.clone(), ownership);
5158 if self.persist_state().is_err() {
5159 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5160 }
5161 return Err(failure(
5162 NodeFailureCode::BackendOperationFailed,
5163 "session environment cleanup requires recovery",
5164 ));
5165 }
5166 let _transaction = self
5167 .state_transaction
5168 .lock()
5169 .unwrap_or_else(|poisoned| poisoned.into_inner());
5170 self.materializations
5171 .lock()
5172 .unwrap_or_else(|poisoned| poisoned.into_inner())
5173 .remove(id);
5174 if let Err(error) = self.persist_state_locked() {
5175 self.materializations
5176 .lock()
5177 .unwrap_or_else(|poisoned| poisoned.into_inner())
5178 .insert(id.clone(), ownership);
5179 return Err(persistence_failure(error));
5180 }
5181 Ok(())
5182 }
5183
5184 fn resolve_record_materialization(
5185 &self,
5186 record_id: &SessionRecordId,
5187 provider: &AgentId,
5188 mode: SessionMode,
5189 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5190 bundle: Option<&ResolvedBundleReceipt>,
5191 context: Option<&ResolvedContextPackReceipt>,
5192 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5193 let ownership = {
5194 let materializations = self
5195 .materializations
5196 .lock()
5197 .unwrap_or_else(|poisoned| poisoned.into_inner());
5198 let mut matches = materializations.values().filter(|record| {
5199 record.owner()
5200 == &MaterializationOwner::Record {
5201 record_id: record_id.clone(),
5202 }
5203 });
5204 let ownership = matches.next().cloned();
5205 if matches.next().is_some() {
5206 return Err(failure(
5207 NodeFailureCode::BackendOperationFailed,
5208 "managed session has conflicting environment ownership",
5209 ));
5210 }
5211 ownership
5212 };
5213 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5214 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5215 failure(
5216 NodeFailureCode::ContextPackMaterializationFailed,
5217 "session context materializer is unavailable",
5218 )
5219 })?;
5220 let pack = materializer
5221 .revalidate_context(ownership, receipt)
5222 .map_err(|_| {
5223 self.mark_materialization_recovery_required(ownership.id());
5224 failure(
5225 NodeFailureCode::ContextPackMaterializationFailed,
5226 "managed session context pack revalidation failed",
5227 )
5228 })?;
5229 self.context_catalog
5230 .write()
5231 .unwrap_or_else(|poisoned| poisoned.into_inner())
5232 .insert(pack)
5233 .map_err(|_| failure(
5234 NodeFailureCode::ContextPackMaterializationFailed,
5235 "managed session context pack catalog restore failed",
5236 ))?;
5237 }
5238 let materialization = self.materialization_profile(
5239 provider,
5240 mode,
5241 environment_profile,
5242 bundle,
5243 context,
5244 )?;
5245 match (ownership, materialization) {
5246 (None, None) => Ok(None),
5247 (None, Some(_)) | (Some(_), None) => Err(failure(
5248 NodeFailureCode::BackendOperationFailed,
5249 "managed session materialization is unavailable",
5250 )),
5251 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5252 if ownership.environment_profile() != environment_profile
5253 || ownership.bundle() != bundle
5254 || ownership.context() != context
5255 || ownership.state() != MaterializationState::Ready
5256 {
5257 self.mark_materialization_recovery_required(ownership.id());
5258 return Err(failure(
5259 NodeFailureCode::BackendOperationFailed,
5260 "managed session environment ownership requires recovery",
5261 ));
5262 }
5263 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5264 failure(
5265 NodeFailureCode::BackendOperationFailed,
5266 "session environment materializer is unavailable",
5267 )
5268 })?;
5269 if materializer.revalidate(&ownership).is_err() {
5270 self.mark_materialization_recovery_required(ownership.id());
5271 return Err(failure(
5272 NodeFailureCode::BackendOperationFailed,
5273 "managed session environment revalidation failed",
5274 ));
5275 }
5276 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5277 let catalog = self.bundle_catalog.read()
5278 .unwrap_or_else(|poisoned| poisoned.into_inner());
5279 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5280 return Err(failure(
5281 NodeFailureCode::UnknownBundle,
5282 "managed session bundle is unavailable",
5283 ));
5284 };
5285 let layout = bundle_layout.ok_or_else(|| failure(
5286 NodeFailureCode::BundleBindingMismatch,
5287 "record bundle provider layout is unavailable",
5288 ))?;
5289 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5290 drop(catalog);
5291 self.mark_materialization_recovery_required(ownership.id());
5292 return Err(failure(
5293 NodeFailureCode::BundleMaterializationFailed,
5294 "managed session bundle revalidation failed",
5295 ));
5296 }
5297 Some(bundle_launch_arguments(
5298 layout,
5299 ownership.bundle_root(),
5300 ownership.provider_home(),
5301 ).map_err(|_| failure(
5302 NodeFailureCode::BundleBindingMismatch,
5303 "managed session bundle launch binding is invalid",
5304 ))?)
5305 } else {
5306 None
5307 };
5308 if !has_environment_materialization {
5309 let overlay = bundle_arguments.map(|extra_args| {
5310 NativeInstanceLaunchOverlay::new(
5311 provider.clone(),
5312 TransportKind::Pty,
5313 Vec::new(),
5314 extra_args,
5315 )
5316 .map(PreparedNativeLaunchOverlay::Instance)
5317 .map_err(|_| failure(
5318 NodeFailureCode::BundleBindingMismatch,
5319 "managed session bundle launch overlay is invalid",
5320 ))
5321 }).transpose()?;
5322 return Ok(Some((ownership.id().clone(), overlay)));
5323 }
5324 let environment = match materializer.resolve_environment(&ownership, &profile) {
5325 Ok(environment) => environment,
5326 Err(
5327 SessionEnvironmentMaterializeError::SecretUnavailable
5328 | SessionEnvironmentMaterializeError::SecretDenied
5329 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5330 ) => {
5331 return Err(failure(
5332 NodeFailureCode::BackendOperationFailed,
5333 "managed session environment resolution failed",
5334 ));
5335 }
5336 Err(
5337 SessionEnvironmentMaterializeError::InvalidRoot
5338 | SessionEnvironmentMaterializeError::OwnershipMismatch
5339 | SessionEnvironmentMaterializeError::Filesystem(_)
5340 | SessionEnvironmentMaterializeError::Record(_),
5341 ) => {
5342 self.mark_materialization_recovery_required(ownership.id());
5343 return Err(failure(
5344 NodeFailureCode::BackendOperationFailed,
5345 "managed session environment resolution failed",
5346 ));
5347 }
5348 };
5349 let transport = match mode {
5350 SessionMode::Pty => TransportKind::Pty,
5351 SessionMode::Inline => TransportKind::Pipe,
5352 SessionMode::Acp => TransportKind::Acp,
5353 };
5354 let overlay = match bundle_arguments {
5355 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5356 NativeInstanceLaunchOverlay::new(
5357 provider.clone(),
5358 transport,
5359 environment,
5360 extra_args,
5361 )
5362 .map_err(|_| failure(
5363 NodeFailureCode::BundleBindingMismatch,
5364 "managed session bundle launch overlay is invalid",
5365 ))?,
5366 ),
5367 None => PreparedNativeLaunchOverlay::Environment(
5368 NativeLaunchEnvironmentOverlay::new(
5369 provider.clone(),
5370 transport,
5371 environment,
5372 )
5373 .map_err(|_| {
5374 failure(
5375 NodeFailureCode::BackendOperationFailed,
5376 "managed session environment overlay is invalid",
5377 )
5378 })?,
5379 ),
5380 };
5381 Ok(Some((ownership.id().clone(), Some(overlay))))
5382 }
5383 }
5384 }
5385
5386 fn destroy_record_materialization(
5387 &self,
5388 record_id: &SessionRecordId,
5389 ) -> Result<bool, NodeFailure> {
5390 let ids = self
5391 .materializations
5392 .lock()
5393 .unwrap_or_else(|poisoned| poisoned.into_inner())
5394 .values()
5395 .filter_map(|record| {
5396 (record.owner()
5397 == &MaterializationOwner::Record {
5398 record_id: record_id.clone(),
5399 })
5400 .then(|| record.id().clone())
5401 })
5402 .collect::<Vec<_>>();
5403 if ids.len() > 1 {
5404 return Err(failure(
5405 NodeFailureCode::BackendOperationFailed,
5406 "managed session has conflicting environment ownership",
5407 ));
5408 }
5409 let Some(id) = ids.first().cloned() else {
5410 return Ok(false);
5411 };
5412 let (terminal_record, mut cleanup_ownership) = {
5413 let _transaction = self
5414 .state_transaction
5415 .lock()
5416 .unwrap_or_else(|poisoned| poisoned.into_inner());
5417 let original_record = self.record(record_id)?;
5418 let mut terminal_record = original_record.clone();
5419 terminal_record.provider_session = None;
5420 terminal_record.active_session = None;
5421 terminal_record.state = ManagedSessionState::Unavailable;
5422 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5423 terminal_record.updated_at_unix_ms = unix_time_ms();
5424 let original_ownership = self
5425 .materializations
5426 .lock()
5427 .unwrap_or_else(|poisoned| poisoned.into_inner())
5428 .get(&id)
5429 .cloned()
5430 .ok_or_else(|| {
5431 failure(
5432 NodeFailureCode::BackendOperationFailed,
5433 "managed session environment ownership disappeared during cleanup",
5434 )
5435 })?;
5436 let mut cleanup_ownership = original_ownership.clone();
5437 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5438 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5439 failure(
5440 NodeFailureCode::BackendOperationFailed,
5441 "managed session environment cleanup state is invalid",
5442 )
5443 })?;
5444 }
5445 self.session_records
5446 .lock()
5447 .unwrap_or_else(|poisoned| poisoned.into_inner())
5448 .records
5449 .insert(record_id.clone(), terminal_record.clone());
5450 self.materializations
5451 .lock()
5452 .unwrap_or_else(|poisoned| poisoned.into_inner())
5453 .insert(id.clone(), cleanup_ownership.clone());
5454 if let Err(error) = self.persist_state_locked() {
5455 self.session_records
5456 .lock()
5457 .unwrap_or_else(|poisoned| poisoned.into_inner())
5458 .records
5459 .insert(record_id.clone(), original_record);
5460 self.materializations
5461 .lock()
5462 .unwrap_or_else(|poisoned| poisoned.into_inner())
5463 .insert(id.clone(), original_ownership);
5464 return Err(persistence_failure(error));
5465 }
5466 (terminal_record, cleanup_ownership)
5467 };
5468 let cleanup = self
5469 .session_environment_materializer
5470 .as_ref()
5471 .ok_or(())
5472 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5473 if cleanup.is_err() {
5474 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5475 self.materializations
5476 .lock()
5477 .unwrap_or_else(|poisoned| poisoned.into_inner())
5478 .insert(id.clone(), cleanup_ownership);
5479 if self.persist_state().is_err() {
5480 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5481 }
5482 return Err(failure(
5483 NodeFailureCode::BackendOperationFailed,
5484 "managed session environment cleanup requires recovery",
5485 ));
5486 }
5487 let _transaction = self
5488 .state_transaction
5489 .lock()
5490 .unwrap_or_else(|poisoned| poisoned.into_inner());
5491 self.remove_record_memory(record_id);
5492 self.materializations
5493 .lock()
5494 .unwrap_or_else(|poisoned| poisoned.into_inner())
5495 .remove(&id);
5496 if let Err(error) = self.persist_state_locked() {
5497 self.session_records
5498 .lock()
5499 .unwrap_or_else(|poisoned| poisoned.into_inner())
5500 .records
5501 .insert(record_id.clone(), terminal_record);
5502 self.materializations
5503 .lock()
5504 .unwrap_or_else(|poisoned| poisoned.into_inner())
5505 .insert(id, cleanup_ownership);
5506 return Err(persistence_failure(error));
5507 }
5508 Ok(true)
5509 }
5510
5511 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5512 let changed = {
5513 let mut materializations = self
5514 .materializations
5515 .lock()
5516 .unwrap_or_else(|poisoned| poisoned.into_inner());
5517 let Some(ownership) = materializations.get_mut(id) else {
5518 return;
5519 };
5520 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5521 };
5522 if changed && self.persist_state().is_err() {
5523 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5524 }
5525 }
5526
5527 fn reconcile_materializations(&self) {
5528 let records = self
5529 .materializations
5530 .lock()
5531 .unwrap_or_else(|poisoned| poisoned.into_inner())
5532 .values()
5533 .cloned()
5534 .collect::<Vec<_>>();
5535 for mut ownership in records {
5536 let result = match (ownership.state(), ownership.owner()) {
5537 (MaterializationState::Preparing, _)
5538 | (MaterializationState::CleanupRequired, _) => {
5539 self.cleanup_materialization(ownership.id())
5540 }
5541 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5542 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5543 self.materializations
5544 .lock()
5545 .unwrap_or_else(|poisoned| poisoned.into_inner())
5546 .insert(ownership.id().clone(), ownership.clone());
5547 self.persist_state().map_err(persistence_failure)
5548 } else {
5549 Err(failure(
5550 NodeFailureCode::BackendOperationFailed,
5551 "session environment reconciliation failed",
5552 ))
5553 }
5554 }
5555 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5556 .session_environment_materializer
5557 .as_ref()
5558 .ok_or_else(|| {
5559 failure(
5560 NodeFailureCode::BackendOperationFailed,
5561 "session environment materializer is unavailable",
5562 )
5563 })
5564 .and_then(|materializer| {
5565 materializer.revalidate(&ownership).map_err(|_| failure(
5566 NodeFailureCode::BackendOperationFailed,
5567 "session materialization revalidation failed",
5568 ))?;
5569 let record = self.record(record_id)?;
5570 if record.environment_profile.as_ref() != ownership.environment_profile()
5571 || record.bundle.as_ref() != ownership.bundle()
5572 || record.context.as_ref() != ownership.context()
5573 {
5574 return Err(failure(
5575 NodeFailureCode::BackendOperationFailed,
5576 "session materialization ownership changed after restart",
5577 ));
5578 }
5579 if let Some(receipt) = ownership.bundle() {
5580 let catalog = self.bundle_catalog.read()
5581 .unwrap_or_else(|poisoned| poisoned.into_inner());
5582 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5583 NodeFailureCode::UnknownBundle,
5584 "session bundle is unavailable after restart",
5585 ))?;
5586 if bundle.receipt() != *receipt {
5587 return Err(failure(
5588 NodeFailureCode::BundleBindingMismatch,
5589 "session bundle changed after restart",
5590 ));
5591 }
5592 let layout = self.bundle_layout(
5593 &record.provider,
5594 record.mode,
5595 record.environment_profile.as_ref(),
5596 bundle,
5597 )?;
5598 materializer
5599 .revalidate_bundle(&ownership, bundle, layout)
5600 .map_err(|_| failure(
5601 NodeFailureCode::BundleMaterializationFailed,
5602 "session bundle revalidation failed",
5603 ))?;
5604 }
5605 if let Some(receipt) = ownership.context() {
5606 let pack = materializer
5607 .revalidate_context(&ownership, receipt)
5608 .map_err(|_| {
5609 failure(
5610 NodeFailureCode::ContextPackMaterializationFailed,
5611 "session context revalidation failed",
5612 )
5613 })?;
5614 self.context_catalog
5615 .write()
5616 .unwrap_or_else(|poisoned| poisoned.into_inner())
5617 .insert(pack)
5618 .map_err(|_| {
5619 failure(
5620 NodeFailureCode::ContextPackMaterializationFailed,
5621 "session context catalog restore failed",
5622 )
5623 })?;
5624 }
5625 Ok(())
5626 }),
5627 (MaterializationState::RecoveryRequired, _) => Ok(()),
5628 };
5629 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5630 let _ = ownership.mark_recovery_required(unix_time_ms());
5631 self.materializations
5632 .lock()
5633 .unwrap_or_else(|poisoned| poisoned.into_inner())
5634 .insert(ownership.id().clone(), ownership);
5635 if self.persist_state().is_err() {
5636 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5637 }
5638 }
5639 }
5640 let materialized_profiles = self
5641 .environment_materialization_profiles
5642 .read()
5643 .unwrap_or_else(|poisoned| poisoned.into_inner())
5644 .keys()
5645 .cloned()
5646 .collect::<Vec<_>>();
5647 let ownership = self
5648 .materializations
5649 .lock()
5650 .unwrap_or_else(|poisoned| poisoned.into_inner())
5651 .values()
5652 .cloned()
5653 .collect::<Vec<_>>();
5654 let changed = {
5655 let mut session_records = self
5656 .session_records
5657 .lock()
5658 .unwrap_or_else(|poisoned| poisoned.into_inner());
5659 let mut changed = false;
5660 for record in session_records.records.values_mut() {
5661 let environment_profile = record.environment_profile.as_ref();
5662 if environment_profile.is_none()
5663 && record.bundle.is_none()
5664 && record.context.is_none()
5665 {
5666 continue;
5667 }
5668 if record.bundle.is_none()
5669 && environment_profile.is_some_and(|profile| {
5670 !materialized_profiles.contains(&profile.profile_id)
5671 })
5672 {
5673 continue;
5674 }
5675 let has_exact_ownership = ownership.iter().any(|candidate| {
5676 candidate.state() == MaterializationState::Ready
5677 && candidate.environment_profile() == environment_profile
5678 && candidate.bundle() == record.bundle.as_ref()
5679 && candidate.context() == record.context.as_ref()
5680 && candidate.owner()
5681 == &MaterializationOwner::Record {
5682 record_id: record.record_id.clone(),
5683 }
5684 });
5685 if !has_exact_ownership {
5686 record.provider_session = None;
5687 record.active_session = None;
5688 record.state = ManagedSessionState::Unavailable;
5689 record.last_error = Some(if record.bundle.is_some() {
5690 "bundle-unavailable".to_owned()
5691 } else if record.context.is_some() {
5692 "context-unavailable".to_owned()
5693 } else {
5694 "environment-profile-unavailable".to_owned()
5695 });
5696 record.updated_at_unix_ms = unix_time_ms();
5697 changed = true;
5698 }
5699 }
5700 changed
5701 };
5702 if changed && self.persist_state().is_err() {
5703 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5704 }
5705 }
5706
5707 fn replay_spawn_spec(
5708 &self,
5709 spec: &SpawnSpec,
5710 now: Instant,
5711 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5712 let mut cache = self
5713 .spawn_idempotency
5714 .lock()
5715 .unwrap_or_else(|poisoned| poisoned.into_inner());
5716 cache.entries.retain(|_, entry| entry.expires_at > now);
5717 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5718 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5719 return Err(failure(
5720 NodeFailureCode::SpawnIdempotencyConflict,
5721 "spawn idempotency key was reused across spawn kinds",
5722 ));
5723 };
5724 if existing != spec {
5725 return Err(failure(
5726 NodeFailureCode::SpawnIdempotencyConflict,
5727 "spawn idempotency key was reused with a different specification",
5728 ));
5729 }
5730 return Ok(Some(result.clone()));
5731 }
5732 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5733 return Err(failure(
5734 NodeFailureCode::SpawnIdempotencyCapacity,
5735 "spawn idempotency cache reached its bounded capacity",
5736 ));
5737 }
5738 Ok(None)
5739 }
5740
5741 fn remember_spawn_spec(
5742 &self,
5743 spec: SpawnSpec,
5744 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5745 accepted_at: Instant,
5746 ) {
5747 self
5748 .spawn_idempotency
5749 .lock()
5750 .unwrap_or_else(|poisoned| poisoned.into_inner())
5751 .entries
5752 .insert(
5753 spec.idempotency_key.clone(),
5754 SpawnIdempotencyEntry {
5755 value: SpawnIdempotencyValue::Standard { spec, result },
5756 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5757 },
5758 );
5759 }
5760
5761 fn claim_harness_mcp_spawn(
5762 &self,
5763 _reservation_id: &HarnessMcpReservationId,
5764 _activation_digest: &HarnessMcpActivationDigest,
5765 spec: &SpawnSpec,
5766 now: Instant,
5767 ) -> Result<(), NodeFailure> {
5768 let mut cache = self.spawn_idempotency.lock()
5769 .unwrap_or_else(|poisoned| poisoned.into_inner());
5770 cache.entries.retain(|_, entry| entry.expires_at > now);
5771 if cache.entries.contains_key(&spec.idempotency_key) {
5772 return Err(failure(
5773 NodeFailureCode::SpawnIdempotencyConflict,
5774 "spawn idempotency key was reused across spawn kinds",
5775 ));
5776 }
5777 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5778 return Err(failure(
5779 NodeFailureCode::SpawnIdempotencyCapacity,
5780 "spawn idempotency cache reached its bounded capacity",
5781 ));
5782 }
5783 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5784 value: SpawnIdempotencyValue::HarnessMcp,
5785 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5786 });
5787 Ok(())
5788 }
5789
5790 fn replay_managed_spawn(
5791 &self,
5792 request: &ManagedWorktreeSpawnRequest,
5793 now: Instant,
5794 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5795 let mut cache = self.spawn_idempotency.lock()
5796 .unwrap_or_else(|poisoned| poisoned.into_inner());
5797 cache.entries.retain(|_, entry| entry.expires_at > now);
5798 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5799 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5800 return Err(failure(
5801 NodeFailureCode::SpawnIdempotencyConflict,
5802 "spawn idempotency key was reused across spawn kinds",
5803 ));
5804 };
5805 if existing != request {
5806 return Err(failure(
5807 NodeFailureCode::SpawnIdempotencyConflict,
5808 "spawn idempotency key was reused with a different managed specification",
5809 ));
5810 }
5811 return Ok(Some(result.clone()));
5812 }
5813 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5814 return Err(failure(
5815 NodeFailureCode::SpawnIdempotencyCapacity,
5816 "spawn idempotency cache reached its bounded capacity",
5817 ));
5818 }
5819 Ok(None)
5820 }
5821
5822 fn remember_managed_spawn(
5823 &self,
5824 request: ManagedWorktreeSpawnRequest,
5825 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5826 accepted_at: Instant,
5827 ) {
5828 self.spawn_idempotency.lock()
5829 .unwrap_or_else(|poisoned| poisoned.into_inner())
5830 .entries
5831 .insert(
5832 request.spawn_spec.idempotency_key.clone(),
5833 SpawnIdempotencyEntry {
5834 value: SpawnIdempotencyValue::Managed { request, result },
5835 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5836 },
5837 );
5838 }
5839
5840 fn remember_managed_spawn_attempt(
5841 &self,
5842 durable_v2_key: Option<&SpawnIdempotencyKey>,
5843 request: ManagedWorktreeSpawnRequest,
5844 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5845 accepted_at: Instant,
5846 ) {
5847 if durable_v2_key.is_none() {
5848 self.remember_managed_spawn(request, result, accepted_at);
5849 }
5850 }
5851
5852 fn acquire_durable_managed_spawn_v2(
5853 &self,
5854 request: &ManagedWorktreeSpawnRequestV2,
5855 request_digest: String,
5856 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5857 if self.state_path.is_none() {
5858 return Err(failure(
5859 NodeFailureCode::BackendOperationFailed,
5860 "managed worktree V2 requires durable node state",
5861 ));
5862 }
5863 let _transaction = self.state_transaction
5864 .lock()
5865 .unwrap_or_else(|poisoned| poisoned.into_inner());
5866 let key = request.spawn_spec.idempotency_key.clone();
5867 let mut previous = None;
5868 {
5869 let mut replays = self.managed_spawn_replays
5870 .lock()
5871 .unwrap_or_else(|poisoned| poisoned.into_inner());
5872 if let Some(record) = replays.get_mut(&key) {
5873 if record.request_digest != request_digest {
5874 return Err(failure(
5875 NodeFailureCode::SpawnIdempotencyConflict,
5876 "managed worktree V2 idempotency key was reused with a different request",
5877 ));
5878 }
5879 match &record.state {
5880 ManagedWorktreeSpawnReplayStateV10::Pending {
5881 owner_incarnation_id,
5882 lease_id: None,
5883 } if owner_incarnation_id != &self.incarnation_id => {
5884 previous = Some(record.clone());
5885 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5886 owner_incarnation_id: self.incarnation_id,
5887 lease_id: None,
5888 };
5889 record.updated_at_unix_ms = unix_time_ms();
5890 }
5891 ManagedWorktreeSpawnReplayStateV10::Pending {
5892 lease_id: None,
5893 ..
5894 } => {
5895 return Err(failure(
5896 NodeFailureCode::BackendBusy,
5897 "managed worktree V2 reservation is already active",
5898 ));
5899 }
5900 ManagedWorktreeSpawnReplayStateV10::Pending {
5901 lease_id: Some(_),
5902 ..
5903 } => {
5904 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5905 }
5906 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5907 return Ok(DurableManagedSpawnReplayDecision::Committed(
5908 receipt.clone(),
5909 ));
5910 }
5911 }
5912 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5913 return Err(failure(
5914 NodeFailureCode::SpawnIdempotencyCapacity,
5915 "durable managed worktree replay ledger reached its bounded capacity",
5916 ));
5917 } else {
5918 let now = unix_time_ms();
5919 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5920 idempotency_key: key.clone(),
5921 request_digest,
5922 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5923 profile_id: request.worktree_profile_id.clone(),
5924 expected_profile_revision: request.expected_profile_revision.clone(),
5925 state: ManagedWorktreeSpawnReplayStateV10::Pending {
5926 owner_incarnation_id: self.incarnation_id,
5927 lease_id: None,
5928 },
5929 created_at_unix_ms: now,
5930 updated_at_unix_ms: now,
5931 });
5932 }
5933 }
5934 if let Err(error) = self.persist_state_locked() {
5935 let mut replays = self.managed_spawn_replays
5936 .lock()
5937 .unwrap_or_else(|poisoned| poisoned.into_inner());
5938 match previous {
5939 Some(previous) => {
5940 replays.insert(key, previous);
5941 }
5942 None => {
5943 replays.remove(&key);
5944 }
5945 }
5946 return Err(persistence_failure(error));
5947 }
5948 Ok(DurableManagedSpawnReplayDecision::Reserved)
5949 }
5950
5951 fn link_durable_managed_spawn_v2_lease_locked(
5952 &self,
5953 key: &SpawnIdempotencyKey,
5954 lease_id: &ManagedWorktreeLeaseId,
5955 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5956 let mut replays = self.managed_spawn_replays
5957 .lock()
5958 .unwrap_or_else(|poisoned| poisoned.into_inner());
5959 let record = replays.get_mut(key).ok_or_else(|| {
5960 failure(
5961 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5962 "managed worktree V2 reservation disappeared before allocation",
5963 )
5964 })?;
5965 let previous = record.clone();
5966 match &mut record.state {
5967 ManagedWorktreeSpawnReplayStateV10::Pending {
5968 lease_id: current,
5969 ..
5970 }
5971 if current.is_none() =>
5972 {
5973 *current = Some(lease_id.clone());
5974 record.updated_at_unix_ms = unix_time_ms();
5975 Ok(previous)
5976 }
5977 _ => Err(failure(
5978 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5979 "managed worktree V2 reservation is not allocation-ready",
5980 )),
5981 }
5982 }
5983
5984 fn restore_durable_managed_spawn_v2_locked(
5985 &self,
5986 key: &SpawnIdempotencyKey,
5987 previous: ManagedWorktreeSpawnReplayRecordV10,
5988 ) {
5989 self.managed_spawn_replays
5990 .lock()
5991 .unwrap_or_else(|poisoned| poisoned.into_inner())
5992 .insert(key.clone(), previous);
5993 }
5994
5995 fn commit_durable_managed_spawn_v2(
5996 &self,
5997 request: &ManagedWorktreeSpawnRequestV2,
5998 receipt: ManagedWorktreeSpawnReceipt,
5999 ) -> Result<(), NodeFailure> {
6000 let _transaction = self.state_transaction
6001 .lock()
6002 .unwrap_or_else(|poisoned| poisoned.into_inner());
6003 let key = &request.spawn_spec.idempotency_key;
6004 let previous = {
6005 let mut replays = self.managed_spawn_replays
6006 .lock()
6007 .unwrap_or_else(|poisoned| poisoned.into_inner());
6008 let record = replays.get_mut(key).ok_or_else(|| {
6009 failure(
6010 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6011 "managed worktree V2 reservation disappeared before commit",
6012 )
6013 })?;
6014 let previous = record.clone();
6015 match &record.state {
6016 ManagedWorktreeSpawnReplayStateV10::Pending {
6017 lease_id: Some(lease_id),
6018 ..
6019 } if lease_id == &receipt.lease.lease_id => {}
6020 _ => {
6021 return Err(failure(
6022 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6023 "managed worktree V2 reservation is not linked to the spawned lease",
6024 ));
6025 }
6026 }
6027 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6028 record.updated_at_unix_ms = unix_time_ms();
6029 previous
6030 };
6031 if let Err(error) = self.persist_state_locked() {
6032 self.restore_durable_managed_spawn_v2_locked(key, previous);
6033 return Err(persistence_failure(error));
6034 }
6035 Ok(())
6036 }
6037
6038 fn remove_unallocated_durable_managed_spawn_v2(
6039 &self,
6040 request: &ManagedWorktreeSpawnRequestV2,
6041 ) -> Result<(), NodeFailure> {
6042 let _transaction = self.state_transaction
6043 .lock()
6044 .unwrap_or_else(|poisoned| poisoned.into_inner());
6045 let key = &request.spawn_spec.idempotency_key;
6046 let removed = {
6047 let mut replays = self.managed_spawn_replays
6048 .lock()
6049 .unwrap_or_else(|poisoned| poisoned.into_inner());
6050 match replays.get(key).map(|record| &record.state) {
6051 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6052 lease_id: None,
6053 ..
6054 }) => {
6055 replays.remove(key)
6056 }
6057 _ => None,
6058 }
6059 };
6060 let Some(removed) = removed else {
6061 return Ok(());
6062 };
6063 if let Err(error) = self.persist_state_locked() {
6064 self.managed_spawn_replays
6065 .lock()
6066 .unwrap_or_else(|poisoned| poisoned.into_inner())
6067 .insert(key.clone(), removed);
6068 return Err(persistence_failure(error));
6069 }
6070 Ok(())
6071 }
6072
6073 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6074 if spec.target.node_id != self.node_id {
6075 return Err(failure(
6076 NodeFailureCode::SpawnTargetMismatch,
6077 "spawn target does not match this node",
6078 ));
6079 }
6080 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6081 failure(
6082 NodeFailureCode::UnknownSpawnProfile,
6083 "spawn profile is unavailable on this node",
6084 )
6085 })?;
6086 let resolved = spec.resolve(defaults).map_err(|error| match error {
6087 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6088 NodeFailureCode::SpawnProfileRevisionMismatch,
6089 "spawn profile revision does not match the loaded profile",
6090 ),
6091 _ => failure(
6092 NodeFailureCode::InvalidRequest,
6093 "spawn specification could not be resolved",
6094 ),
6095 })?;
6096 if resolved.target.worktree_id.is_some() {
6097 self.require_worktree_service(&resolved.target.workspace_id)?;
6098 }
6099 let environment_profile = self.resolve_environment_profile(&resolved)?;
6100 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6101 self.resolve_context(&resolved)?;
6102 Ok(resolved)
6103 }
6104
6105 async fn resolve_spawn_workspace(
6106 &self,
6107 resolved: &ResolvedSpawnSpec,
6108 deadline: Instant,
6109 ) -> Result<WorkspaceId, NodeFailure> {
6110 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6111 return Ok(resolved.target.workspace_id.clone());
6112 };
6113 if self.managed_worktrees.lock()
6114 .unwrap_or_else(|poisoned| poisoned.into_inner())
6115 .lease_for_workspace(worktree_id)
6116 .is_some()
6117 {
6118 return Err(failure(
6119 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6120 "managed worktree targets may only be spawned through their lease request",
6121 ));
6122 }
6123 self.require_worktree_service(&resolved.target.workspace_id)?;
6124 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6125 let selected_root = self.workspace_root(worktree_id)?;
6126 let remaining = spawn_deadline_remaining(deadline)?;
6127 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6128 .await
6129 .map_err(|_| {
6130 failure(
6131 NodeFailureCode::SpawnDeadlineExceeded,
6132 "Git worktree selection exceeded the spawn deadline",
6133 )
6134 })?
6135 .map_err(git_worktree_failure)?;
6136 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6137 Ok(worktree_id.clone())
6138 }
6139
6140 async fn spawn_from_spec(
6141 &self,
6142 spec: SpawnSpec,
6143 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6144 self.spawn_from_spec_with_proxy(spec, None).await
6145 }
6146
6147 async fn spawn_from_spec_with_proxy(
6148 &self,
6149 spec: SpawnSpec,
6150 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6151 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6152 let accepted_at = Instant::now();
6153 if harness_mcp.is_none() {
6154 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6155 return replayed;
6156 }
6157 }
6158 let resolved = self.resolve_spawn_spec(&spec)?;
6159 let environment_profile = self.resolve_environment_profile(&resolved)?;
6160 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6161 let context = self.resolve_context(&resolved)?;
6162 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6163 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6164 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6165 let spawn_workspace_id = match spawn_workspace_id {
6166 Ok(workspace_id) => workspace_id,
6167 Err(error) => {
6168 let result = Err(error);
6169 if harness_mcp.is_none() {
6170 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6171 }
6172 return result;
6173 }
6174 };
6175 if self.managed_worktrees.lock()
6176 .unwrap_or_else(|poisoned| poisoned.into_inner())
6177 .lease_for_workspace(&spawn_workspace_id)
6178 .is_some()
6179 {
6180 let result = Err(failure(
6181 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6182 "managed worktree targets may only be spawned through their lease request",
6183 ));
6184 if harness_mcp.is_none() {
6185 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6186 }
6187 return result;
6188 }
6189 let result = self
6190 .spawn_session_with_deadline(
6191 spawn_workspace_id,
6192 resolved.provider.clone(),
6193 resolved.mode,
6194 resolved.terminal_size,
6195 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6196 environment_profile.clone(),
6197 bundle.clone(),
6198 context.clone(),
6199 None,
6200 None,
6201 SpawnRecordPolicy::Always,
6202 Some(deadline),
6203 &required_capabilities,
6204 harness_mcp,
6205 resolved.approval_level,
6206 resolved.network_allowlist.clone(),
6207 resolved.browser_profile_id.clone(),
6208 )
6209 .await
6210 .map(|(session, _runtime_policy)| {
6211 resolved.receipt_with_materialization(
6212 self.incarnation_id,
6213 session,
6214 environment_profile,
6215 bundle,
6216 context,
6217 )
6218 });
6219 if harness_mcp.is_none() {
6220 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6221 }
6222 result
6223 }
6224
6225 async fn spawn_from_spec_with_harness_mcp(
6226 &self,
6227 reservation_id: HarnessMcpReservationId,
6228 activation_digest: HarnessMcpActivationDigest,
6229 spec: SpawnSpec,
6230 deadline_unix_ms: u64,
6231 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6232 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6233 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6234 })?;
6235 if let Some(receipt) = registry
6236 .replay_spawn(&reservation_id, &activation_digest, &spec)
6237 .map_err(harness_mcp_failure)?
6238 {
6239 return Ok(receipt);
6240 }
6241 let resolved = self.resolve_spawn_spec(&spec)?;
6242 let prepared = registry
6243 .prepare_spawn(
6244 &reservation_id,
6245 &activation_digest,
6246 &spec,
6247 deadline_unix_ms,
6248 resolved.provider.clone(),
6249 )
6250 .map_err(harness_mcp_failure)?;
6251 self.claim_harness_mcp_spawn(
6252 &reservation_id,
6253 &activation_digest,
6254 &spec,
6255 Instant::now(),
6256 )?;
6257 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6258 let mut receipt = match spawn {
6259 Ok(receipt) => receipt,
6260 Err(error) => {
6261 let _ = registry.abort(&reservation_id, &activation_digest);
6262 return Err(error);
6263 }
6264 };
6265 let record_id = self
6266 .session_bindings
6267 .lock()
6268 .unwrap_or_else(|poisoned| poisoned.into_inner())
6269 .get(&receipt.session.session.instance_id)
6270 .and_then(|binding| binding.record_id.clone());
6271 let record_id = match record_id {
6272 Some(record_id) => record_id,
6273 None => {
6274 let rollback = self.remove_session(&receipt.session).await;
6275 let _ = registry.abort(&reservation_id, &activation_digest);
6276 if let Err(rollback_error) = rollback {
6277 return Err(rollback_error);
6278 }
6279 return Err(failure(
6280 NodeFailureCode::BindingMismatch,
6281 "harness MCP spawn did not retain its exact managed record",
6282 ));
6283 }
6284 };
6285 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6286 reservation_id: reservation_id.clone(),
6287 activation_digest: activation_digest.clone(),
6288 });
6289 if let Err(error) = registry.mark_spawned(
6290 &prepared,
6291 receipt.session.clone(),
6292 record_id,
6293 receipt.clone(),
6294 ) {
6295 let rollback = self.remove_session(&receipt.session).await;
6296 let _ = registry.abort(&reservation_id, &activation_digest);
6297 if let Err(rollback_error) = rollback {
6298 return Err(rollback_error);
6299 }
6300 return Err(harness_mcp_failure(error));
6301 }
6302 Ok(receipt)
6303 }
6304
6305 async fn spawn_managed_worktree(
6306 &self,
6307 request: ManagedWorktreeSpawnRequest,
6308 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6309 self.spawn_managed_worktree_inner(request, None).await
6310 }
6311
6312 async fn spawn_managed_worktree_inner(
6313 &self,
6314 request: ManagedWorktreeSpawnRequest,
6315 durable_v2_key: Option<&SpawnIdempotencyKey>,
6316 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6317 let accepted_at = Instant::now();
6318 if durable_v2_key.is_none() {
6319 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6320 return replayed;
6321 }
6322 }
6323 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6324 if request.spawn_spec.target.worktree_id.is_some() {
6325 let result = Err(failure(
6326 NodeFailureCode::InvalidRequest,
6327 "managed worktree spawn must not select a caller-provided worktree",
6328 ));
6329 self.remember_managed_spawn_attempt(
6330 durable_v2_key,
6331 request,
6332 result.clone(),
6333 accepted_at,
6334 );
6335 return result;
6336 }
6337 let environment_profile = self.resolve_environment_profile(&resolved)?;
6338 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6339 let context = self.resolve_context(&resolved)?;
6340 let source_workspace_id = resolved.target.workspace_id.clone();
6341 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6342 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6343 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6344 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6345 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6346 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6347 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6348 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6349 };
6350 let runtime_policy = timeout(
6351 spawn_deadline_remaining(deadline)?,
6352 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6353 ).await.map_err(|_| failure(
6354 NodeFailureCode::SpawnDeadlineExceeded,
6355 "provider admission exceeded the managed spawn deadline",
6356 ))??;
6357 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6358 return Err(failure(
6359 NodeFailureCode::UnsupportedSpawnCapability,
6360 "provider runtime does not admit a required spawn capability",
6361 ));
6362 }
6363 self.ensure_binding_capacity()?;
6364 let source_root = self.workspace_root(&source_workspace_id)?;
6365 profile.validate_for_workspace(&source_root)
6366 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6367 let base_timeout_ms = u64::try_from(
6368 spawn_deadline_remaining(deadline)?.as_millis(),
6369 ).unwrap_or(u64::MAX).max(1);
6370 let base_commit = resolve_base_commit_with_timeout(
6371 &source_root,
6372 profile.base(),
6373 base_timeout_ms,
6374 ).await.map_err(|error| {
6375 if Instant::now() >= deadline || error.message.contains("timed out") {
6376 failure(
6377 NodeFailureCode::SpawnDeadlineExceeded,
6378 "managed worktree base resolution exceeded the spawn deadline",
6379 )
6380 } else {
6381 managed_git_worktree_failure(error)
6382 }
6383 })?;
6384
6385 let lease = {
6386 let _transaction = self.state_transaction.lock()
6387 .unwrap_or_else(|poisoned| poisoned.into_inner());
6388 let previous = self.managed_worktrees.lock()
6389 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6390 let lease = self.managed_worktrees.lock()
6391 .unwrap_or_else(|poisoned| poisoned.into_inner())
6392 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6393 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6394 let previous_replay = match durable_v2_key
6395 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6396 .transpose()
6397 {
6398 Ok(previous) => previous,
6399 Err(error) => {
6400 *self.managed_worktrees.lock()
6401 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6402 return Err(error);
6403 }
6404 };
6405 if let Err(error) = self.persist_state_locked() {
6406 *self.managed_worktrees.lock()
6407 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6408 if let (Some(key), Some(previous_replay)) =
6409 (durable_v2_key, previous_replay)
6410 {
6411 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6412 }
6413 return Err(persistence_failure(error));
6414 }
6415 lease
6416 };
6417 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6418 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6419 self.managed_worktrees.lock()
6420 .unwrap_or_else(|poisoned| poisoned.into_inner())
6421 .remove_unmutated(&lease.lease_id);
6422 if self.persist_state().is_err() {
6423 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6424 }
6425 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6426 self.remember_managed_spawn_attempt(
6427 durable_v2_key,
6428 request,
6429 result.clone(),
6430 accepted_at,
6431 );
6432 return result;
6433 }
6434 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6435 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6436 Err(error) => {
6437 self.reconcile_failed_allocation(&lease, &source_root).await;
6438 let result = Err(error);
6439 self.remember_managed_spawn_attempt(
6440 durable_v2_key,
6441 request,
6442 result.clone(),
6443 accepted_at,
6444 );
6445 return result;
6446 }
6447 };
6448 let created = match create_git_worktree_with_timeout(
6449 &source_root,
6450 &lease.target_root,
6451 &lease.branch,
6452 Some(&base_commit),
6453 mutation_timeout_ms,
6454 ).await {
6455 Ok(created) => created,
6456 Err(error) => {
6457 self.reconcile_failed_allocation(&lease, &source_root).await;
6458 let result = Err(if Instant::now() >= deadline
6459 || error.message.contains("timed out")
6460 || error.message.contains("deadline")
6461 {
6462 failure(
6463 NodeFailureCode::SpawnDeadlineExceeded,
6464 "managed worktree creation exceeded the spawn deadline",
6465 )
6466 } else {
6467 managed_git_worktree_failure(error)
6468 });
6469 self.remember_managed_spawn_attempt(
6470 durable_v2_key,
6471 request,
6472 result.clone(),
6473 accepted_at,
6474 );
6475 return result;
6476 }
6477 };
6478 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6479 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6480 let result = Err(failure(
6481 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6482 "created worktree identity did not match its durable lease",
6483 ));
6484 self.remember_managed_spawn_attempt(
6485 durable_v2_key,
6486 request,
6487 result.clone(),
6488 accepted_at,
6489 );
6490 return result;
6491 }
6492 {
6493 let mut registry = self.managed_worktrees.lock()
6494 .unwrap_or_else(|poisoned| poisoned.into_inner());
6495 let current = registry.get_mut(&lease.lease_id)
6496 .expect("allocated managed worktree lease remains present");
6497 current.expected_head = Some(created.head.clone());
6498 current.state = ManagedWorktreeLeaseState::Ready;
6499 current.updated_at_unix_ms = unix_time_ms();
6500 }
6501 if let Err(error) = self.persist_state() {
6502 self.mark_managed_recovery_required(
6503 &lease.lease_id,
6504 ManagedWorktreeCleanupFailure::Backend,
6505 );
6506 let result = Err(persistence_failure(error));
6507 self.remember_managed_spawn_attempt(
6508 durable_v2_key,
6509 request,
6510 result.clone(),
6511 accepted_at,
6512 );
6513 return result;
6514 }
6515 let ready = self.managed_worktrees.lock()
6516 .unwrap_or_else(|poisoned| poisoned.into_inner())
6517 .get(&lease.lease_id)
6518 .expect("ready managed worktree lease remains present")
6519 .snapshot();
6520 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6521 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6522 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6523 let result = Err(error);
6524 self.remember_managed_spawn_attempt(
6525 durable_v2_key,
6526 request,
6527 result.clone(),
6528 accepted_at,
6529 );
6530 return result;
6531 }
6532 let spawn = self.spawn_session_with_deadline(
6533 lease.workspace_id.clone(),
6534 resolved.provider.clone(),
6535 resolved.mode,
6536 resolved.terminal_size,
6537 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6538 environment_profile.clone(),
6539 bundle.clone(),
6540 context.clone(),
6541 Some(lease.lease_id.clone()),
6542 Some(runtime_policy),
6543 SpawnRecordPolicy::Always,
6544 Some(deadline),
6545 &required_capabilities,
6546 None,
6547 resolved.approval_level,
6548 resolved.network_allowlist.clone(),
6549 resolved.browser_profile_id.clone(),
6550 ).await;
6551 let (session, effective_runtime_policy) = match spawn {
6552 Ok(spawned) => spawned,
6553 Err(error) => {
6554 if self
6555 .cleanup_managed_worktree(&lease.lease_id, true)
6556 .await
6557 .is_err()
6558 {
6559 self.mark_managed_recovery_required(
6560 &lease.lease_id,
6561 ManagedWorktreeCleanupFailure::Backend,
6562 );
6563 }
6564 let result = Err(error);
6565 self.remember_managed_spawn_attempt(
6566 durable_v2_key,
6567 request,
6568 result.clone(),
6569 accepted_at,
6570 );
6571 return result;
6572 }
6573 };
6574 let (record_id, raw_inventory_record_id) = {
6575 let mut bindings = self.session_bindings.lock()
6576 .unwrap_or_else(|poisoned| poisoned.into_inner());
6577 let binding = bindings.get_mut(&session.session.instance_id)
6578 .expect("successful spawn retains its session binding");
6579 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6580 managed_spawn_record_ownership(binding, effective_runtime_policy)
6581 };
6582 let bound = self.managed_worktrees.lock()
6583 .unwrap_or_else(|poisoned| poisoned.into_inner())
6584 .bind_session(
6585 &lease.lease_id,
6586 ManagedWorktreeSessionHolder {
6587 incarnation_id: self.incarnation_id,
6588 instance_id: session.session.instance_id,
6589 generation: session.session.generation,
6590 },
6591 record_id,
6592 unix_time_ms(),
6593 )
6594 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6595 let snapshot = match bound {
6596 Ok(snapshot) => snapshot,
6597 Err(error) => {
6598 if raw_inventory_record_id
6599 .as_ref()
6600 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6601 {
6602 self.mark_managed_recovery_required(
6603 &lease.lease_id,
6604 ManagedWorktreeCleanupFailure::Backend,
6605 );
6606 }
6607 let _ = self.remove_session(&session).await;
6608 self.mark_managed_recovery_required(
6609 &lease.lease_id,
6610 ManagedWorktreeCleanupFailure::Backend,
6611 );
6612 let result = Err(error);
6613 self.remember_managed_spawn_attempt(
6614 durable_v2_key,
6615 request,
6616 result.clone(),
6617 accepted_at,
6618 );
6619 return result;
6620 }
6621 };
6622 if let Err(error) = self.persist_state() {
6623 if raw_inventory_record_id
6624 .as_ref()
6625 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6626 {
6627 self.mark_managed_recovery_required(
6628 &lease.lease_id,
6629 ManagedWorktreeCleanupFailure::Backend,
6630 );
6631 }
6632 let _ = self.remove_session(&session).await;
6633 if self
6634 .cleanup_managed_worktree(&lease.lease_id, true)
6635 .await
6636 .is_err()
6637 {
6638 self.mark_managed_recovery_required(
6639 &lease.lease_id,
6640 ManagedWorktreeCleanupFailure::Backend,
6641 );
6642 }
6643 let result = Err(persistence_failure(error));
6644 self.remember_managed_spawn_attempt(
6645 durable_v2_key,
6646 request,
6647 result.clone(),
6648 accepted_at,
6649 );
6650 return result;
6651 }
6652 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6653 let receipt = managed_spawn_receipt(
6654 &resolved,
6655 self.incarnation_id,
6656 session,
6657 snapshot,
6658 environment_profile,
6659 bundle,
6660 context,
6661 );
6662 let result = Ok(receipt);
6663 self.remember_managed_spawn_attempt(
6664 durable_v2_key,
6665 request,
6666 result.clone(),
6667 accepted_at,
6668 );
6669 result
6670 }
6671
6672 async fn spawn_managed_worktree_v2(
6673 &self,
6674 request: ManagedWorktreeSpawnRequestV2,
6675 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6676 if request.spawn_spec.target.node_id != self.node_id {
6677 return Err(failure(
6678 NodeFailureCode::SpawnTargetMismatch,
6679 "spawn target does not match this node",
6680 ));
6681 }
6682 if request.spawn_spec.target.worktree_id.is_some() {
6683 return Err(failure(
6684 NodeFailureCode::InvalidRequest,
6685 "managed worktree spawn must not select a caller-provided worktree",
6686 ));
6687 }
6688 let request_digest = managed_spawn_request_digest_v2(&request)?;
6689 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6690 match replay {
6691 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6692 DurableManagedSpawnReplayDecision::PendingLinked => {
6693 return Err(failure(
6694 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6695 "managed worktree V2 reservation has an incomplete durable allocation",
6696 ));
6697 }
6698 DurableManagedSpawnReplayDecision::Reserved => {}
6699 }
6700 let validated = (|| {
6701 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6702 let profile = self.managed_profile(
6703 &resolved.target.workspace_id,
6704 &request.worktree_profile_id,
6705 )?;
6706 if profile.revision() != &request.expected_profile_revision {
6707 return Err(failure(
6708 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6709 "managed worktree profile revision does not match the loaded profile",
6710 ));
6711 }
6712 Ok(())
6713 })();
6714 match validated {
6715 Ok(()) => {}
6716 Err(error) => {
6717 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6718 return Err(error);
6719 }
6720 }
6721 let legacy = ManagedWorktreeSpawnRequest {
6722 spawn_spec: request.spawn_spec.clone(),
6723 worktree_profile_id: request.worktree_profile_id.clone(),
6724 };
6725 match self
6726 .spawn_managed_worktree_inner(
6727 legacy,
6728 Some(&request.spawn_spec.idempotency_key),
6729 )
6730 .await
6731 {
6732 Ok(receipt) => {
6733 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6734 Ok(receipt)
6735 }
6736 Err(error) => {
6737 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6738 Err(error)
6739 }
6740 }
6741 }
6742
6743 fn managed_profile(
6744 &self,
6745 workspace_id: &WorkspaceId,
6746 profile_id: &WorktreeProfileId,
6747 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6748 if self.worktree_service_modes.get(workspace_id).copied()
6749 != Some(WorktreeServiceMode::Managed)
6750 {
6751 return Err(failure(
6752 NodeFailureCode::UnsupportedSpawnCapability,
6753 "workspace does not enable managed worktrees",
6754 ));
6755 }
6756 self.managed_worktree_profiles.get(workspace_id)
6757 .and_then(|profiles| profiles.get(profile_id))
6758 .cloned()
6759 .ok_or_else(|| failure(
6760 NodeFailureCode::InvalidRequest,
6761 "managed worktree profile is unavailable for this workspace",
6762 ))
6763 }
6764
6765 async fn reconcile_failed_allocation(
6766 &self,
6767 lease: &ManagedWorktreeLeaseRecord,
6768 source_root: &str,
6769 ) {
6770 match list_git_worktrees(source_root).await {
6771 Ok(worktrees) => {
6772 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6773 None => {
6774 match std::fs::symlink_metadata(&lease.target_root) {
6775 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6776 self.managed_worktrees.lock()
6777 .unwrap_or_else(|poisoned| poisoned.into_inner())
6778 .remove_unmutated(&lease.lease_id);
6779 let _ = self.persist_state();
6780 }
6781 _ => self.mark_managed_recovery_required(
6782 &lease.lease_id,
6783 ManagedWorktreeCleanupFailure::OwnershipConflict,
6784 ),
6785 }
6786 }
6787 Some(item) if exact_owned_worktree(
6788 lease,
6789 &item.path,
6790 item.branch.as_deref(),
6791 &item.head,
6792 ) => {
6793 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6794 }
6795 Some(_) => self.mark_managed_recovery_required(
6796 &lease.lease_id,
6797 ManagedWorktreeCleanupFailure::OwnershipConflict,
6798 ),
6799 }
6800 }
6801 Err(_) => self.mark_managed_recovery_required(
6802 &lease.lease_id,
6803 ManagedWorktreeCleanupFailure::Backend,
6804 ),
6805 }
6806 }
6807
6808 async fn reconcile_managed_worktrees(&self) {
6809 let leases = self.managed_worktrees.lock()
6810 .unwrap_or_else(|poisoned| poisoned.into_inner())
6811 .active_records()
6812 .cloned()
6813 .collect::<Vec<_>>();
6814 for lease in leases {
6815 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6816 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6817 _ => {
6818 self.mark_managed_recovery_required(
6819 &lease.lease_id,
6820 ManagedWorktreeCleanupFailure::OwnershipConflict,
6821 );
6822 continue;
6823 }
6824 };
6825 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6826 self.mark_managed_recovery_required(
6827 &lease.lease_id,
6828 ManagedWorktreeCleanupFailure::OwnershipConflict,
6829 );
6830 continue;
6831 }
6832 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6833 Ok(root) => root,
6834 Err(_) => {
6835 self.mark_managed_recovery_required(
6836 &lease.lease_id,
6837 ManagedWorktreeCleanupFailure::OwnershipConflict,
6838 );
6839 continue;
6840 }
6841 };
6842 let worktrees = match list_git_worktrees(&source_root).await {
6843 Ok(worktrees) => worktrees,
6844 Err(_) => {
6845 self.mark_managed_recovery_required(
6846 &lease.lease_id,
6847 ManagedWorktreeCleanupFailure::Backend,
6848 );
6849 continue;
6850 }
6851 };
6852 let listed = worktrees.iter()
6853 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6854 let Some(listed) = listed else {
6855 match std::fs::symlink_metadata(&lease.target_root) {
6856 Err(error) if error.kind() == io::ErrorKind::NotFound
6857 && lease.state == ManagedWorktreeLeaseState::Allocating
6858 && !lease.has_holders() => {
6859 let removed = self.managed_worktrees.lock()
6860 .unwrap_or_else(|poisoned| poisoned.into_inner())
6861 .tombstone(&lease.lease_id, unix_time_ms());
6862 if self.persist_state().is_ok() && removed.is_some() {
6863 self.publish(NodeEvent::ManagedWorktreeRemoved {
6864 lease_id: lease.lease_id.clone(),
6865 });
6866 }
6867 }
6868 _ => self.mark_managed_recovery_required(
6869 &lease.lease_id,
6870 ManagedWorktreeCleanupFailure::OwnershipConflict,
6871 ),
6872 }
6873 continue;
6874 };
6875 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6876 &lease,
6877 &listed.path,
6878 listed.branch.as_deref(),
6879 &listed.head,
6880 ) {
6881 self.mark_managed_recovery_required(
6882 &lease.lease_id,
6883 ManagedWorktreeCleanupFailure::OwnershipConflict,
6884 );
6885 continue;
6886 }
6887 if !self.workspaces.read()
6888 .unwrap_or_else(|poisoned| poisoned.into_inner())
6889 .contains_key(&lease.workspace_id)
6890 {
6891 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6892 self.mark_managed_recovery_required(
6893 &lease.lease_id,
6894 ManagedWorktreeCleanupFailure::Backend,
6895 );
6896 continue;
6897 }
6898 }
6899 let snapshot = {
6900 let mut registry = self.managed_worktrees.lock()
6901 .unwrap_or_else(|poisoned| poisoned.into_inner());
6902 let current = registry.get_mut(&lease.lease_id)
6903 .expect("reconciled managed lease remains present");
6904 current.expected_head = Some(listed.head.clone());
6905 current.cleanup_failure = None;
6906 current.state = if current.has_holders() {
6907 ManagedWorktreeLeaseState::InUse
6908 } else if current.retention == ManagedWorktreeRetention::Retain {
6909 ManagedWorktreeLeaseState::Retained
6910 } else {
6911 ManagedWorktreeLeaseState::Ready
6912 };
6913 current.updated_at_unix_ms = unix_time_ms();
6914 current.snapshot()
6915 };
6916 if self.persist_state().is_err() {
6917 self.mark_managed_recovery_required(
6918 &lease.lease_id,
6919 ManagedWorktreeCleanupFailure::Backend,
6920 );
6921 continue;
6922 }
6923 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6924 if snapshot.state == ManagedWorktreeLeaseState::Ready {
6925 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6926 }
6927 }
6928 }
6929
6930 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6950 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6951 .session_records
6952 .lock()
6953 .unwrap_or_else(|poisoned| poisoned.into_inner())
6954 .records
6955 .values()
6956 .filter(|record| {
6957 record.state == ManagedSessionState::Live
6958 && record.exported_context.is_none()
6959 && record.provider_session.is_some()
6960 })
6961 .filter_map(|record| {
6962 record
6963 .active_session
6964 .clone()
6965 .map(|session| (record.record_id.clone(), session))
6966 })
6967 .collect();
6968 for (record_id, session) in candidates {
6969 let shared = Arc::clone(self);
6970 tokio::spawn(async move {
6971 shared
6972 .reconcile_context_pack_export_for_record(&record_id, session)
6973 .await;
6974 });
6975 }
6976 }
6977
6978 async fn reconcile_context_pack_export_for_record(
6997 &self,
6998 record_id: &SessionRecordId,
6999 session: SessionAddress,
7000 ) {
7001 let Ok(record) = self.record(record_id) else {
7002 return;
7003 };
7004 if !matches!(
7005 record.state,
7006 ManagedSessionState::Live | ManagedSessionState::Dormant
7007 ) || record.exported_context.is_some()
7008 || record.provider_session.is_none()
7009 {
7010 return;
7011 }
7012 let Ok(receipt) = self
7019 .export_context_pack_for_session_record(&record.record_id, &session, true)
7020 .await
7021 else {
7022 return;
7023 };
7024 self.set_exported_context(&record.record_id, receipt);
7025 }
7026
7027 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7032 let transaction = self
7033 .state_transaction
7034 .lock()
7035 .unwrap_or_else(|poisoned| poisoned.into_inner());
7036 let (previous, updated) = {
7037 let mut records = self
7038 .session_records
7039 .lock()
7040 .unwrap_or_else(|poisoned| poisoned.into_inner());
7041 let Some(record) = records.records.get_mut(record_id) else {
7042 return;
7043 };
7044 if record.exported_context.is_some() {
7045 return;
7046 }
7047 let previous = record.clone();
7048 record.exported_context = Some(receipt);
7049 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7050 (previous, record.clone())
7051 };
7052 if self.persist_state_locked().is_err() {
7053 self.session_records
7054 .lock()
7055 .unwrap_or_else(|poisoned| poisoned.into_inner())
7056 .records
7057 .insert(record_id.clone(), previous);
7058 return;
7059 }
7060 drop(transaction);
7061 self.publish_record(updated);
7062 }
7063
7064 fn managed_record_export_target_for_instance(
7074 &self,
7075 instance_id: AgentInstanceId,
7076 generation: SessionGeneration,
7077 ) -> Option<(SessionRecordId, SessionAddress)> {
7078 let bindings = self
7079 .session_bindings
7080 .lock()
7081 .unwrap_or_else(|poisoned| poisoned.into_inner());
7082 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7083 let record_id = binding.record_id.clone()?;
7084 Some((
7085 record_id,
7086 SessionAddress {
7087 workspace_id: binding.workspace_id.clone(),
7088 session: SessionKey { instance_id, generation },
7089 },
7090 ))
7091 }
7092
7093 fn mark_managed_recovery_required(
7094 &self,
7095 lease_id: &ManagedWorktreeLeaseId,
7096 failure_kind: ManagedWorktreeCleanupFailure,
7097 ) {
7098 let snapshot = {
7099 let mut registry = self.managed_worktrees.lock()
7100 .unwrap_or_else(|poisoned| poisoned.into_inner());
7101 let Some(lease) = registry.get_mut(lease_id) else { return };
7102 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7103 lease.cleanup_failure = Some(failure_kind);
7104 lease.updated_at_unix_ms = unix_time_ms();
7105 lease.snapshot()
7106 };
7107 let _ = self.persist_state();
7108 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7109 }
7110
7111 fn set_persistence_error(&self, error: Option<String>) {
7112 *self
7113 .persistence_error
7114 .write()
7115 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7116 .map(|message| session_registry::sanitized_persistence_summary(&message));
7117 }
7118
7119 fn persist_state(&self) -> io::Result<()> {
7120 let _transaction = self
7121 .state_transaction
7122 .lock()
7123 .unwrap_or_else(|poisoned| poisoned.into_inner());
7124 self.persist_state_locked()
7125 }
7126
7127 fn persist_state_locked(&self) -> io::Result<()> {
7128 let workspaces = self
7129 .workspaces
7130 .read()
7131 .unwrap_or_else(|poisoned| poisoned.into_inner())
7132 .clone();
7133 let records = self
7134 .session_records
7135 .lock()
7136 .unwrap_or_else(|poisoned| poisoned.into_inner())
7137 .records
7138 .values()
7139 .cloned()
7140 .collect::<Vec<_>>();
7141 let mut managed = self.managed_worktrees
7142 .lock()
7143 .unwrap_or_else(|poisoned| poisoned.into_inner());
7144 managed.reattach_record_holders(
7145 &managed_worktree_record_holders(&records),
7146 unix_time_ms(),
7147 );
7148 let materializations = self
7149 .materializations
7150 .lock()
7151 .unwrap_or_else(|poisoned| poisoned.into_inner())
7152 .values()
7153 .cloned()
7154 .collect::<Vec<_>>();
7155 let managed_spawn_replays = self.managed_spawn_replays
7156 .lock()
7157 .unwrap_or_else(|poisoned| poisoned.into_inner())
7158 .values()
7159 .cloned()
7160 .collect::<Vec<_>>();
7161 let result = session_registry::save_v10(
7162 self.state_path.as_deref(),
7163 &self.node_id,
7164 &workspaces,
7165 &records,
7166 &managed.records(),
7167 &managed.tombstones(),
7168 &materializations,
7169 &managed_spawn_replays,
7170 );
7171 match &result {
7172 Ok(warning) => self.set_persistence_error(warning.clone()),
7173 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7174 }
7175 result.map(|_| ())
7176 }
7177
7178 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7179 let _mutation_guard = self.mutation_gate.lock().await;
7180 self.begin_shutdown_locked().await
7181 }
7182
7183 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7184 if self.shutdown.swap(true, Ordering::AcqRel) {
7185 return Ok(());
7186 }
7187 SessionLifecycleHook::on_shutdown(self);
7188 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7189 let mut first_error = None;
7190 for session in &self.handle.snapshot().sessions {
7191 let remaining = deadline.saturating_duration_since(Instant::now());
7192 if remaining.is_zero() {
7193 first_error.get_or_insert_with(|| {
7194 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7195 });
7196 break;
7197 }
7198 if let Err(error) = self
7199 .dispatch_bounded(
7200 ControlCommand::Stop {
7201 instance_id: session.instance_id,
7202 force: true,
7203 },
7204 remaining,
7205 )
7206 .await
7207 {
7208 first_error.get_or_insert(error);
7209 }
7210 }
7211 self.shutdown_notify.notify_waiters();
7212 match first_error {
7213 Some(error) => Err(error),
7214 None => Ok(()),
7215 }
7216 }
7217
7218 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7219 let bindings = self
7220 .session_bindings
7221 .lock()
7222 .unwrap_or_else(|poisoned| poisoned.into_inner());
7223 if bindings.len() >= CONTROL_SESSIONS_MAX {
7224 Err(failure(
7225 NodeFailureCode::BackendBusy,
7226 "node session-binding capacity is full; remove an existing session first",
7227 ))
7228 } else {
7229 Ok(())
7230 }
7231 }
7232
7233 fn ensure_workspace_environment_profile_compatible(
7240 &self,
7241 workspace_id: &WorkspaceId,
7242 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7243 ) -> Result<(), NodeFailure> {
7244 let bindings = self
7245 .session_bindings
7246 .lock()
7247 .unwrap_or_else(|poisoned| poisoned.into_inner());
7248 for binding in bindings.values() {
7249 if &binding.workspace_id != workspace_id {
7250 continue;
7251 }
7252 if binding.environment_profile.as_ref() != environment_profile {
7253 return Err(failure(
7254 NodeFailureCode::WorkspaceBusy,
7255 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7256 ));
7257 }
7258 }
7259 Ok(())
7260 }
7261
7262 #[cfg(test)]
7263 fn bind_session(
7264 &self,
7265 instance_id: AgentInstanceId,
7266 workspace_id: WorkspaceId,
7267 generation: SessionGeneration,
7268 ) {
7269 self.bind_session_with_policy(
7270 &SessionAddress {
7271 workspace_id,
7272 session: SessionKey { instance_id, generation },
7273 },
7274 ProviderRuntimePolicy::raw_pty(),
7275 None,
7276 );
7277 }
7278
7279 #[cfg(test)]
7280 fn bind_session_with_policy(
7281 &self,
7282 address: &SessionAddress,
7283 runtime_policy: ProviderRuntimePolicy,
7284 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7285 ) {
7286 self.bind_session_with_materialization(
7287 address,
7288 runtime_policy,
7289 environment_profile,
7290 None,
7291 None,
7292 None,
7293 );
7294 }
7295
7296 fn bind_session_with_materialization(
7297 &self,
7298 address: &SessionAddress,
7299 runtime_policy: ProviderRuntimePolicy,
7300 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7301 bundle: Option<ResolvedBundleReceipt>,
7302 context: Option<ResolvedContextPackReceipt>,
7303 materialization_id: Option<MaterializationId>,
7304 ) {
7305 let mut bindings = self
7306 .session_bindings
7307 .lock()
7308 .unwrap_or_else(|poisoned| poisoned.into_inner());
7309 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7310 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7311 bindings.insert(address.session.instance_id, SessionBinding {
7312 workspace_id: address.workspace_id.clone(),
7313 generation: address.session.generation,
7314 runtime_policy,
7315 pending_resume: None,
7316 record_id: None,
7317 managed_worktree_lease_id: None,
7318 environment_profile,
7319 bundle,
7320 context,
7321 materialization_id,
7322 });
7323 }
7324
7325 #[cfg(test)]
7326 fn bind_managed_session(
7327 &self,
7328 address: &SessionAddress,
7329 record_id: SessionRecordId,
7330 runtime_policy: ProviderRuntimePolicy,
7331 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7332 ) {
7333 self.bind_managed_session_with_materialization(
7334 address,
7335 record_id,
7336 runtime_policy,
7337 environment_profile,
7338 None,
7339 None,
7340 None,
7341 );
7342 }
7343
7344 fn bind_managed_session_with_materialization(
7345 &self,
7346 address: &SessionAddress,
7347 record_id: SessionRecordId,
7348 runtime_policy: ProviderRuntimePolicy,
7349 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7350 bundle: Option<ResolvedBundleReceipt>,
7351 context: Option<ResolvedContextPackReceipt>,
7352 materialization_id: Option<MaterializationId>,
7353 ) {
7354 let mut bindings = self
7355 .session_bindings
7356 .lock()
7357 .unwrap_or_else(|poisoned| poisoned.into_inner());
7358 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7359 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7360 bindings.insert(
7361 address.session.instance_id,
7362 SessionBinding {
7363 workspace_id: address.workspace_id.clone(),
7364 generation: address.session.generation,
7365 runtime_policy,
7366 pending_resume: None,
7367 record_id: Some(record_id),
7368 managed_worktree_lease_id: None,
7369 environment_profile,
7370 bundle,
7371 context,
7372 materialization_id,
7373 },
7374 );
7375 }
7376
7377 #[cfg(test)]
7378 fn bind_spawn_session(
7379 &self,
7380 address: &SessionAddress,
7381 provider: AgentId,
7382 mode: SessionMode,
7383 runtime_policy: ProviderRuntimePolicy,
7384 record_policy: SpawnRecordPolicy,
7385 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7386 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7387 self.bind_spawn_session_with_materialization(
7388 address,
7389 provider,
7390 mode,
7391 runtime_policy,
7392 record_policy,
7393 environment_profile,
7394 None,
7395 None,
7396 None,
7397 )
7398 }
7399
7400 fn bind_spawn_session_with_materialization(
7401 &self,
7402 address: &SessionAddress,
7403 provider: AgentId,
7404 mode: SessionMode,
7405 runtime_policy: ProviderRuntimePolicy,
7406 record_policy: SpawnRecordPolicy,
7407 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7408 bundle: Option<ResolvedBundleReceipt>,
7409 context: Option<ResolvedContextPackReceipt>,
7410 materialization_id: Option<MaterializationId>,
7411 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7412 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7413 && !runtime_policy.provider_session_identity
7414 {
7415 self.bind_session_with_materialization(
7416 address,
7417 runtime_policy,
7418 environment_profile,
7419 bundle,
7420 context,
7421 materialization_id,
7422 );
7423 return Ok(None);
7424 }
7425 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7426 let record = self.new_record(
7427 address,
7428 provider,
7429 mode,
7430 active_record_state(runtime_policy.provider_session_identity, false),
7431 None,
7432 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7433 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7434 record_owns_lifecycle.then(|| context.clone()).flatten(),
7435 )?;
7436 let record_id = record.record_id.clone();
7437 let transaction = self
7438 .state_transaction
7439 .lock()
7440 .unwrap_or_else(|poisoned| poisoned.into_inner());
7441 self.insert_record(record.clone())?;
7442 let previous_materialization = if record_owns_lifecycle {
7443 if let Some(materialization_id) = materialization_id.as_ref() {
7444 let mut materializations = self
7445 .materializations
7446 .lock()
7447 .unwrap_or_else(|poisoned| poisoned.into_inner());
7448 let Some(ownership) = materializations.get_mut(materialization_id) else {
7449 drop(materializations);
7450 self.remove_record_memory(&record_id);
7451 return Err(failure(
7452 NodeFailureCode::BackendOperationFailed,
7453 "session environment ownership disappeared before binding",
7454 ));
7455 };
7456 let previous = ownership.clone();
7457 if ownership
7458 .transfer_to_record(record_id.clone(), unix_time_ms())
7459 .is_err()
7460 {
7461 drop(materializations);
7462 self.remove_record_memory(&record_id);
7463 return Err(failure(
7464 NodeFailureCode::BackendOperationFailed,
7465 "session environment ownership transfer failed",
7466 ));
7467 }
7468 Some(previous)
7469 } else {
7470 None
7471 }
7472 } else {
7473 None
7474 };
7475 self.bind_managed_session_with_materialization(
7476 address,
7477 record_id.clone(),
7478 runtime_policy,
7479 environment_profile,
7480 bundle,
7481 context,
7482 materialization_id.clone(),
7483 );
7484 if let Err(error) = self.persist_state_locked() {
7485 self.remove_binding(address);
7486 self.remove_record_memory(&record_id);
7487 if let (Some(materialization_id), Some(previous)) =
7488 (materialization_id.as_ref(), previous_materialization)
7489 {
7490 self.materializations
7491 .lock()
7492 .unwrap_or_else(|poisoned| poisoned.into_inner())
7493 .insert(materialization_id.clone(), previous);
7494 }
7495 return Err(persistence_failure(error));
7496 }
7497 drop(transaction);
7498 self.publish_record(record);
7499 Ok(Some(record_id))
7500 }
7501
7502 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7503 for _ in 0..8 {
7504 let bytes = random_nonce().map_err(|error| {
7505 failure(
7506 NodeFailureCode::BackendOperationFailed,
7507 &format!("session record identity generation failed: {error}"),
7508 )
7509 })?;
7510 let mut value = String::with_capacity(27);
7511 value.push_str("sr-");
7512 for byte in &bytes[..12] {
7513 use std::fmt::Write as _;
7514 let _ = write!(&mut value, "{byte:02x}");
7515 }
7516 let record_id = SessionRecordId::new(value).map_err(|error| {
7517 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7518 })?;
7519 let records = self
7520 .session_records
7521 .lock()
7522 .unwrap_or_else(|poisoned| poisoned.into_inner());
7523 if !records.records.contains_key(&record_id) {
7524 return Ok(record_id);
7525 }
7526 }
7527 Err(failure(
7528 NodeFailureCode::BackendBusy,
7529 "could not allocate a unique session record ID",
7530 ))
7531 }
7532
7533 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7534 for _ in 0..8 {
7535 let bytes = random_nonce().map_err(|error| {
7536 failure(
7537 NodeFailureCode::BackendOperationFailed,
7538 &format!("task identity generation failed: {error}"),
7539 )
7540 })?;
7541 let mut nonce = [0_u8; 12];
7542 nonce.copy_from_slice(&bytes[..12]);
7543 let task_id = TaskId::from_nonce(nonce);
7544 let records = self
7545 .session_records
7546 .lock()
7547 .unwrap_or_else(|poisoned| poisoned.into_inner());
7548 if !records.records.values().any(|record| {
7549 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7550 == Some(&task_id)
7551 }) {
7552 return Ok(task_id);
7553 }
7554 }
7555 Err(failure(
7556 NodeFailureCode::BackendBusy,
7557 "could not allocate a unique task ID",
7558 ))
7559 }
7560
7561 fn default_record_name(&self, provider: &AgentId) -> String {
7562 let records = self
7563 .session_records
7564 .lock()
7565 .unwrap_or_else(|poisoned| poisoned.into_inner());
7566 let ordinal = records
7567 .records
7568 .values()
7569 .filter(|record| &record.provider == provider)
7570 .count()
7571 .saturating_add(1);
7572 format!("{} #{ordinal}", provider.as_str())
7573 }
7574
7575 fn new_record(
7576 &self,
7577 address: &SessionAddress,
7578 provider: AgentId,
7579 mode: SessionMode,
7580 state: ManagedSessionState,
7581 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7582 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7583 bundle: Option<ResolvedBundleReceipt>,
7584 context: Option<ResolvedContextPackReceipt>,
7585 ) -> Result<ManagedSessionRecord, NodeFailure> {
7586 let canonical_root = self.workspace_root(&address.workspace_id)?;
7587 let now = unix_time_ms();
7588 Ok(ManagedSessionRecord {
7589 record_id: self.allocate_record_id()?,
7590 display_name: self.default_record_name(&provider),
7591 provider,
7592 mode,
7593 state,
7594 workspace_id: address.workspace_id.clone(),
7595 canonical_root: opaque_windows_path(canonical_root),
7596 provider_session,
7597 active_session: Some(address.clone()),
7598 environment_profile,
7599 bundle,
7600 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7601 context,
7602 exported_context: None,
7603 task_binding: None,
7604 created_at_unix_ms: now,
7605 updated_at_unix_ms: now,
7606 last_error: None,
7607 })
7608 }
7609
7610 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7611 let mut records = self
7612 .session_records
7613 .lock()
7614 .unwrap_or_else(|poisoned| poisoned.into_inner());
7615 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7616 return Err(failure(
7617 NodeFailureCode::BackendBusy,
7618 "managed session record capacity is full; forget an inactive record first",
7619 ));
7620 }
7621 if let Some(identity) = record.provider_session.as_ref() {
7622 if records.records.values().any(|candidate| {
7623 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7624 session_registry::same_provider_session(
7625 &candidate.provider,
7626 candidate_identity,
7627 &record.provider,
7628 identity,
7629 )
7630 })
7631 }) {
7632 return Err(failure(
7633 NodeFailureCode::SessionRecordConflict,
7634 "provider session is already represented by another managed record",
7635 ));
7636 }
7637 }
7638 match records.records.entry(record.record_id.clone()) {
7639 std::collections::btree_map::Entry::Vacant(entry) => {
7640 entry.insert(record);
7641 }
7642 std::collections::btree_map::Entry::Occupied(_) => {
7643 return Err(failure(
7644 NodeFailureCode::SessionRecordConflict,
7645 "session record ID collision",
7646 ));
7647 }
7648 }
7649 Ok(())
7650 }
7651
7652 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7653 self.session_records
7654 .lock()
7655 .unwrap_or_else(|poisoned| poisoned.into_inner())
7656 .records
7657 .get(record_id)
7658 .cloned()
7659 .ok_or_else(|| {
7660 failure(
7661 NodeFailureCode::UnknownSessionRecord,
7662 "managed session record does not exist",
7663 )
7664 })
7665 }
7666
7667 fn revalidate_session_record_preview(
7668 &self,
7669 expected: &ManagedSessionRecord,
7670 identity: &ProviderSessionIdentity,
7671 ) -> Result<(), NodeFailure> {
7672 let current = self.record(&expected.record_id)?;
7673 let current_root = self.workspace_root(¤t.workspace_id)?;
7674 if current.provider != expected.provider
7675 || current.workspace_id != expected.workspace_id
7676 || current.canonical_root != expected.canonical_root
7677 || current.provider_session.as_ref() != Some(identity)
7678 || !platform::roots_equal(
7679 ¤t_root,
7680 windows_path_text(¤t.canonical_root),
7681 )
7682 {
7683 return Err(failure(
7684 NodeFailureCode::SessionRecordConflict,
7685 "managed session changed while its preview was loading",
7686 ));
7687 }
7688 Ok(())
7689 }
7690
7691 fn revalidate_session_record_context_export(
7692 &self,
7693 expected: &ManagedSessionRecord,
7694 identity: Option<&ProviderSessionIdentity>,
7695 session: &SessionAddress,
7696 allow_clean_detachment: bool,
7697 ) -> Result<(), NodeFailure> {
7698 let current = self.record(&expected.record_id)?;
7699 let current_root = self.workspace_root(¤t.workspace_id)?;
7700 let snapshot = self.internal_session_snapshot(session)?;
7701 let binding = self
7702 .session_bindings
7703 .lock()
7704 .unwrap_or_else(|poisoned| poisoned.into_inner())
7705 .get(&session.session.instance_id)
7706 .cloned()
7707 .ok_or_else(|| {
7708 failure(
7709 NodeFailureCode::SessionRecordConflict,
7710 "managed session runtime binding disappeared during context export",
7711 )
7712 })?;
7713 if !session_record_context_export_binding_is_exact(
7714 expected,
7715 ¤t,
7716 identity,
7717 session,
7718 &binding,
7719 &snapshot.agent_id,
7720 ¤t_root,
7721 allow_clean_detachment,
7722 ) || !session_record_context_export_source_is_usable(
7723 ¤t,
7724 &snapshot.status,
7725 allow_clean_detachment,
7726 ) {
7727 return Err(failure(
7728 NodeFailureCode::SessionRecordConflict,
7729 "managed session binding changed during context export",
7730 ));
7731 }
7732 Ok(())
7733 }
7734
7735 fn revalidate_loaded_history(
7736 &self,
7737 session: &SessionAddress,
7738 candidate_id: &str,
7739 loaded: &HistorySessionRecord,
7740 ) -> Result<(), NodeFailure> {
7741 let snapshot = self.internal_session_snapshot(session)?;
7742 if snapshot.history.pending.is_some()
7743 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7744 || snapshot.history.loaded.as_ref() != Some(loaded)
7745 {
7746 return Err(failure(
7747 NodeFailureCode::SessionRecordConflict,
7748 "managed session history changed during context export",
7749 ));
7750 }
7751 Ok(())
7752 }
7753
7754 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7755 self.session_records
7756 .lock()
7757 .unwrap_or_else(|poisoned| poisoned.into_inner())
7758 .records
7759 .remove(record_id)
7760 }
7761
7762 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7763 if self.destroy_record_materialization(record_id)? {
7764 self.publish(NodeEvent::SessionRecordRemoved {
7765 record_id: record_id.clone(),
7766 });
7767 return Ok(());
7768 }
7769 let transaction = self
7770 .state_transaction
7771 .lock()
7772 .unwrap_or_else(|poisoned| poisoned.into_inner());
7773 let Some(removed) = self.remove_record_memory(record_id) else {
7774 return Ok(());
7775 };
7776 if let Err(error) = self.persist_state_locked() {
7777 let _ = self.insert_record(removed);
7778 return Err(persistence_failure(error));
7779 }
7780 drop(transaction);
7781 self.publish(NodeEvent::SessionRecordRemoved {
7782 record_id: record_id.clone(),
7783 });
7784 Ok(())
7785 }
7786
7787 fn publish_record(&self, record: ManagedSessionRecord) {
7788 self.publish(NodeEvent::SessionRecordUpserted { record });
7789 }
7790
7791 fn mark_record_error(
7792 &self,
7793 record_id: &SessionRecordId,
7794 message: &str,
7795 ) -> Result<(), NodeFailure> {
7796 let transaction = self
7797 .state_transaction
7798 .lock()
7799 .unwrap_or_else(|poisoned| poisoned.into_inner());
7800 let previous = {
7801 let mut records = self
7802 .session_records
7803 .lock()
7804 .unwrap_or_else(|poisoned| poisoned.into_inner());
7805 let Some(record) = records.records.get_mut(record_id) else {
7806 return Ok(());
7807 };
7808 let previous = record.clone();
7809 record.state = ManagedSessionState::Unavailable;
7810 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7811 record.updated_at_unix_ms = unix_time_ms();
7812 previous
7813 };
7814 if let Err(error) = self.persist_state_locked() {
7815 self.session_records
7816 .lock()
7817 .unwrap_or_else(|poisoned| poisoned.into_inner())
7818 .records
7819 .insert(record_id.clone(), previous);
7820 return Err(persistence_failure(error));
7821 }
7822 let record = self.record(record_id)?;
7823 drop(transaction);
7824 self.publish_record(record);
7825 Ok(())
7826 }
7827
7828 fn rename_session_record(
7829 &self,
7830 record_id: &SessionRecordId,
7831 display_name: String,
7832 ) -> Result<ManagedSessionRecord, NodeFailure> {
7833 validate_display_name(&display_name)
7834 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7835 let _transaction = self
7836 .state_transaction
7837 .lock()
7838 .unwrap_or_else(|poisoned| poisoned.into_inner());
7839 let previous = {
7840 let mut records = self
7841 .session_records
7842 .lock()
7843 .unwrap_or_else(|poisoned| poisoned.into_inner());
7844 let record = records.records.get_mut(record_id).ok_or_else(|| {
7845 failure(
7846 NodeFailureCode::UnknownSessionRecord,
7847 "managed session record does not exist",
7848 )
7849 })?;
7850 let previous = record.clone();
7851 record.display_name = display_name;
7852 record.updated_at_unix_ms = unix_time_ms();
7853 previous
7854 };
7855 if let Err(error) = self.persist_state_locked() {
7856 self.session_records
7857 .lock()
7858 .unwrap_or_else(|poisoned| poisoned.into_inner())
7859 .records
7860 .insert(record_id.clone(), previous);
7861 return Err(persistence_failure(error));
7862 }
7863 let updated = self.record(record_id)?;
7864 self.publish_record(updated.clone());
7865 Ok(updated)
7866 }
7867
7868 fn set_session_task(
7869 &self,
7870 record_id: &SessionRecordId,
7871 expected_revision: u64,
7872 target: SessionTaskTargetV1,
7873 ) -> Result<ManagedSessionRecord, NodeFailure> {
7874 let transaction = self
7875 .state_transaction
7876 .lock()
7877 .unwrap_or_else(|poisoned| poisoned.into_inner());
7878 let current = self.record(record_id)?;
7879 let current_task_id = current
7880 .task_binding
7881 .as_ref()
7882 .and_then(|binding| binding.task_id.as_ref());
7883 let current_revision = current
7884 .task_binding
7885 .as_ref()
7886 .map_or(0, |binding| binding.revision);
7887 if expected_revision != current_revision {
7888 return Err(failure(
7889 NodeFailureCode::SessionRecordConflict,
7890 "managed session task binding revision changed",
7891 ));
7892 }
7893 let exact = match &target {
7894 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7895 SessionTaskTargetV1::Clear => current_task_id.is_none(),
7896 SessionTaskTargetV1::New => false,
7897 };
7898 if exact {
7899 return Ok(current);
7900 }
7901 let revision = current_revision.checked_add(1).ok_or_else(|| {
7902 failure(
7903 NodeFailureCode::SessionRecordConflict,
7904 "managed session task binding revision is exhausted",
7905 )
7906 })?;
7907 let task_id = match target {
7908 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7909 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7910 SessionTaskTargetV1::Clear => None,
7911 };
7912 let changed_at_unix_ms = unix_time_ms()
7913 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7914 failure(
7915 NodeFailureCode::SessionRecordConflict,
7916 "managed session record timestamp is exhausted",
7917 )
7918 })?);
7919 let updated = {
7920 let mut records = self
7921 .session_records
7922 .lock()
7923 .unwrap_or_else(|poisoned| poisoned.into_inner());
7924 let record = records.records.get_mut(record_id).ok_or_else(|| {
7925 failure(
7926 NodeFailureCode::UnknownSessionRecord,
7927 "managed session record does not exist",
7928 )
7929 })?;
7930 record.task_binding = Some(SessionTaskBindingV1 {
7931 revision,
7932 task_id,
7933 changed_at_unix_ms,
7934 });
7935 record.updated_at_unix_ms = changed_at_unix_ms;
7936 record.clone()
7937 };
7938 if let Err(error) = self.persist_state_locked() {
7939 self.session_records
7940 .lock()
7941 .unwrap_or_else(|poisoned| poisoned.into_inner())
7942 .records
7943 .insert(record_id.clone(), current);
7944 return Err(persistence_failure(error));
7945 }
7946 drop(transaction);
7947 self.publish_record(updated.clone());
7948 Ok(updated)
7949 }
7950
7951 fn index_provider_session(
7952 &self,
7953 workspace_id: WorkspaceId,
7954 provider: AgentId,
7955 identity: ProviderSessionIdentity,
7956 display_name: String,
7957 ) -> Result<ManagedSessionRecord, NodeFailure> {
7958 self.index_provider_session_with_policy(
7959 workspace_id,
7960 provider,
7961 identity,
7962 display_name,
7963 false,
7964 )
7965 }
7966
7967 fn index_provider_session_with_policy(
7968 &self,
7969 workspace_id: WorkspaceId,
7970 provider: AgentId,
7971 identity: ProviderSessionIdentity,
7972 display_name: String,
7973 allow_validated_transcript_path: bool,
7974 ) -> Result<ManagedSessionRecord, NodeFailure> {
7975 validate_display_name(&display_name)
7976 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7977 identity
7978 .validate()
7979 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7980 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7981 return Err(failure(
7982 NodeFailureCode::InvalidRequest,
7983 "provider session reference index v1 does not accept transcript paths",
7984 ));
7985 }
7986 let canonical_root = self.workspace_root(&workspace_id)?;
7987 if !self.enabled_providers.contains(&provider) {
7988 return Err(failure(
7989 NodeFailureCode::InvalidRequest,
7990 "provider is not enabled on this node",
7991 ));
7992 }
7993
7994 let transaction = self
7995 .state_transaction
7996 .lock()
7997 .unwrap_or_else(|poisoned| poisoned.into_inner());
7998 {
7999 let records = self
8000 .session_records
8001 .lock()
8002 .unwrap_or_else(|poisoned| poisoned.into_inner());
8003 if let Some(existing) = records.records.values().find(|candidate| {
8004 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8005 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8006 })
8007 }) {
8008 if existing.provider == provider && existing.workspace_id == workspace_id {
8009 return Ok(existing.clone());
8010 }
8011 return Err(failure(
8012 NodeFailureCode::SessionRecordConflict,
8013 "provider session reference conflicts with another provider or workspace",
8014 ));
8015 }
8016 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8017 return Err(failure(
8018 NodeFailureCode::BackendBusy,
8019 "managed session record capacity is full; forget an inactive record first",
8020 ));
8021 }
8022 }
8023
8024 let now = unix_time_ms();
8025 let record = ManagedSessionRecord {
8026 record_id: self.allocate_record_id()?,
8027 display_name,
8028 provider,
8029 mode: SessionMode::Pty,
8030 state: ManagedSessionState::Dormant,
8031 workspace_id,
8032 canonical_root: opaque_windows_path(canonical_root),
8033 provider_session: Some(identity),
8034 active_session: None,
8035 environment_profile: None,
8036 bundle: None,
8037 context_id: None,
8038 context: None,
8039 exported_context: None,
8040 task_binding: None,
8041 created_at_unix_ms: now,
8042 updated_at_unix_ms: now,
8043 last_error: None,
8044 };
8045 self.insert_record(record.clone())?;
8046 if let Err(error) = self.persist_state_locked() {
8047 self.remove_record_memory(&record.record_id);
8048 return Err(persistence_failure(error));
8049 }
8050 drop(transaction);
8051 self.publish_record(record.clone());
8052 Ok(record)
8053 }
8054
8055 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8056 self.session_bindings
8057 .lock()
8058 .unwrap_or_else(|poisoned| poisoned.into_inner())
8059 .iter()
8060 .find_map(|(instance_id, binding)| {
8061 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8062 workspace_id: binding.workspace_id.clone(),
8063 session: SessionKey {
8064 instance_id: *instance_id,
8065 generation: binding.generation,
8066 },
8067 })
8068 })
8069 }
8070
8071 async fn forget_session_record(
8072 &self,
8073 record_id: &SessionRecordId,
8074 ) -> Result<(), NodeFailure> {
8075 let record = self.record(record_id)?;
8076 if record.active_session.is_some()
8077 || matches!(
8078 record.state,
8079 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8080 )
8081 {
8082 return Err(failure(
8083 NodeFailureCode::SessionRecordBusy,
8084 "stop the managed session before forgetting it",
8085 ));
8086 }
8087 if let Some(address) = self.bound_address_for_record(record_id) {
8088 self.remove_session(&address).await?;
8089 }
8090 self.remove_managed_session_record(record_id, &record.workspace_id).await
8091 }
8092
8093 async fn remove_managed_session_record(
8108 &self,
8109 record_id: &SessionRecordId,
8110 workspace_id: &WorkspaceId,
8111 ) -> Result<(), NodeFailure> {
8112 let managed_lease_id = self.managed_worktrees.lock()
8113 .unwrap_or_else(|poisoned| poisoned.into_inner())
8114 .lease_for_workspace(workspace_id);
8115 if !self.destroy_record_materialization(record_id)? {
8116 let transaction = self
8117 .state_transaction
8118 .lock()
8119 .unwrap_or_else(|poisoned| poisoned.into_inner());
8120 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8121 failure(
8122 NodeFailureCode::UnknownSessionRecord,
8123 "managed session record does not exist",
8124 )
8125 })?;
8126 if let Err(error) = self.persist_state_locked() {
8127 let _ = self.insert_record(removed);
8128 return Err(persistence_failure(error));
8129 }
8130 drop(transaction);
8131 }
8132 self.publish(NodeEvent::SessionRecordRemoved {
8133 record_id: record_id.clone(),
8134 });
8135 if let Some(lease_id) = managed_lease_id {
8136 if let Some(snapshot) = self.managed_worktrees.lock()
8137 .unwrap_or_else(|poisoned| poisoned.into_inner())
8138 .get(&lease_id)
8139 .map(ManagedWorktreeLeaseRecord::snapshot)
8140 {
8141 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8142 }
8143 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8144 }
8145 Ok(())
8146 }
8147
8148 async fn retire_unavailable_session_records(&self) {
8161 let policy = self.session_record_retention;
8162 let age_enabled = policy.age_ms > 0;
8163 let keep_enabled = policy.keep_per_workspace > 0;
8164 if !age_enabled && !keep_enabled {
8165 return;
8166 }
8167 let now = unix_time_ms();
8168 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8169 self.session_records
8170 .lock()
8171 .unwrap_or_else(|poisoned| poisoned.into_inner())
8172 .records
8173 .values()
8174 .filter(|record| record.state == ManagedSessionState::Unavailable)
8175 .map(|record| {
8176 (
8177 record.record_id.clone(),
8178 record.workspace_id.clone(),
8179 record.provider.clone(),
8180 record.updated_at_unix_ms,
8181 )
8182 })
8183 .collect()
8184 };
8185 if candidates.is_empty() {
8186 return;
8187 }
8188 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8189 BTreeMap::new();
8190 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8191 by_workspace
8192 .entry(workspace_id)
8193 .or_default()
8194 .push((record_id, provider, updated_at_unix_ms));
8195 }
8196 let reason = if age_enabled && keep_enabled {
8197 "age+keep-n"
8198 } else if age_enabled {
8199 "age"
8200 } else {
8201 "keep-n"
8202 };
8203 let mut retired: u64 = 0;
8204 for (workspace_id, mut records) in by_workspace {
8205 records.sort_by(|left, right| right.2.cmp(&left.2));
8208 for (index, (record_id, provider, updated_at_unix_ms)) in
8209 records.into_iter().enumerate()
8210 {
8211 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8212 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8213 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8214 let should_retire = if age_enabled && keep_enabled {
8215 age_hit && keep_hit
8216 } else {
8217 age_hit || keep_hit
8218 };
8219 if !should_retire {
8220 continue;
8221 }
8222 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8223 Ok(()) => {
8224 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8225 retired += 1;
8226 tracing::info!(
8227 node_id = %self.node_id,
8228 record_id = %record_id,
8229 workspace_id = %workspace_id,
8230 provider = %provider,
8231 age_ms = record_age_ms,
8232 reason,
8233 "managed session record retired"
8234 );
8235 }
8236 Err(error) => tracing::warn!(
8237 node_id = %self.node_id,
8238 record_id = %record_id,
8239 workspace_id = %workspace_id,
8240 code = ?error.code,
8241 cause = %error.message,
8242 "session record retention sweep failed to retire a record"
8243 ),
8244 }
8245 }
8246 }
8247 if retired > 0 {
8248 let remaining_unavailable = self
8249 .session_records
8250 .lock()
8251 .unwrap_or_else(|poisoned| poisoned.into_inner())
8252 .records
8253 .values()
8254 .filter(|record| record.state == ManagedSessionState::Unavailable)
8255 .count();
8256 tracing::info!(
8257 node_id = %self.node_id,
8258 retired,
8259 remaining_unavailable,
8260 "session record retention sweep completed"
8261 );
8262 }
8263 }
8264
8265 async fn resume_session_record(
8266 &self,
8267 record_id: &SessionRecordId,
8268 terminal_size: gate4agent_types::TerminalSize,
8269 initial_prompt: Option<String>,
8270 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8271 let record = self.record(record_id)?;
8272 if record.active_session.is_some()
8273 || matches!(
8274 record.state,
8275 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8276 )
8277 {
8278 return Err(failure(
8279 NodeFailureCode::SessionRecordBusy,
8280 "managed session already has a live runtime binding",
8281 ));
8282 }
8283 let identity = record.provider_session.clone().ok_or_else(|| {
8284 failure(
8285 NodeFailureCode::SessionRecordNotResumable,
8286 "managed session has no verified provider session identity",
8287 )
8288 })?;
8289 let working_directory = self.workspace_root(&record.workspace_id)?;
8290 if !platform::roots_equal(
8291 &working_directory,
8292 windows_path_text(&record.canonical_root),
8293 ) {
8294 return Err(failure(
8295 NodeFailureCode::SessionRecordConflict,
8296 "managed session workspace root changed; refusing to resume in another directory",
8297 ));
8298 }
8299 {
8300 let records = self
8301 .session_records
8302 .lock()
8303 .unwrap_or_else(|poisoned| poisoned.into_inner());
8304 if records.records.values().any(|candidate| {
8305 candidate.record_id != *record_id
8306 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8307 session_registry::same_provider_session(
8308 &candidate.provider,
8309 candidate_identity,
8310 &record.provider,
8311 &identity,
8312 )
8313 })
8314 && (candidate.active_session.is_some()
8315 || matches!(
8316 candidate.state,
8317 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8318 ))
8319 }) {
8320 return Err(failure(
8321 NodeFailureCode::SessionRecordConflict,
8322 "provider session is already active through another managed record",
8323 ));
8324 }
8325 }
8326 let runtime_requirement = if initial_prompt.is_some() {
8327 ProviderRuntimeRequirement::ResumeWithPrompt
8328 } else {
8329 ProviderRuntimeRequirement::Resume
8330 };
8331 let runtime_policy = self.admit_provider_runtime(
8332 &record.provider,
8333 runtime_requirement,
8334 )
8335 .await?;
8336 let request = ResumeLaunchRequest {
8337 working_directory,
8338 terminal_size,
8339 initial_prompt,
8340 };
8341 request
8342 .validate()
8343 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8344 self.ensure_binding_capacity()?;
8345 let resolved_materialization = match self.resolve_record_materialization(
8346 record_id,
8347 &record.provider,
8348 record.mode,
8349 record.environment_profile.as_ref(),
8350 record.bundle.as_ref(),
8351 record.context.as_ref(),
8352 ) {
8353 Ok(materialization) => materialization,
8354 Err(error) => {
8355 let label = match error.code {
8356 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8357 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8358 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8359 _ => "environment-profile-unavailable",
8360 };
8361 self.mark_record_error(record_id, label)?;
8362 return Err(error);
8363 }
8364 };
8365 let (materialization_id, environment_overlay) = match resolved_materialization {
8366 Some((id, overlay)) => (Some(id), overlay),
8367 None => (None, None),
8368 };
8369 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8370 let mut environment_selection = match SpawnPreparer::select_environment(self,
8371 instance_id,
8372 &record.provider,
8373 record.mode,
8374 record.environment_profile.as_ref(),
8375 ) {
8376 Ok(selection) => selection,
8377 Err(error) => {
8378 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8379 return Err(error);
8380 }
8381 };
8382 let mut instance_overlay = environment_overlay
8383 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8384 .transpose()?
8385 .flatten();
8386 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8387 self.remove_session(&stale_address).await?;
8388 }
8389 let address = SessionAddress {
8390 workspace_id: record.workspace_id.clone(),
8391 session: SessionKey {
8392 instance_id,
8393 generation: SessionGeneration(1),
8394 },
8395 };
8396 let transaction = self
8397 .state_transaction
8398 .lock()
8399 .unwrap_or_else(|poisoned| poisoned.into_inner());
8400 let current = self.record(record_id)?;
8401 if current.active_session.is_some()
8402 || matches!(
8403 current.state,
8404 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8405 )
8406 {
8407 return Err(failure(
8408 NodeFailureCode::SessionRecordBusy,
8409 "managed session became active while resume was being prepared",
8410 ));
8411 }
8412 self.bind_managed_session_with_materialization(
8413 &address,
8414 record_id.clone(),
8415 runtime_policy,
8416 record.environment_profile.clone(),
8417 record.bundle.clone(),
8418 record.context.clone(),
8419 materialization_id,
8420 );
8421 if let Some(selection) = environment_selection.take() {
8422 selection.retain();
8423 }
8424 if let Some(overlay) = instance_overlay.take() {
8425 overlay.retain();
8426 }
8427 drop(transaction);
8428
8429 let transport = match record.mode {
8430 SessionMode::Pty => TransportKind::Pty,
8431 SessionMode::Inline => TransportKind::Pipe,
8432 SessionMode::Acp => TransportKind::Acp,
8433 };
8434 let agent_id = record.provider.clone();
8435 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8436 if let Err(error) = self
8437 .dispatch_bounded(
8438 ControlCommand::Register {
8439 instance_id,
8440 agent_id,
8441 transport,
8442 },
8443 dispatch_timeout,
8444 )
8445 .await
8446 {
8447 if let Some(binding) = self.remove_binding(&address) {
8448 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8449 }
8450 return Err(error);
8451 }
8452 let command = self.prepare_command(ControlCommand::Resume {
8453 instance_id,
8454 target: ResumeTarget::ProviderSession { identity },
8455 runtime_policy,
8456 request,
8457 });
8458 let started_after = self.current_sequence();
8459 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8460 self.rollback_spawn(&address, dispatch_timeout).await?;
8461 return Err(error);
8462 }
8463 if let Err(error) = self.dispatch_envelope(command) {
8464 self.clear_armed_resume(&address);
8465 self.rollback_spawn(&address, dispatch_timeout).await?;
8466 return Err(error);
8467 }
8468 match self
8469 .wait_for_record_resume(&address, started_after)
8470 .await
8471 {
8472 Ok(settled_address) => {
8473 let managed_lease_id = {
8474 self.managed_worktrees.lock()
8475 .unwrap_or_else(|poisoned| poisoned.into_inner())
8476 .lease_for_workspace(&record.workspace_id)
8477 };
8478 if let Some(lease_id) = managed_lease_id {
8479 {
8480 let mut bindings = self.session_bindings.lock()
8481 .unwrap_or_else(|poisoned| poisoned.into_inner());
8482 let binding = bindings.get_mut(&settled_address.session.instance_id)
8483 .ok_or_else(|| failure(
8484 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8485 "resumed managed session lost its exact binding",
8486 ))?;
8487 if binding.generation != settled_address.session.generation {
8488 return Err(failure(
8489 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8490 "resumed managed session generation diverged",
8491 ));
8492 }
8493 binding.managed_worktree_lease_id = Some(lease_id.clone());
8494 }
8495 let snapshot = self.managed_worktrees.lock()
8496 .unwrap_or_else(|poisoned| poisoned.into_inner())
8497 .bind_session(
8498 &lease_id,
8499 ManagedWorktreeSessionHolder {
8500 incarnation_id: self.incarnation_id,
8501 instance_id: settled_address.session.instance_id,
8502 generation: settled_address.session.generation,
8503 },
8504 Some(record_id.clone()),
8505 unix_time_ms(),
8506 ).map_err(|message| failure(
8507 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8508 &message,
8509 ))?;
8510 if let Err(error) = self.persist_state() {
8511 let _ = self.remove_session(&settled_address).await;
8512 return Err(persistence_failure(error));
8513 }
8514 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8515 }
8516 Ok((self.record(record_id)?, settled_address))
8517 }
8518 Err(error) => {
8519 if matches!(
8520 error.code,
8521 NodeFailureCode::SessionRecordNotResumable
8522 | NodeFailureCode::BackendOperationFailed
8523 ) {
8524 if let Some(bound) = self.bound_address_for_record(record_id) {
8525 let _ = self.remove_session(&bound).await;
8526 }
8527 }
8528 Err(error)
8529 }
8530 }
8531 }
8532
8533 async fn wait_for_record_resume(
8534 &self,
8535 initial_address: &SessionAddress,
8536 after_sequence: u64,
8537 ) -> Result<SessionAddress, NodeFailure> {
8538 let deadline = Instant::now()
8539 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8540 let mut scan_after = after_sequence;
8541 loop {
8542 let events = self
8543 .history
8544 .lock()
8545 .unwrap_or_else(|poisoned| poisoned.into_inner())
8546 .events
8547 .iter()
8548 .filter(|event| event.sequence > scan_after)
8549 .cloned()
8550 .collect::<Vec<_>>();
8551 for envelope in events {
8552 scan_after = scan_after.max(envelope.sequence);
8553 let NodeEvent::Control { address, event } = envelope.event else {
8554 continue;
8555 };
8556 if address.session.instance_id != initial_address.session.instance_id {
8557 continue;
8558 }
8559 match event.event {
8560 ControlEventKind::Resumed { .. } => return Ok(address),
8561 ControlEventKind::ResumeDenied { reason } => {
8562 return Err(failure(
8563 NodeFailureCode::SessionRecordNotResumable,
8564 &reason,
8565 ));
8566 }
8567 ControlEventKind::ResumeFailed { message }
8568 | ControlEventKind::CommandRejected { message } => {
8569 return Err(failure(
8570 NodeFailureCode::BackendOperationFailed,
8571 &message,
8572 ));
8573 }
8574 _ => {}
8575 }
8576 }
8577 if Instant::now() >= deadline {
8578 return Err(failure(
8579 NodeFailureCode::BackendBusy,
8580 "managed resume did not settle before the bounded deadline",
8581 ));
8582 }
8583 sleep(Duration::from_millis(5)).await;
8584 }
8585 }
8586
8587 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8588 self.workspaces
8589 .read()
8590 .unwrap_or_else(|poisoned| poisoned.into_inner())
8591 .get(workspace_id)
8592 .cloned()
8593 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8594 }
8595
8596 fn workspace_roots(&self) -> Vec<PathBuf> {
8597 self.workspaces
8598 .read()
8599 .unwrap_or_else(|poisoned| poisoned.into_inner())
8600 .values()
8601 .map(PathBuf::from)
8602 .collect()
8603 }
8604
8605 async fn inspect_workspace(
8606 &self,
8607 workspace_id: WorkspaceId,
8608 ) -> Result<WorkspaceInspection, NodeFailure> {
8609 let _permit = self
8610 .inspection_slots
8611 .clone()
8612 .try_acquire_owned()
8613 .map_err(|_| {
8614 tracing::warn!(
8615 workspace_id = %workspace_id,
8616 "workspace inspection rejected: inspection capacity is busy",
8617 );
8618 failure(
8619 NodeFailureCode::BackendBusy,
8620 "workspace inspection capacity is busy",
8621 )
8622 })?;
8623 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8624 tracing::warn!(
8625 workspace_id = %workspace_id,
8626 code = ?error.code,
8627 "workspace inspection rejected: workspace is not registered",
8628 );
8629 error
8630 })?;
8631 let tree_root = canonical_root.clone();
8632 let started_at = Instant::now();
8633 let time_budget_ms = workspace_inspection_time_budget_ms();
8634 let entry_cap = workspace_inspection_entry_cap();
8635 let deadline = started_at + Duration::from_millis(time_budget_ms);
8636 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8637 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8638 })
8639 .await
8640 .map_err(|error| {
8641 tracing::warn!(
8642 workspace_id = %workspace_id,
8643 error = %error,
8644 "workspace inspection rejected: walk task failed",
8645 );
8646 failure(
8647 NodeFailureCode::BackendOperationFailed,
8648 &format!("workspace inspection task failed: {error}"),
8649 )
8650 })?;
8651 let (mut git, git_time_budget_exceeded) =
8652 inspect_git_workspace(&canonical_root, deadline).await;
8653 if !git.worktrees.is_empty() {
8654 let registered = self
8655 .workspaces
8656 .read()
8657 .unwrap_or_else(|poisoned| poisoned.into_inner())
8658 .iter()
8659 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8660 .collect::<Vec<_>>();
8661 for worktree in &mut git.worktrees {
8662 worktree.workspace_id = registered
8663 .iter()
8664 .find(|(_, root)| {
8665 worktree_paths_equal(root, windows_path_text(&worktree.path))
8666 })
8667 .map(|(workspace_id, _)| workspace_id.clone());
8668 }
8669 }
8670 if git.is_repository {
8671 git.managed_worktree = self
8672 .managed_worktrees
8673 .lock()
8674 .unwrap_or_else(|poisoned| poisoned.into_inner())
8675 .git_scope(&workspace_id, git.branch.as_deref());
8676 }
8677 let truncation = workspace_inspection_truncation(
8678 &walk_budget,
8679 git_time_budget_exceeded,
8680 started_at.elapsed(),
8681 );
8682 if let Some(truncation) = &truncation {
8683 tracing::info!(
8684 workspace_id = %workspace_id,
8685 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8686 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8687 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8688 entries_visited = truncation.entries_visited,
8689 elapsed_ms = truncation.elapsed_ms,
8690 "workspace inspection truncated by its inner budget",
8691 );
8692 }
8693 Ok(WorkspaceInspection {
8694 workspace_id,
8695 entries,
8696 tree_truncated,
8697 git,
8698 truncation,
8699 })
8700 }
8701
8702 async fn browse_host_directories(
8703 &self,
8704 directory: Option<OpaqueHostPath>,
8705 after: Option<OpaqueHostPath>,
8706 ) -> Result<HostDirectoryListing, NodeFailure> {
8707 let permit = self
8708 .inspection_slots
8709 .clone()
8710 .try_acquire_owned()
8711 .map_err(|_| {
8712 failure(
8713 NodeFailureCode::BackendBusy,
8714 "host directory browse capacity is busy",
8715 )
8716 })?;
8717 let task = tokio::task::spawn_blocking(move || {
8718 let _permit = permit;
8719 browse_host_directories(directory, after)
8720 });
8721 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8722 .await
8723 .map_err(|_| {
8724 failure(
8725 NodeFailureCode::HostDirectoryReadTimedOut,
8726 "host directory browse exceeded its bounded deadline",
8727 )
8728 })?
8729 .map_err(|_| {
8730 failure(
8731 NodeFailureCode::HostDirectoryReadFailed,
8732 "host directory browse task failed",
8733 )
8734 })?
8735 .map_err(host_directory_failure)
8736 }
8737
8738 async fn read_workspace_file(
8739 &self,
8740 workspace_id: WorkspaceId,
8741 path: RepositoryPath,
8742 ) -> Result<WorkspaceFileRead, NodeFailure> {
8743 let canonical_root = self.workspace_root(&workspace_id)?;
8744 let permit = self
8745 .inspection_slots
8746 .clone()
8747 .try_acquire_owned()
8748 .map_err(|_| {
8749 failure(
8750 NodeFailureCode::BackendBusy,
8751 "workspace file read capacity is busy",
8752 )
8753 })?;
8754 let path_to_read = path.clone();
8755 let task = tokio::task::spawn_blocking(move || {
8756 let _permit = permit;
8757 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8758 });
8759 let content = timeout(
8760 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8761 task,
8762 )
8763 .await
8764 .map_err(|_| {
8765 failure(
8766 NodeFailureCode::RepositoryFileReadTimedOut,
8767 "repository file read exceeded its bounded deadline",
8768 )
8769 })?
8770 .map_err(|_| {
8771 failure(
8772 NodeFailureCode::RepositoryFileReadFailed,
8773 "repository file read task failed",
8774 )
8775 })?
8776 .map_err(workspace_file_failure)?;
8777 let content = match content {
8778 WorkspaceFileBytes::Utf8(text) => {
8779 let revision = Some(workspace_file_revision(text.as_bytes()));
8780 return Ok(WorkspaceFileRead {
8781 workspace_id,
8782 path,
8783 content: WorkspaceFileContent::Utf8 {
8784 byte_len: u32::try_from(text.len())
8785 .expect("bounded workspace text length must fit u32"),
8786 text,
8787 },
8788 revision,
8789 });
8790 }
8791 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8792 WorkspaceFileContent::NonUtf8 {
8793 byte_len: u32::try_from(byte_length)
8794 .expect("bounded workspace file length must fit u32"),
8795 }
8796 }
8797 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8798 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8799 },
8800 };
8801 Ok(WorkspaceFileRead {
8802 workspace_id,
8803 path,
8804 content,
8805 revision: None,
8806 })
8807 }
8808
8809 async fn write_workspace_file(
8810 &self,
8811 workspace_id: WorkspaceId,
8812 path: RepositoryPath,
8813 expected_revision: WorkspaceFileRevision,
8814 text: String,
8815 ) -> Result<WorkspaceFileRead, NodeFailure> {
8816 let canonical_root = self.workspace_root(&workspace_id)?;
8817 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8818 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8819 })?;
8820 let path_to_write = path.clone();
8821 let expected = expected_revision.as_str().to_owned();
8822 let text_to_write = text.clone();
8823 let task = tokio::task::spawn_blocking(move || {
8824 let _permit = permit;
8825 write_workspace_file_to_disk(
8826 Path::new(&canonical_root),
8827 &path_to_write,
8828 &expected,
8829 &text_to_write,
8830 )
8831 });
8832 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8833 .await
8834 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8835 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8836 .map_err(workspace_file_write_failure)?;
8837 Ok(WorkspaceFileRead {
8838 workspace_id,
8839 path,
8840 content: WorkspaceFileContent::Utf8 {
8841 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8842 text,
8843 },
8844 revision: Some(WorkspaceFileRevision::new(revision)
8845 .expect("workspace writer returns a valid SHA-256 revision")),
8846 })
8847 }
8848
8849 async fn create_workspace_file(
8850 &self,
8851 workspace_id: WorkspaceId,
8852 path: RepositoryPath,
8853 ) -> Result<WorkspaceFileRead, NodeFailure> {
8854 let canonical_root = self.workspace_root(&workspace_id)?;
8855 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8856 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8857 })?;
8858 let path_to_create = path.clone();
8859 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8860 let task_commit_state = Arc::clone(&commit_state);
8861 let task = tokio::task::spawn_blocking(move || {
8862 let _permit = permit;
8863 create_workspace_file_on_disk(
8864 Path::new(&canonical_root),
8865 &path_to_create,
8866 &task_commit_state,
8867 )
8868 });
8869 let revision = settle_workspace_entry_create(
8870 task,
8871 commit_state,
8872 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8873 "repository file create exceeded its bounded deadline",
8874 "repository file create task failed",
8875 )
8876 .await?;
8877 Ok(WorkspaceFileRead {
8878 workspace_id,
8879 path,
8880 content: WorkspaceFileContent::Utf8 {
8881 text: String::new(),
8882 byte_len: 0,
8883 },
8884 revision: Some(WorkspaceFileRevision::new(revision)
8885 .expect("workspace creator returns a valid SHA-256 revision")),
8886 })
8887 }
8888
8889 async fn create_workspace_directory(
8890 &self,
8891 workspace_id: WorkspaceId,
8892 path: RepositoryPath,
8893 ) -> Result<WorkspaceEntry, NodeFailure> {
8894 let canonical_root = self.workspace_root(&workspace_id)?;
8895 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8896 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8897 })?;
8898 let path_to_create = path.clone();
8899 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8900 let task_commit_state = Arc::clone(&commit_state);
8901 let task = tokio::task::spawn_blocking(move || {
8902 let _permit = permit;
8903 create_workspace_directory_on_disk(
8904 Path::new(&canonical_root),
8905 &path_to_create,
8906 &task_commit_state,
8907 )
8908 });
8909 settle_workspace_entry_create(
8910 task,
8911 commit_state,
8912 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8913 "repository directory create exceeded its bounded deadline",
8914 "repository directory create task failed",
8915 )
8916 .await?;
8917 Ok(WorkspaceEntry {
8918 relative_path: path,
8919 kind: WorkspaceEntryKind::Directory,
8920 })
8921 }
8922
8923 async fn read_git_history(
8924 &self,
8925 workspace_id: WorkspaceId,
8926 path: Option<RepositoryPath>,
8927 before: Option<GitObjectId>,
8928 limit: u16,
8929 ) -> Result<GitHistoryPage, NodeFailure> {
8930 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8931 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8932 })?;
8933 let root = self.workspace_root(&workspace_id)?;
8934 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8935 }
8936
8937 async fn read_git_diff(
8938 &self,
8939 workspace_id: WorkspaceId,
8940 request: GitDiffRequest,
8941 ) -> Result<GitDiff, NodeFailure> {
8942 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8943 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8944 })?;
8945 let root = self.workspace_root(&workspace_id)?;
8946 read_git_diff_bounded(&root, request).await
8947 }
8948
8949 fn native_session_route_root(
8950 &self,
8951 route: &NativeSessionCatalogRoute,
8952 ) -> Result<Option<String>, NodeFailure> {
8953 route
8954 .validate()
8955 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8956 match route.scope {
8957 gate4agent_types::NativeSessionCatalogScope::Workspace => route
8958 .workspace_id
8959 .as_ref()
8960 .map(|workspace_id| self.workspace_root(workspace_id))
8961 .transpose(),
8962 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8963 }
8964 }
8965
8966 fn project_native_session_entries(
8967 &self,
8968 route: &NativeSessionCatalogRoute,
8969 entries: Vec<ScopedNativeSessionCatalogEntry>,
8970 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8971 let current_root = route
8972 .workspace_id
8973 .as_ref()
8974 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8975 let records = self
8976 .session_records
8977 .lock()
8978 .unwrap_or_else(|poisoned| poisoned.into_inner());
8979 let mut seen_records = std::collections::HashSet::new();
8980 entries
8981 .into_iter()
8982 .map(|entry| {
8983 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8984 let matches = records
8985 .records
8986 .values()
8987 .filter(|record| {
8988 record.provider == route.provider
8989 && record.workspace_id == *workspace_id
8990 && current_root.as_deref().is_some_and(|root| {
8991 platform::roots_equal(
8992 root,
8993 windows_path_text(&record.canonical_root),
8994 )
8995 })
8996 && record.provider_session.as_ref().is_some_and(|identity| {
8997 session_registry::same_provider_session(
8998 &record.provider,
8999 identity,
9000 &route.provider,
9001 &entry.provider_session,
9002 )
9003 })
9004 })
9005 .map(|record| record.record_id.clone())
9006 .collect::<Vec<_>>();
9007 (matches.len() == 1
9008 && seen_records.insert(matches[0].clone()))
9009 .then(|| matches[0].clone())
9010 });
9011 let metadata = entry.metadata;
9012 crate::protocol::NativeSessionCatalogEntry {
9013 selection_id: metadata.selection_id,
9014 title: metadata.title,
9015 modified_at_unix_ms: metadata.modified_at_unix_ms,
9016 model: metadata.model,
9017 message_count: metadata.message_count,
9018 completed_turn_count: metadata.completed_turn_count,
9019 external_group: entry.external_group,
9020 record_id,
9021 }
9022 })
9023 .collect()
9024 }
9025
9026 async fn catalog_native_sessions(
9027 &self,
9028 route: NativeSessionCatalogRoute,
9029 limit: u16,
9030 ) -> Result<(
9031 Vec<crate::protocol::NativeSessionCatalogEntry>,
9032 crate::protocol::NativeSessionCatalogSummary,
9033 ), NodeFailure> {
9034 let canonical_root = self.native_session_route_root(&route)?;
9035 let registered_workspace_roots = self.workspace_roots();
9036 let provider = route.provider.clone();
9037 let scope = route.scope;
9038 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9039 failure(
9040 NodeFailureCode::InvalidRequest,
9041 "native session catalog is not configured",
9042 )
9043 })?;
9044 let _permit = Arc::clone(&self.inspection_slots)
9045 .try_acquire_owned()
9046 .map_err(|_| {
9047 failure(
9048 NodeFailureCode::BackendBusy,
9049 "native session catalog capacity is busy",
9050 )
9051 })?;
9052 let page = run_native_session_catalog_operation(
9053 catalog,
9054 "native session catalog",
9055 move |catalog| {
9056 catalog.catalog_initial_for_scope(
9057 &provider,
9058 scope,
9059 canonical_root.as_deref().map(Path::new),
9060 ®istered_workspace_roots,
9061 limit,
9062 )
9063 },
9064 )
9065 .await?;
9066 let summary = crate::protocol::NativeSessionCatalogSummary {
9067 catalog_revision: page.revision,
9068 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9069 recent_total_count: page.recent_total_count,
9070 older_total_count: page.older_total_count,
9071 recent_next_after_selection_id: page.next_after_selection_id,
9072 recent_has_more: page.remaining_count > 0,
9073 };
9074 Ok((self.project_native_session_entries(&route, page.entries), summary))
9075 }
9076
9077 async fn page_native_sessions(
9078 &self,
9079 route: NativeSessionCatalogRoute,
9080 window: crate::protocol::NativeSessionCatalogWindow,
9081 catalog_revision: u64,
9082 recent_cutoff_unix_ms: u64,
9083 after_selection_id: Option<String>,
9084 limit: u16,
9085 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9086 let canonical_root = self.native_session_route_root(&route)?;
9087 let registered_workspace_roots = self.workspace_roots();
9088 let provider = route.provider.clone();
9089 let scope = route.scope;
9090 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9091 failure(
9092 NodeFailureCode::InvalidRequest,
9093 "native session catalog is not configured",
9094 )
9095 })?;
9096 let _permit = Arc::clone(&self.inspection_slots)
9097 .try_acquire_owned()
9098 .map_err(|_| {
9099 failure(
9100 NodeFailureCode::BackendBusy,
9101 "native session catalog capacity is busy",
9102 )
9103 })?;
9104 let page = run_native_session_catalog_operation(
9105 catalog,
9106 "native session catalog page",
9107 move |catalog| {
9108 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9109 &provider,
9110 scope,
9111 canonical_root.as_deref().map(Path::new),
9112 ®istered_workspace_roots,
9113 window,
9114 catalog_revision,
9115 recent_cutoff_unix_ms,
9116 after_selection_id.as_deref(),
9117 limit,
9118 ))
9119 },
9120 )
9121 .await?;
9122 let page = page.map_err(|error| match error {
9123 NativeSessionCatalogError::StaleCatalog => failure(
9124 NodeFailureCode::StaleNativeSessionCatalog,
9125 "native session catalog revision or cursor is stale",
9126 ),
9127 _ => failure(
9128 NodeFailureCode::BackendOperationFailed,
9129 "native session catalog page failed",
9130 ),
9131 })?;
9132 let entries = self.project_native_session_entries(&route, page.entries);
9133 Ok(crate::protocol::NativeSessionCatalogPage {
9134 window: page.window,
9135 revision: page.revision,
9136 entries,
9137 next_after_selection_id: page.next_after_selection_id,
9138 remaining_count: page.remaining_count,
9139 has_more: page.remaining_count > 0,
9140 })
9141 }
9142
9143 async fn preview_native_session(
9144 &self,
9145 selection: NativeSessionSelection,
9146 message_limit: u16,
9147 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9148 selection
9149 .validate()
9150 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9151 let canonical_root = self.native_session_route_root(&selection.route)?;
9152 let registered_workspace_roots = self.workspace_roots();
9153 let provider = selection.route.provider.clone();
9154 let scope = selection.route.scope;
9155 let catalog_revision = selection.catalog_revision;
9156 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9157 let selection_id = selection.selection_id.clone();
9158 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9159 failure(
9160 NodeFailureCode::InvalidRequest,
9161 "native session preview is not configured",
9162 )
9163 })?;
9164 let _permit = Arc::clone(&self.inspection_slots)
9165 .try_acquire_owned()
9166 .map_err(|_| {
9167 failure(
9168 NodeFailureCode::BackendBusy,
9169 "native session preview capacity is busy",
9170 )
9171 })?;
9172 let preview = run_native_session_catalog_operation(
9173 catalog,
9174 "native session preview",
9175 move |catalog| {
9176 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9177 &provider,
9178 scope,
9179 canonical_root.as_deref().map(Path::new),
9180 ®istered_workspace_roots,
9181 catalog_revision,
9182 recent_cutoff_unix_ms,
9183 &selection_id,
9184 message_limit,
9185 ))
9186 },
9187 )
9188 .await?;
9189 preview
9190 .map(Into::into)
9191 .map_err(native_session_preview_failure)
9192 }
9193
9194 async fn index_native_session(
9195 &self,
9196 selection: NativeSessionSelection,
9197 display_name: String,
9198 ) -> Result<ManagedSessionRecord, NodeFailure> {
9199 selection
9200 .validate()
9201 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9202 if selection.route.scope
9203 != gate4agent_types::NativeSessionCatalogScope::Workspace
9204 {
9205 return Err(failure(
9206 NodeFailureCode::WorkspaceRegistrationRequired,
9207 "register the external project as a workspace before indexing or resuming it",
9208 ));
9209 }
9210 let workspace_id = selection
9211 .route
9212 .workspace_id
9213 .clone()
9214 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9215 let canonical_root = self
9216 .native_session_route_root(&selection.route)?
9217 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9218 let registered_workspace_roots = self.workspace_roots();
9219 let provider = selection.route.provider.clone();
9220 let catalog_revision = selection.catalog_revision;
9221 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9222 let selection_id = selection.selection_id.clone();
9223 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9224 failure(
9225 NodeFailureCode::InvalidRequest,
9226 "native session index is not configured",
9227 )
9228 })?;
9229 let _permit = Arc::clone(&self.inspection_slots)
9230 .try_acquire_owned()
9231 .map_err(|_| {
9232 failure(
9233 NodeFailureCode::BackendBusy,
9234 "native session index capacity is busy",
9235 )
9236 })?;
9237 let resolved = run_native_session_catalog_operation(
9238 catalog,
9239 "native session index resolution",
9240 move |catalog| {
9241 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9242 &provider,
9243 gate4agent_types::NativeSessionCatalogScope::Workspace,
9244 Some(Path::new(&canonical_root)),
9245 ®istered_workspace_roots,
9246 catalog_revision,
9247 recent_cutoff_unix_ms,
9248 &selection_id,
9249 ))
9250 },
9251 )
9252 .await?
9253 .map_err(native_session_preview_failure)?;
9254 self.index_provider_session_with_policy(
9255 workspace_id,
9256 selection.route.provider,
9257 resolved.identity,
9258 display_name,
9259 true,
9260 )
9261 }
9262
9263 async fn preview_native_session_by_session_id(
9264 &self,
9265 workspace_id: WorkspaceId,
9266 provider: AgentId,
9267 session_id: String,
9268 message_limit: u16,
9269 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9270 let canonical_root = self.workspace_root(&workspace_id)?;
9271 let registered_workspace_roots = self.workspace_roots();
9272 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9273 failure(
9274 NodeFailureCode::InvalidRequest,
9275 "native session preview is not configured",
9276 )
9277 })?;
9278 let _permit = Arc::clone(&self.inspection_slots)
9279 .try_acquire_owned()
9280 .map_err(|_| {
9281 failure(
9282 NodeFailureCode::BackendBusy,
9283 "native session preview capacity is busy",
9284 )
9285 })?;
9286 run_native_session_catalog_operation(
9287 catalog,
9288 "native session preview",
9289 move |catalog| {
9290 catalog.preview_session_id_for_workspace(
9291 &provider,
9292 Path::new(&canonical_root),
9293 ®istered_workspace_roots,
9294 &session_id,
9295 message_limit,
9296 )
9297 },
9298 )
9299 .await
9300 }
9301
9302 async fn register_workspace(
9303 &self,
9304 workspace_id: WorkspaceId,
9305 root: String,
9306 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9307 validate_workspace_request_root(&root)?;
9308 let canonicalize_workspace_id = workspace_id.clone();
9309 let workspace = tokio::task::spawn_blocking(move || {
9310 WorkspaceConfig::new(canonicalize_workspace_id, root)
9311 })
9312 .await
9313 .map_err(|error| {
9314 failure(
9315 NodeFailureCode::BackendOperationFailed,
9316 &format!("workspace canonicalization task failed: {error}"),
9317 )
9318 })?
9319 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9320 let transaction = self
9321 .state_transaction
9322 .lock()
9323 .unwrap_or_else(|poisoned| poisoned.into_inner());
9324 let mut workspaces = self
9325 .workspaces
9326 .write()
9327 .unwrap_or_else(|poisoned| poisoned.into_inner());
9328 if workspaces.contains_key(&workspace_id) {
9329 return Err(failure(
9330 NodeFailureCode::DuplicateWorkspaceId,
9331 "workspace ID is already registered",
9332 ));
9333 }
9334 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9335 platform::roots_equal(existing_root, workspace.canonical_root())
9336 }) {
9337 return Err(failure(
9338 NodeFailureCode::DuplicateWorkspaceRoot,
9339 &format!("workspace root is already registered as '{existing_id}'"),
9340 ));
9341 }
9342 {
9343 let records = self
9344 .session_records
9345 .lock()
9346 .unwrap_or_else(|poisoned| poisoned.into_inner());
9347 if let Some(record) = records.records.values().find(|record| {
9348 record.workspace_id == workspace_id
9349 && !record
9350 .canonical_root
9351 .as_utf8()
9352 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9353 }) {
9354 return Err(failure(
9355 NodeFailureCode::SessionRecordConflict,
9356 &format!(
9357 "workspace ID is retained by session record '{}' at another root",
9358 record.record_id,
9359 ),
9360 ));
9361 }
9362 }
9363 let snapshot = WorkspaceSnapshot {
9364 workspace_id: workspace_id.clone(),
9365 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9366 sessions: Vec::new(),
9367 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9368 managed_worktree_profiles: Some(WorktreeProfileInventory {
9369 profiles: Vec::new(),
9370 }),
9371 };
9372 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9373 drop(workspaces);
9374 let previous_records = {
9375 let mut records = self
9376 .session_records
9377 .lock()
9378 .unwrap_or_else(|poisoned| poisoned.into_inner());
9379 let previous = records.records.clone();
9380 for record in records.records.values_mut().filter(|record| {
9381 record.workspace_id == workspace_id
9382 && record
9383 .canonical_root
9384 .as_utf8()
9385 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9386 }) {
9387 if record.active_session.is_none() {
9388 record.state = detached_record_state(record);
9389 }
9390 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9391 record.last_error = None;
9392 }
9393 record.updated_at_unix_ms = unix_time_ms();
9394 }
9395 previous
9396 };
9397 if let Err(error) = self.persist_state_locked() {
9398 self.workspaces
9399 .write()
9400 .unwrap_or_else(|poisoned| poisoned.into_inner())
9401 .remove(&workspace_id);
9402 self.session_records
9403 .lock()
9404 .unwrap_or_else(|poisoned| poisoned.into_inner())
9405 .records = previous_records;
9406 return Err(persistence_failure(error));
9407 }
9408 let restored = self
9409 .session_records
9410 .lock()
9411 .unwrap_or_else(|poisoned| poisoned.into_inner())
9412 .records
9413 .values()
9414 .filter(|record| record.workspace_id == workspace_id)
9415 .cloned()
9416 .collect::<Vec<_>>();
9417 drop(transaction);
9418 self.publish(NodeEvent::WorkspaceAdded {
9419 workspace: snapshot.clone(),
9420 });
9421 for record in restored {
9422 self.publish_record(record);
9423 }
9424 Ok(snapshot)
9425 }
9426
9427 async fn create_standalone_workspace(
9428 &self,
9429 workspace_id: WorkspaceId,
9430 root: String,
9431 initial_branch: Option<String>,
9432 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9433 validate_workspace_request_root(&root)?;
9434 validate_git_revision("initial branch", initial_branch.as_deref())?;
9435 let preflight_workspace_id = workspace_id.clone();
9436 let preflight_root = root.clone();
9437 let canonical_candidate = tokio::task::spawn_blocking(move || {
9438 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9439 })
9440 .await
9441 .map_err(|_| failure(
9442 NodeFailureCode::BackendOperationFailed,
9443 "standalone workspace root preflight task failed",
9444 ))?
9445 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9446 {
9447 let workspaces = self
9448 .workspaces
9449 .read()
9450 .unwrap_or_else(|poisoned| poisoned.into_inner());
9451 if workspaces.contains_key(&workspace_id) {
9452 return Err(failure(
9453 NodeFailureCode::DuplicateWorkspaceId,
9454 "workspace ID is already registered",
9455 ));
9456 }
9457 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9458 platform::roots_equal(existing_root, &canonical_candidate)
9459 }) {
9460 return Err(failure(
9461 NodeFailureCode::DuplicateWorkspaceRoot,
9462 &format!("workspace root is already registered as '{existing_id}'"),
9463 ));
9464 }
9465 }
9466 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9467 .await
9468 .map_err(standalone_workspace_failure)?;
9469 prepared
9470 .verify_for_registration()
9471 .await
9472 .map_err(standalone_workspace_failure)?;
9473 match self
9474 .register_workspace(workspace_id, prepared.root().to_owned())
9475 .await
9476 {
9477 Ok(workspace) => Ok(workspace),
9478 Err(registration_error) => {
9479 if prepared.compensate_registration_failure().await {
9480 Err(registration_error)
9481 } else {
9482 Err(failure(
9483 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9484 "standalone workspace initialization succeeded but registration failed; recovery is required",
9485 ))
9486 }
9487 }
9488 }
9489 }
9490
9491 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9492 let bindings = self
9493 .session_bindings
9494 .lock()
9495 .unwrap_or_else(|poisoned| poisoned.into_inner());
9496 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9497 return Err(failure(
9498 NodeFailureCode::WorkspaceBusy,
9499 "workspace retains a bound session; remove every session before unregistering it",
9500 ));
9501 }
9502 drop(bindings);
9503 let transaction = self
9504 .state_transaction
9505 .lock()
9506 .unwrap_or_else(|poisoned| poisoned.into_inner());
9507 let mut workspaces = self
9508 .workspaces
9509 .write()
9510 .unwrap_or_else(|poisoned| poisoned.into_inner());
9511 if !workspaces.contains_key(workspace_id) {
9512 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9513 }
9514 if workspaces.len() == 1 {
9515 return Err(failure(
9516 NodeFailureCode::LastWorkspace,
9517 "node must retain at least one workspace",
9518 ));
9519 }
9520 let removed_root = workspaces
9521 .remove(workspace_id)
9522 .expect("workspace presence was validated");
9523 drop(workspaces);
9524 let previous_records = {
9525 let mut records = self
9526 .session_records
9527 .lock()
9528 .unwrap_or_else(|poisoned| poisoned.into_inner());
9529 let previous = records.records.clone();
9530 for record in records
9531 .records
9532 .values_mut()
9533 .filter(|record| &record.workspace_id == workspace_id)
9534 {
9535 record.active_session = None;
9536 record.state = ManagedSessionState::Unavailable;
9537 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9538 record.updated_at_unix_ms = unix_time_ms();
9539 }
9540 previous
9541 };
9542 if let Err(error) = self.persist_state_locked() {
9543 self.workspaces
9544 .write()
9545 .unwrap_or_else(|poisoned| poisoned.into_inner())
9546 .insert(workspace_id.clone(), removed_root);
9547 self.session_records
9548 .lock()
9549 .unwrap_or_else(|poisoned| poisoned.into_inner())
9550 .records = previous_records;
9551 return Err(persistence_failure(error));
9552 }
9553 let unavailable = self
9554 .session_records
9555 .lock()
9556 .unwrap_or_else(|poisoned| poisoned.into_inner())
9557 .records
9558 .values()
9559 .filter(|record| &record.workspace_id == workspace_id)
9560 .cloned()
9561 .collect::<Vec<_>>();
9562 drop(transaction);
9563 self.publish(NodeEvent::WorkspaceRemoved {
9564 workspace_id: workspace_id.clone(),
9565 });
9566 for record in unavailable {
9567 self.publish_record(record);
9568 }
9569 Ok(())
9570 }
9571
9572 async fn create_worktree(
9573 &self,
9574 source_workspace_id: WorkspaceId,
9575 workspace_id: WorkspaceId,
9576 target_root: String,
9577 branch: String,
9578 base: Option<String>,
9579 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9580 self.require_worktree_service(&source_workspace_id)?;
9581 validate_workspace_request_root(&target_root)?;
9582 validate_git_revision("worktree base", base.as_deref())?;
9583 let source_root = self.workspace_root(&source_workspace_id)?;
9584 {
9585 let workspaces = self
9586 .workspaces
9587 .read()
9588 .unwrap_or_else(|poisoned| poisoned.into_inner());
9589 if workspaces.contains_key(&workspace_id) {
9590 return Err(failure(
9591 NodeFailureCode::DuplicateWorkspaceId,
9592 "workspace ID is already registered",
9593 ));
9594 }
9595 if let Some((existing_id, _)) = workspaces
9596 .iter()
9597 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9598 {
9599 return Err(failure(
9600 NodeFailureCode::DuplicateWorkspaceRoot,
9601 &format!("workspace root is already registered as '{existing_id}'"),
9602 ));
9603 }
9604 }
9605 let mut worktree = create_git_worktree(
9606 &source_root,
9607 &target_root,
9608 &branch,
9609 base.as_deref(),
9610 )
9611 .await
9612 .map_err(git_worktree_failure)?;
9613 let workspace = match self
9614 .register_workspace(workspace_id, worktree.path.clone())
9615 .await
9616 {
9617 Ok(workspace) => workspace,
9618 Err(registration_error) => {
9619 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9620 return Err(failure(
9621 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9622 "worktree registration failed and compensation requires recovery",
9623 ));
9624 }
9625 return Err(failure(
9626 registration_error.code,
9627 ®istration_error.message,
9628 ));
9629 }
9630 };
9631 worktree.workspace_id = Some(workspace.workspace_id.clone());
9632 Ok((protocol_worktree(worktree), workspace))
9633 }
9634
9635 async fn remove_worktree(
9636 &self,
9637 source_workspace_id: WorkspaceId,
9638 target_root: String,
9639 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9640 self.require_worktree_service(&source_workspace_id)?;
9641 validate_workspace_request_root(&target_root)?;
9642 let source_root = self.workspace_root(&source_workspace_id)?;
9643 let target_root = normalize_worktree_removal_target(&target_root)
9644 .map_err(git_worktree_failure)?;
9645 let target = list_git_worktrees(&source_root)
9646 .await
9647 .map_err(git_worktree_failure)?
9648 .into_iter()
9649 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9650 .ok_or_else(|| {
9651 failure(
9652 NodeFailureCode::WorktreeProtected,
9653 "refusing to remove a path that is not in Git's worktree listing",
9654 )
9655 })?;
9656 let registered_workspace_id = {
9657 let workspaces = self
9658 .workspaces
9659 .read()
9660 .unwrap_or_else(|poisoned| poisoned.into_inner());
9661 let matched = workspaces
9662 .iter()
9663 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9664 .map(|(workspace_id, _)| workspace_id.clone());
9665 if matched.is_some() && workspaces.len() == 1 {
9666 return Err(failure(
9667 NodeFailureCode::LastWorkspace,
9668 "node must retain at least one workspace",
9669 ));
9670 }
9671 matched
9672 };
9673 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9674 let bindings = self
9675 .session_bindings
9676 .lock()
9677 .unwrap_or_else(|poisoned| poisoned.into_inner());
9678 if bindings
9679 .values()
9680 .any(|binding| &binding.workspace_id == workspace_id)
9681 {
9682 return Err(failure(
9683 NodeFailureCode::WorkspaceBusy,
9684 "worktree workspace retains a bound session; remove every session first",
9685 ));
9686 }
9687 }
9688 remove_git_worktree(&source_root, &target.path)
9689 .await
9690 .map_err(git_worktree_failure)?;
9691 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9692 self.unregister_workspace(workspace_id)
9693 .map_err(|error| failure(error.code, &error.message))?;
9694 }
9695 Ok(registered_workspace_id)
9696 }
9697
9698 async fn cleanup_managed_worktree(
9699 &self,
9700 lease_id: &ManagedWorktreeLeaseId,
9701 explicit: bool,
9702 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9703 let lease = self.managed_worktrees.lock()
9704 .unwrap_or_else(|poisoned| poisoned.into_inner())
9705 .get(lease_id)
9706 .cloned()
9707 .ok_or_else(|| failure(
9708 NodeFailureCode::UnknownManagedWorktreeLease,
9709 "managed worktree lease does not exist",
9710 ))?;
9711 if lease.state == ManagedWorktreeLeaseState::Removed {
9712 return Ok(lease.snapshot());
9713 }
9714 if lease.has_holders() {
9715 return Err(failure(
9716 NodeFailureCode::ManagedWorktreeBusy,
9717 "managed worktree lease still has a session or durable-record holder",
9718 ));
9719 }
9720 if self
9721 .materializations
9722 .lock()
9723 .unwrap_or_else(|poisoned| poisoned.into_inner())
9724 .values()
9725 .any(|record| record.managed_lease_id() == Some(lease_id))
9726 {
9727 return Err(failure(
9728 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9729 "managed worktree cleanup is blocked by session environment ownership",
9730 ));
9731 }
9732 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9733 let snapshot = {
9734 let mut registry = self.managed_worktrees.lock()
9735 .unwrap_or_else(|poisoned| poisoned.into_inner());
9736 let current = registry.get_mut(lease_id)
9737 .expect("managed worktree lease remains present");
9738 current.state = ManagedWorktreeLeaseState::Retained;
9739 current.cleanup_failure = None;
9740 current.updated_at_unix_ms = unix_time_ms();
9741 current.snapshot()
9742 };
9743 if self.persist_state().is_err() {
9744 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9745 }
9746 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9747 return Ok(snapshot);
9748 }
9749 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9750 if profile.revision() != &lease.profile_revision {
9751 self.mark_managed_recovery_required(
9752 lease_id,
9753 ManagedWorktreeCleanupFailure::OwnershipConflict,
9754 );
9755 return Err(failure(
9756 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9757 "managed worktree profile revision changed",
9758 ));
9759 }
9760 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9761 self.mark_managed_recovery_required(
9762 lease_id,
9763 ManagedWorktreeCleanupFailure::OwnershipConflict,
9764 );
9765 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9766 })?;
9767 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9768 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9769 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9770 managed_git_worktree_failure(error)
9771 })?;
9772 let target = listed.iter()
9773 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9774 if let Some(target) = target {
9775 if target.is_main || target.is_bare || !exact_owned_worktree(
9776 &lease,
9777 &target.path,
9778 target.branch.as_deref(),
9779 &target.head,
9780 ) {
9781 self.mark_managed_recovery_required(
9782 lease_id,
9783 ManagedWorktreeCleanupFailure::OwnershipConflict,
9784 );
9785 return Err(failure(
9786 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9787 "Git worktree identity no longer matches the managed lease",
9788 ));
9789 }
9790 if target.prunable {
9791 self.mark_managed_cleanup_blocked(
9792 lease_id,
9793 ManagedWorktreeCleanupFailure::Prunable,
9794 );
9795 return Err(failure(
9796 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9797 "managed worktree is prunable and requires recovery",
9798 ));
9799 }
9800 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9801 let failure_kind = match error.kind {
9802 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9803 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9804 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9805 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9806 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9807 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9808 };
9809 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9810 self.mark_managed_recovery_required(lease_id, failure_kind);
9811 } else {
9812 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9813 }
9814 return Err(managed_git_worktree_failure(error));
9815 }
9816 } else {
9817 match std::fs::symlink_metadata(&lease.target_root) {
9818 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9819 Ok(_) => {
9820 self.mark_managed_recovery_required(
9821 lease_id,
9822 ManagedWorktreeCleanupFailure::OwnershipConflict,
9823 );
9824 return Err(failure(
9825 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9826 "managed target exists but is not owned by Git",
9827 ));
9828 }
9829 Err(_) => {
9830 self.mark_managed_cleanup_blocked(
9831 lease_id,
9832 ManagedWorktreeCleanupFailure::Backend,
9833 );
9834 return Err(failure(
9835 NodeFailureCode::BackendOperationFailed,
9836 "managed target identity could not be inspected",
9837 ));
9838 }
9839 }
9840 }
9841 let registered = self.workspaces.read()
9842 .unwrap_or_else(|poisoned| poisoned.into_inner())
9843 .contains_key(&lease.workspace_id);
9844 if registered {
9845 self.unregister_workspace(&lease.workspace_id)?;
9846 }
9847 let removed = {
9848 self.managed_worktrees.lock()
9849 .unwrap_or_else(|poisoned| poisoned.into_inner())
9850 .tombstone(lease_id, unix_time_ms())
9851 .expect("managed worktree lease remains present during cleanup")
9852 };
9853 self.persist_state().map_err(persistence_failure)?;
9854 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9855 Ok(removed.snapshot())
9856 }
9857
9858 fn mark_managed_cleanup_blocked(
9859 &self,
9860 lease_id: &ManagedWorktreeLeaseId,
9861 failure_kind: ManagedWorktreeCleanupFailure,
9862 ) {
9863 let snapshot = {
9864 let mut registry = self.managed_worktrees.lock()
9865 .unwrap_or_else(|poisoned| poisoned.into_inner());
9866 let Some(lease) = registry.get_mut(lease_id) else { return };
9867 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9868 lease.cleanup_failure = Some(failure_kind);
9869 lease.updated_at_unix_ms = unix_time_ms();
9870 lease.snapshot()
9871 };
9872 let _ = self.persist_state();
9873 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9874 }
9875
9876 fn require_worktree_service(
9877 &self,
9878 source_workspace_id: &WorkspaceId,
9879 ) -> Result<(), NodeFailure> {
9880 if self.managed_worktrees.lock()
9881 .unwrap_or_else(|poisoned| poisoned.into_inner())
9882 .lease_for_workspace(source_workspace_id)
9883 .is_some()
9884 {
9885 return Err(failure(
9886 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9887 "managed worktree targets are internal-only workspaces",
9888 ));
9889 }
9890 match self
9891 .worktree_service_modes
9892 .get(source_workspace_id)
9893 .copied()
9894 .unwrap_or(WorktreeServiceMode::Manual)
9895 {
9896 WorktreeServiceMode::Manual => Ok(()),
9897 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9898 NodeFailureCode::UnsupportedSpawnCapability,
9899 "Git worktree service mode is unavailable in this node build",
9900 )),
9901 }
9902 }
9903
9904 fn managed_reservation_conflict(
9905 &self,
9906 workspace_id: Option<&WorkspaceId>,
9907 root: Option<&str>,
9908 ) -> bool {
9909 self.managed_worktrees.lock()
9910 .unwrap_or_else(|poisoned| poisoned.into_inner())
9911 .active_records()
9912 .any(|lease| {
9913 workspace_id.is_some_and(|workspace_id| {
9914 workspace_id == &lease.source_workspace_id
9915 || workspace_id == &lease.workspace_id
9916 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9917 })
9918 }
9919
9920 fn reject_managed_reservation(
9921 &self,
9922 workspace_id: Option<&WorkspaceId>,
9923 root: Option<&str>,
9924 ) -> Result<(), NodeFailure> {
9925 if self.managed_reservation_conflict(workspace_id, root) {
9926 Err(failure(
9927 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9928 "managed worktree ownership may only be mutated through its lease cleanup",
9929 ))
9930 } else {
9931 Ok(())
9932 }
9933 }
9934
9935 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9936 self.validate_address(address)?;
9937 self.workspace_root(&address.workspace_id)
9938 }
9939
9940 fn require_session_runtime_policy(
9974 &self,
9975 address: &SessionAddress,
9976 requirement: ProviderRuntimeRequirement,
9977 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9978 let runtime_policy = {
9979 let bindings = self
9980 .session_bindings
9981 .lock()
9982 .unwrap_or_else(|poisoned| poisoned.into_inner());
9983 let binding = bindings
9984 .get(&address.session.instance_id)
9985 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9986 if binding.workspace_id != address.workspace_id {
9987 return Err(failure(
9988 NodeFailureCode::SessionWorkspaceMismatch,
9989 "session belongs to another workspace",
9990 ));
9991 }
9992 if binding.generation != address.session.generation {
9993 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9994 }
9995 binding.runtime_policy
9996 };
9997 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9998 let snapshot = self.internal_session_snapshot(address)?;
9999 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10000 && snapshot.provider.lead_activity != ProviderActivity::Idle
10001 {
10002 return Err(failure(
10003 NodeFailureCode::TurnInFlight,
10004 "session already has a turn in flight; wait for it to complete before sending another prompt",
10005 ));
10006 }
10007 }
10008 require_policy(runtime_policy, false, false, requirement)
10009 .map(|()| runtime_policy)
10010 .map_err(|_| failure(
10011 NodeFailureCode::UnsupportedCapability,
10012 "session runtime policy does not admit this semantic operation",
10013 ))
10014 }
10015
10016 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10017 let mut bindings = self
10018 .session_bindings
10019 .lock()
10020 .unwrap_or_else(|poisoned| poisoned.into_inner());
10021 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10022 binding.workspace_id == address.workspace_id
10023 && binding.generation == address.session.generation
10024 }) {
10025 let removed = bindings.remove(&address.session.instance_id);
10026 self.clear_terminal_frame_watermark(address);
10027 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10028 return removed;
10029 }
10030 None
10031 }
10032
10033 fn cleanup_session_owned_materialization(
10034 &self,
10035 address: &SessionAddress,
10036 binding: &SessionBinding,
10037 ) -> Result<(), NodeFailure> {
10038 let Some(id) = binding.materialization_id.as_ref() else {
10039 return Ok(());
10040 };
10041 let owner = self
10042 .materializations
10043 .lock()
10044 .unwrap_or_else(|poisoned| poisoned.into_inner())
10045 .get(id)
10046 .map(|record| record.owner().clone());
10047 match owner {
10048 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10049 Some(MaterializationOwner::Session {
10050 incarnation_id,
10051 instance_id,
10052 generation,
10053 }) if incarnation_id == self.incarnation_id
10054 && instance_id == address.session.instance_id
10055 && generation == address.session.generation =>
10056 {
10057 self.cleanup_materialization(id)
10058 }
10059 Some(MaterializationOwner::Session { .. }) => Err(failure(
10060 NodeFailureCode::BackendOperationFailed,
10061 "session environment ownership requires recovery",
10062 )),
10063 }
10064 }
10065
10066 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10067 let mut watermarks = self
10068 .terminal_frame_watermarks
10069 .lock()
10070 .unwrap_or_else(|poisoned| poisoned.into_inner());
10071 if watermarks
10072 .get(&address.session.instance_id)
10073 .is_some_and(|(current, _)| current == address)
10074 {
10075 watermarks.remove(&address.session.instance_id);
10076 }
10077 }
10078
10079 #[cfg(test)]
10080 fn address_for(
10081 &self,
10082 instance_id: AgentInstanceId,
10083 generation: SessionGeneration,
10084 ) -> Option<SessionAddress> {
10085 let binding = self.session_bindings
10086 .lock()
10087 .unwrap_or_else(|poisoned| poisoned.into_inner())
10088 .get(&instance_id)
10089 .cloned()?;
10090 (binding.generation == generation).then_some(SessionAddress {
10091 workspace_id: binding.workspace_id,
10092 session: SessionKey { instance_id, generation },
10093 })
10094 }
10095
10096 fn arm_resume(
10097 &self,
10098 address: &SessionAddress,
10099 command_id: CommandId,
10100 runtime_policy: ProviderRuntimePolicy,
10101 ) -> Result<(), NodeFailure> {
10102 let mut bindings = self
10103 .session_bindings
10104 .lock()
10105 .unwrap_or_else(|poisoned| poisoned.into_inner());
10106 let binding = bindings
10107 .get_mut(&address.session.instance_id)
10108 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10109 if binding.workspace_id != address.workspace_id {
10110 return Err(failure(
10111 NodeFailureCode::SessionWorkspaceMismatch,
10112 "session belongs to another workspace",
10113 ));
10114 }
10115 if binding.generation != address.session.generation {
10116 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10117 }
10118 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10119 Ok(())
10120 }
10121
10122 fn clear_armed_resume(&self, address: &SessionAddress) {
10123 let mut bindings = self
10124 .session_bindings
10125 .lock()
10126 .unwrap_or_else(|poisoned| poisoned.into_inner());
10127 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10128 if binding.workspace_id == address.workspace_id
10129 && binding.generation == address.session.generation
10130 {
10131 binding.pending_resume = None;
10132 }
10133 }
10134 }
10135
10136 fn publish_control(&self, event: ControlEvent) {
10137 let revokes_harness_mcp = matches!(
10138 &event.event,
10139 ControlEventKind::Exited { .. }
10140 | ControlEventKind::Failed { .. }
10141 | ControlEventKind::Removed
10142 );
10143 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10144 let mut bindings = self
10145 .session_bindings
10146 .lock()
10147 .unwrap_or_else(|poisoned| poisoned.into_inner());
10148 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10149 return;
10150 };
10151 let previous_address = SessionAddress {
10152 workspace_id: binding.workspace_id.clone(),
10153 session: SessionKey {
10154 instance_id: event.instance_id,
10155 generation: binding.generation,
10156 },
10157 };
10158 let mut resume_command_rejected = false;
10159 if binding.generation == event.generation {
10160 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10161 let rejected_resume = matches!(
10162 &event.event,
10163 ControlEventKind::CommandRejected { .. }
10164 ) && event.command_id == Some(pending_command_id);
10165 let resume_failed = matches!(
10166 &event.event,
10167 ControlEventKind::ResumeDenied { .. }
10168 | ControlEventKind::ResumeFailed { .. }
10169 );
10170 if pending_generation == binding.generation
10171 && (rejected_resume || resume_failed)
10172 {
10173 resume_command_rejected = rejected_resume;
10174 binding.pending_resume = None;
10175 }
10176 }
10177 } else {
10178 let expected = binding.generation.0.checked_add(1);
10179 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10180 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10181 generation == binding.generation
10182 })
10183 || expected != Some(event.generation.0)
10184 || !authorized
10185 {
10186 return;
10187 }
10188 let (_, _, runtime_policy) = binding
10189 .pending_resume
10190 .expect("authorized resume retains its pending runtime policy");
10191 binding.generation = event.generation;
10192 binding.runtime_policy = runtime_policy;
10193 binding.pending_resume = None;
10194 }
10195 (
10196 SessionAddress {
10197 workspace_id: binding.workspace_id.clone(),
10198 session: SessionKey {
10199 instance_id: event.instance_id,
10200 generation: event.generation,
10201 },
10202 },
10203 (previous_address.session.generation != event.generation)
10204 .then_some(previous_address),
10205 binding.record_id.clone(),
10206 binding.runtime_policy.provider_session_identity,
10207 resume_command_rejected,
10208 )
10209 };
10210 if let Some(previous_address) = previous_address.as_ref() {
10211 self.clear_terminal_frame_watermark(previous_address);
10212 }
10213 if let Some(record_id) = record_id {
10214 self.reconcile_managed_record(
10215 &record_id,
10216 &address,
10217 &event,
10218 identity_admitted,
10219 resume_command_rejected,
10220 );
10221 }
10222 match &event.event {
10230 ControlEventKind::Running { process_id } => {
10231 tracing::info!(
10232 node_id = %self.node_id,
10233 workspace_id = %address.workspace_id,
10234 session = ?address.session,
10235 pid = ?process_id,
10236 "session process is running",
10237 );
10238 }
10239 ControlEventKind::Exited { exit_code, forced } => {
10240 tracing::info!(
10241 node_id = %self.node_id,
10242 workspace_id = %address.workspace_id,
10243 session = ?address.session,
10244 exit_code = ?exit_code,
10245 forced = forced,
10246 "session process exited",
10247 );
10248 }
10249 ControlEventKind::Failed { message } => {
10250 tracing::warn!(
10251 node_id = %self.node_id,
10252 workspace_id = %address.workspace_id,
10253 session = ?address.session,
10254 cause = %message,
10255 "session failed",
10256 );
10257 }
10258 ControlEventKind::CommandRejected { message } => {
10259 tracing::warn!(
10260 node_id = %self.node_id,
10261 workspace_id = %address.workspace_id,
10262 session = ?address.session,
10263 cause = %message,
10264 "session command rejected",
10265 );
10266 }
10267 ControlEventKind::Removed => {
10268 tracing::info!(
10269 node_id = %self.node_id,
10270 workspace_id = %address.workspace_id,
10271 session = ?address.session,
10272 "session removed",
10273 );
10274 }
10275 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10276 tracing::info!(
10277 node_id = %self.node_id,
10278 workspace_id = %address.workspace_id,
10279 session = ?address.session,
10280 provider_session = ?provider_session,
10281 "session resume authorized",
10282 );
10283 }
10284 ControlEventKind::Resumed { session: provider_session, process_id } => {
10285 tracing::info!(
10286 node_id = %self.node_id,
10287 workspace_id = %address.workspace_id,
10288 session = ?address.session,
10289 provider_session = ?provider_session,
10290 pid = ?process_id,
10291 "session resumed",
10292 );
10293 }
10294 ControlEventKind::ResumeDenied { reason } => {
10295 tracing::warn!(
10296 node_id = %self.node_id,
10297 workspace_id = %address.workspace_id,
10298 session = ?address.session,
10299 cause = %reason,
10300 "session resume denied",
10301 );
10302 }
10303 ControlEventKind::ResumeFailed { message } => {
10304 tracing::warn!(
10305 node_id = %self.node_id,
10306 workspace_id = %address.workspace_id,
10307 session = ?address.session,
10308 cause = %message,
10309 "session resume failed",
10310 );
10311 }
10312 _ => {}
10313 }
10314 let stream_chunk = agent_stream_chunk(&event);
10315 self.publish(NodeEvent::Control {
10316 address: address.clone(),
10317 event,
10318 });
10319 if let Some(chunk) = stream_chunk {
10320 self.publish_agent_stream_chunk(address.clone(), chunk);
10321 }
10322 if revokes_harness_mcp {
10323 SessionLifecycleHook::on_session_ended(self, &address);
10324 }
10325 }
10326
10327 fn reconcile_managed_record(
10328 &self,
10329 record_id: &SessionRecordId,
10330 address: &SessionAddress,
10331 event: &ControlEvent,
10332 identity_admitted: bool,
10333 resume_command_rejected: bool,
10334 ) {
10335 let _transaction = self
10336 .state_transaction
10337 .lock()
10338 .unwrap_or_else(|poisoned| poisoned.into_inner());
10339 let observed_identity = match &event.event {
10340 ControlEventKind::ProviderEvent {
10341 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10342 ..
10343 } if identity_admitted => Some(identity.clone()),
10344 _ => None,
10345 };
10346 let identity_was_observed = observed_identity.is_some();
10347 let replacement_id = observed_identity
10348 .as_ref()
10349 .and_then(|_| self.allocate_record_id().ok());
10350 let mut upserts = Vec::new();
10351 let mut removals = Vec::new();
10352 let mut rebind = None;
10353 let original_records;
10354 let original_materializations = self
10355 .materializations
10356 .lock()
10357 .unwrap_or_else(|poisoned| poisoned.into_inner())
10358 .clone();
10359 {
10360 let mut records = self
10361 .session_records
10362 .lock()
10363 .unwrap_or_else(|poisoned| poisoned.into_inner());
10364 original_records = records.records.clone();
10365 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10366 return;
10367 };
10368 if let Some(identity) = observed_identity {
10369 let existing_id = records
10370 .records
10371 .values()
10372 .find(|record| {
10373 record.record_id != *record_id
10374 && record.provider_session.as_ref().is_some_and(|record_identity| {
10375 session_registry::same_provider_session(
10376 &record.provider,
10377 record_identity,
10378 ¤t_snapshot.provider,
10379 &identity,
10380 )
10381 })
10382 })
10383 .map(|record| record.record_id.clone());
10384 if let Some(existing_id) = existing_id {
10385 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10386 record.mode == current_snapshot.mode
10387 && record.workspace_id == current_snapshot.workspace_id
10388 && record.environment_profile == current_snapshot.environment_profile
10389 && record.bundle == current_snapshot.bundle
10390 && record.context_id == current_snapshot.context_id
10391 && record.context == current_snapshot.context
10392 && record
10393 .canonical_root
10394 .as_utf8()
10395 .zip(current_snapshot.canonical_root.as_utf8())
10396 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10397 });
10398 if !scope_matches {
10399 if let Some(current) = records.records.get_mut(record_id) {
10400 current.state = ManagedSessionState::Unavailable;
10401 current.last_error =
10402 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10403 current.updated_at_unix_ms = unix_time_ms();
10404 upserts.push(current.clone());
10405 }
10406 } else {
10407 let existing_is_active = records
10408 .records
10409 .get(&existing_id)
10410 .is_some_and(|record| record.active_session.is_some());
10411 if existing_is_active {
10412 if let Some(current) = records.records.get_mut(record_id) {
10413 current.state = ManagedSessionState::Unavailable;
10414 current.last_error =
10415 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10416 current.updated_at_unix_ms = unix_time_ms();
10417 upserts.push(current.clone());
10418 }
10419 } else {
10420 if current_snapshot.provider_session.is_some() {
10421 if let Some(current) = records.records.get_mut(record_id) {
10422 current.active_session = None;
10423 current.state = ManagedSessionState::Dormant;
10424 current.updated_at_unix_ms = unix_time_ms();
10425 upserts.push(current.clone());
10426 }
10427 } else {
10428 records.records.remove(record_id);
10429 removals.push(record_id.clone());
10430 }
10431 if let Some(existing) = records.records.get_mut(&existing_id) {
10432 existing.provider_session = Some(identity.clone());
10433 existing.active_session = Some(address.clone());
10434 existing.state = ManagedSessionState::Live;
10435 existing.updated_at_unix_ms = unix_time_ms();
10436 existing.last_error = None;
10437 upserts.push(existing.clone());
10438 }
10439 rebind = Some(existing_id);
10440 }
10441 }
10442 } else if current_snapshot
10443 .provider_session
10444 .as_ref()
10445 .is_some_and(|current| {
10446 !session_registry::same_provider_session(
10447 ¤t_snapshot.provider,
10448 current,
10449 ¤t_snapshot.provider,
10450 &identity,
10451 )
10452 })
10453 {
10454 if let Some(current) = records.records.get_mut(record_id) {
10455 current.active_session = None;
10456 if current.bundle.is_some() || current.context.is_some() {
10457 let had_bundle = current.bundle.is_some();
10458 current.provider_session = None;
10459 current.bundle = None;
10460 current.context_id = None;
10461 current.context = None;
10462 current.state = ManagedSessionState::Unavailable;
10463 current.last_error = Some(if had_bundle {
10464 "bundle-unavailable".to_owned()
10465 } else {
10466 "context-unavailable".to_owned()
10467 });
10468 } else {
10469 current.state = ManagedSessionState::Dormant;
10470 }
10471 current.updated_at_unix_ms = unix_time_ms();
10472 upserts.push(current.clone());
10473 }
10474 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10475 if let Some(current) = records.records.get_mut(record_id) {
10476 current.state = ManagedSessionState::Unavailable;
10477 current.last_error =
10478 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10479 current.updated_at_unix_ms = unix_time_ms();
10480 upserts.push(current.clone());
10481 }
10482 } else if let Some(new_record_id) = replacement_id {
10483 let now = unix_time_ms();
10484 let replacement = ManagedSessionRecord {
10485 record_id: new_record_id.clone(),
10486 display_name: format!(
10487 "{} #{}",
10488 current_snapshot.provider.as_str(),
10489 records
10490 .records
10491 .values()
10492 .filter(|record| {
10493 record.provider == current_snapshot.provider
10494 })
10495 .count()
10496 .saturating_add(1),
10497 ),
10498 provider: current_snapshot.provider.clone(),
10499 mode: current_snapshot.mode,
10500 state: ManagedSessionState::Live,
10501 workspace_id: current_snapshot.workspace_id,
10502 canonical_root: current_snapshot.canonical_root,
10503 provider_session: Some(identity),
10504 active_session: Some(address.clone()),
10505 environment_profile: current_snapshot.environment_profile,
10506 bundle: current_snapshot.bundle,
10507 context_id: current_snapshot.context_id,
10508 context: current_snapshot.context,
10509 exported_context: None,
10515 task_binding: current_snapshot.task_binding,
10516 created_at_unix_ms: now,
10517 updated_at_unix_ms: now,
10518 last_error: None,
10519 };
10520 records
10521 .records
10522 .insert(new_record_id.clone(), replacement.clone());
10523 upserts.push(replacement);
10524 rebind = Some(new_record_id);
10525 } else if let Some(current) = records.records.get_mut(record_id) {
10526 current.state = ManagedSessionState::Unavailable;
10527 current.last_error =
10528 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10529 upserts.push(current.clone());
10530 }
10531 } else if let Some(current) = records.records.get_mut(record_id) {
10532 current.provider_session = Some(identity);
10533 current.active_session = Some(address.clone());
10534 current.state = ManagedSessionState::Live;
10535 current.updated_at_unix_ms = unix_time_ms();
10536 current.last_error = None;
10537 upserts.push(current.clone());
10538 }
10539 } else if let Some(current) = records.records.get_mut(record_id) {
10540 if resume_command_rejected {
10541 current.active_session = None;
10542 current.state = detached_record_state(current);
10543 current.updated_at_unix_ms = unix_time_ms();
10544 current.last_error =
10545 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10546 upserts.push(current.clone());
10547 } else {
10548 match &event.event {
10549 ControlEventKind::Running { .. }
10550 | ControlEventKind::ResumeAuthorized { .. }
10551 | ControlEventKind::Resumed { .. } => {
10552 current.active_session = Some(address.clone());
10553 current.state = active_record_state(
10554 identity_admitted,
10555 current.provider_session.is_some(),
10556 );
10557 current.updated_at_unix_ms = unix_time_ms();
10558 current.last_error = None;
10559 upserts.push(current.clone());
10560 }
10561 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10562 current.active_session = None;
10563 current.state = detached_record_state(current);
10564 current.updated_at_unix_ms = unix_time_ms();
10565 upserts.push(current.clone());
10566 }
10567 ControlEventKind::Failed { message }
10568 | ControlEventKind::ResumeFailed { message } => {
10569 current.active_session = None;
10570 current.state = detached_record_state(current);
10571 current.updated_at_unix_ms = unix_time_ms();
10572 current.last_error = Some(
10573 session_registry::sanitized_record_error_summary(message),
10574 );
10575 upserts.push(current.clone());
10576 }
10577 ControlEventKind::ResumeDenied { reason } => {
10578 current.active_session = None;
10579 current.state = detached_record_state(current);
10580 current.updated_at_unix_ms = unix_time_ms();
10581 current.last_error = Some(
10582 session_registry::sanitized_record_error_summary(reason),
10583 );
10584 upserts.push(current.clone());
10585 }
10586 _ => {}
10587 }
10588 }
10589 }
10590 }
10591 let materialization_owner_record = identity_was_observed.then(|| {
10592 rebind.clone().unwrap_or_else(|| record_id.clone())
10593 }).and_then(|candidate| {
10594 self.session_records
10595 .lock()
10596 .unwrap_or_else(|poisoned| poisoned.into_inner())
10597 .records
10598 .get(&candidate)
10599 .filter(|record| {
10600 record.provider_session.is_some()
10601 && record.active_session.as_ref() == Some(address)
10602 })
10603 .map(|_| candidate)
10604 });
10605 if let Some(owner_record_id) = materialization_owner_record {
10606 let materialization_id = self
10607 .session_bindings
10608 .lock()
10609 .unwrap_or_else(|poisoned| poisoned.into_inner())
10610 .get(&address.session.instance_id)
10611 .and_then(|binding| binding.materialization_id.clone());
10612 if let Some(materialization_id) = materialization_id {
10613 let mut transfer_failed = false;
10614 {
10615 let mut materializations = self
10616 .materializations
10617 .lock()
10618 .unwrap_or_else(|poisoned| poisoned.into_inner());
10619 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10620 match ownership.owner() {
10621 MaterializationOwner::Session {
10622 incarnation_id,
10623 instance_id,
10624 generation,
10625 } if *incarnation_id == self.incarnation_id
10626 && *instance_id == address.session.instance_id
10627 && *generation == address.session.generation =>
10628 {
10629 transfer_failed = ownership
10630 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10631 .is_err();
10632 }
10633 MaterializationOwner::Record { record_id }
10634 if record_id == &owner_record_id => {}
10635 MaterializationOwner::Record { record_id } => {
10636 let expected = record_id.clone();
10637 transfer_failed = ownership
10638 .transfer_record_owner(
10639 &expected,
10640 owner_record_id.clone(),
10641 unix_time_ms(),
10642 )
10643 .is_err();
10644 }
10645 _ => transfer_failed = true,
10646 }
10647 if transfer_failed {
10648 let _ = ownership.mark_recovery_required(unix_time_ms());
10649 }
10650 } else {
10651 transfer_failed = true;
10652 }
10653 }
10654 if transfer_failed {
10655 if let Some(record) = self
10656 .session_records
10657 .lock()
10658 .unwrap_or_else(|poisoned| poisoned.into_inner())
10659 .records
10660 .get_mut(&owner_record_id)
10661 {
10662 record.state = ManagedSessionState::Unavailable;
10663 record.last_error = Some("environment-profile-unavailable".to_owned());
10664 record.updated_at_unix_ms = unix_time_ms();
10665 upserts.push(record.clone());
10666 }
10667 }
10668 }
10669 }
10670 if let Some(new_record_id) = rebind.clone() {
10671 if let Some(binding) = self
10672 .session_bindings
10673 .lock()
10674 .unwrap_or_else(|poisoned| poisoned.into_inner())
10675 .get_mut(&address.session.instance_id)
10676 {
10677 binding.record_id = Some(new_record_id);
10678 }
10679 }
10680 if upserts.is_empty() && removals.is_empty() {
10681 return;
10682 }
10683 if self.persist_state_locked().is_err() {
10684 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10685 let mut records = self
10686 .session_records
10687 .lock()
10688 .unwrap_or_else(|poisoned| poisoned.into_inner());
10689 records.records = original_records;
10690 *self
10691 .materializations
10692 .lock()
10693 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10694 if let Some(current) = records.records.get_mut(record_id) {
10695 current.state = ManagedSessionState::Unavailable;
10696 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10697 current.updated_at_unix_ms = unix_time_ms();
10698 upserts = vec![current.clone()];
10699 } else {
10700 upserts.clear();
10701 }
10702 removals.clear();
10703 drop(records);
10704 if let Some(binding) = self
10705 .session_bindings
10706 .lock()
10707 .unwrap_or_else(|poisoned| poisoned.into_inner())
10708 .get_mut(&address.session.instance_id)
10709 {
10710 binding.record_id = Some(record_id.clone());
10711 }
10712 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10713 }
10714 for record in upserts {
10715 self.publish_record(record);
10716 }
10717 for record_id in removals {
10718 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10719 }
10720 }
10721
10722 fn snapshot(&self) -> NodeSnapshot {
10723 let control = self.handle.snapshot();
10724 let bindings = self
10725 .session_bindings
10726 .lock()
10727 .unwrap_or_else(|poisoned| poisoned.into_inner())
10728 .clone();
10729 let workspace_roots = self
10730 .workspaces
10731 .read()
10732 .unwrap_or_else(|poisoned| poisoned.into_inner())
10733 .clone();
10734 let environment_profiles = self
10735 .environment_profiles
10736 .read()
10737 .unwrap_or_else(|poisoned| poisoned.into_inner());
10738 let mut workspaces = workspace_roots
10739 .iter()
10740 .map(|(workspace_id, canonical_root)| {
10741 (
10742 workspace_id.clone(),
10743 WorkspaceSnapshot {
10744 workspace_id: workspace_id.clone(),
10745 canonical_root: opaque_windows_path(canonical_root.clone()),
10746 sessions: Vec::new(),
10747 worktree_service_mode: Some(
10748 self.worktree_service_modes
10749 .get(workspace_id)
10750 .copied()
10751 .unwrap_or(WorktreeServiceMode::Manual),
10752 ),
10753 managed_worktree_profiles: Some(WorktreeProfileInventory {
10754 profiles: self
10755 .managed_worktree_profiles
10756 .get(workspace_id)
10757 .into_iter()
10758 .flat_map(BTreeMap::values)
10759 .map(|profile| ManagedWorktreeProfileSummary {
10760 id: profile.profile_id().clone(),
10761 revision: profile.revision().clone(),
10762 retention: profile.retention(),
10763 })
10764 .collect(),
10765 }),
10766 },
10767 )
10768 })
10769 .collect::<BTreeMap<_, _>>();
10770 let mut agent_progress = Vec::new();
10771 for session in &control.sessions {
10772 let Some(binding) = bindings.get(&session.instance_id) else {
10773 continue;
10774 };
10775 if binding.generation != session.generation {
10776 continue;
10777 }
10778 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10779 let address = SessionAddress {
10780 workspace_id: binding.workspace_id.clone(),
10781 session: SessionKey {
10782 instance_id: session.instance_id,
10783 generation: session.generation,
10784 },
10785 };
10786 if let Some(progress) =
10787 agent_progress_from_provider_snapshot(address, &session.provider)
10788 {
10789 agent_progress.push(progress);
10790 }
10791 workspace.sessions.push(session.clone());
10792 }
10793 }
10794 NodeSnapshot {
10795 node_id: self.node_id.clone(),
10796 enabled_providers: self.enabled_providers.clone(),
10797 provider_runtime_statuses: {
10798 let updates = self
10799 .provider_runtime_status_updates
10800 .read()
10801 .unwrap_or_else(|poisoned| poisoned.into_inner());
10802 ProviderRuntimeStatuses::new(
10803 self.provider_runtime_statuses
10804 .iter()
10805 .filter(|status| !updates.contains_key(status.provider()))
10806 .cloned()
10807 .chain(updates.values().cloned()),
10808 )
10809 .expect("refreshed provider runtime inventory remains bounded and unique")
10810 },
10811 workspaces: workspaces.into_values().collect(),
10812 session_records: self
10813 .session_records
10814 .lock()
10815 .unwrap_or_else(|poisoned| poisoned.into_inner())
10816 .records
10817 .values()
10818 .cloned()
10819 .collect(),
10820 managed_worktrees: self.managed_worktrees
10821 .lock()
10822 .unwrap_or_else(|poisoned| poisoned.into_inner())
10823 .snapshots(),
10824 launch_inventory: Some(LaunchInventory {
10825 spawn_profiles: Some(
10826 self.spawn_profiles
10827 .iter()
10828 .filter_map(|profile| {
10829 resolved_spawn_profile_summary(profile, &environment_profiles)
10830 })
10831 .collect(),
10832 ),
10833 bundles: Some(
10834 self.bundle_catalog
10835 .read()
10836 .unwrap_or_else(|poisoned| poisoned.into_inner())
10837 .iter()
10838 .map(NodeBundle::receipt)
10839 .collect(),
10840 ),
10841 network_allowlists: {
10844 let catalog = self
10845 .network_allowlist_catalog
10846 .read()
10847 .unwrap_or_else(|poisoned| poisoned.into_inner());
10848 if catalog.is_empty() {
10849 None
10850 } else {
10851 Some(catalog.ids().cloned().collect())
10852 }
10853 },
10854 }),
10855 agent_progress,
10856 }
10857 }
10858
10859 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10860 assert!(
10861 !matches!(
10862 &event,
10863 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10864 ),
10865 "terminal frame and agent stream events must use their own dedicated live channel",
10866 );
10867 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10868 let sequence = history
10869 .last_sequence
10870 .checked_add(1)
10871 .expect("node event sequence exhausted");
10872 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10873 if let Some(evicted) = history.events.pop_front() {
10874 history.replay_floor_sequence = evicted
10875 .sequence
10876 .checked_add(1)
10877 .expect("node replay floor sequence exhausted");
10878 history.removed_record_providers.remove(&evicted.sequence);
10879 }
10880 }
10881 match &event {
10882 NodeEvent::HarnessMcpReadCall { .. } => {
10883 panic!("transient harness MCP calls must not enter durable event history")
10884 }
10885 NodeEvent::SessionRecordUpserted { record } => {
10886 history
10887 .record_providers
10888 .insert(record.record_id.clone(), record.provider.clone());
10889 }
10890 NodeEvent::SessionRecordRemoved { record_id } => {
10891 if let Some(provider) = history.record_providers.remove(record_id) {
10892 history.removed_record_providers.insert(sequence, provider);
10893 }
10894 }
10895 NodeEvent::ManagedWorktreeUpserted { .. }
10896 | NodeEvent::ManagedWorktreeRemoved { .. }
10897 | NodeEvent::Control { .. }
10898 | NodeEvent::ControllerChanged { .. }
10899 | NodeEvent::SessionRecordHistorySummarized { .. }
10900 | NodeEvent::WorkspaceAdded { .. }
10901 | NodeEvent::WorkspaceRemoved { .. }
10902 | NodeEvent::ResyncRequired { .. }
10903 | NodeEvent::AgentStream { .. }
10904 | NodeEvent::TerminalFrame { .. } => {}
10905 }
10906 let envelope = NodeEventEnvelope { sequence, event };
10907 history.events.push_back(envelope.clone());
10908 history.last_sequence = sequence;
10909 let _ = self.event_tx.send(envelope.clone());
10910 drop(history);
10911 envelope
10912 }
10913
10914 fn publish_transient(&self, event: NodeEvent) -> bool {
10925 assert!(event.requires_harness_mcp_proxy_capability());
10926 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10927 let envelope = NodeEventEnvelope { sequence: 0, event };
10928 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10929 Ok(_) => true,
10930 Err(broadcast::error::SendError(undelivered)) => {
10931 if let Some(NodeEventEnvelope {
10932 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10933 ..
10934 }) = undelivered.first() {
10935 tracing::warn!(
10941 call_id = call_id.as_str(),
10942 content_type = ?request.content_type,
10943 cause = "no-subscribers",
10944 "harness MCP proxy event could not be published",
10945 );
10946 }
10947 false
10948 }
10949 }
10950 }
10951
10952 fn publish_terminal_frames(&self) {
10953 let snapshot = self.handle.snapshot();
10954 let bindings = self
10955 .session_bindings
10956 .lock()
10957 .unwrap_or_else(|poisoned| poisoned.into_inner());
10958 let candidates = snapshot
10959 .sessions
10960 .iter()
10961 .filter_map(|session| {
10962 let binding = bindings.get(&session.instance_id)?;
10963 if binding.generation != session.generation {
10964 return None;
10965 }
10966 let frame = session.terminal_frame.clone()?;
10967 Some((
10968 SessionAddress {
10969 workspace_id: binding.workspace_id.clone(),
10970 session: SessionKey {
10971 instance_id: session.instance_id,
10972 generation: session.generation,
10973 },
10974 },
10975 frame,
10976 ))
10977 })
10978 .collect();
10979 drop(bindings);
10980 self.publish_terminal_frame_candidates(candidates);
10981 }
10982
10983 fn publish_terminal_frame_candidates(
10984 &self,
10985 candidates: Vec<(SessionAddress, TerminalFrame)>,
10986 ) {
10987 let mut watermarks = self
10988 .terminal_frame_watermarks
10989 .lock()
10990 .unwrap_or_else(|poisoned| poisoned.into_inner());
10991 let mut events = Vec::with_capacity(candidates.len());
10992 for (address, frame) in candidates {
10993 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10994 let advanced = watermarks
10995 .get(&address.session.instance_id)
10996 .map_or(true, |(current_address, sequence)| {
10997 current_address != &address || frame.sequence > *sequence
10998 });
10999 if !advanced {
11000 continue;
11001 }
11002 if is_first_frame {
11003 if self.controller_state().is_none() {
11011 tracing::warn!(
11012 node_id = %self.node_id,
11013 workspace_id = %address.workspace_id,
11014 session = ?address.session,
11015 terminal_size = ?frame.size,
11016 "session producing output with no controller attached",
11017 );
11018 } else {
11019 tracing::info!(
11020 node_id = %self.node_id,
11021 workspace_id = %address.workspace_id,
11022 session = ?address.session,
11023 terminal_size = ?frame.size,
11024 "session produced its first output",
11025 );
11026 }
11027 }
11028 watermarks.insert(
11029 address.session.instance_id,
11030 (address.clone(), frame.sequence),
11031 );
11032 if let Some((at, reported)) = self
11033 .pty_input_marks
11034 .lock()
11035 .unwrap_or_else(|poisoned| poisoned.into_inner())
11036 .get_mut(&address.session.instance_id)
11037 {
11038 if !*reported && at.elapsed() < Duration::from_secs(5) {
11040 *reported = true;
11041 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");
11042 }
11043 }
11044 events.push(NodeEvent::TerminalFrame { address, frame });
11045 }
11046 drop(watermarks);
11047 if events.is_empty() {
11048 return;
11049 }
11050 let mut history = self
11051 .history
11052 .lock()
11053 .unwrap_or_else(|poisoned| poisoned.into_inner());
11054 let mut envelopes = Vec::with_capacity(events.len());
11055 for event in events {
11056 let sequence = history
11057 .last_sequence
11058 .checked_add(1)
11059 .expect("node event sequence exhausted");
11060 history.last_sequence = sequence;
11061 envelopes.push(NodeEventEnvelope { sequence, event });
11062 }
11063 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11064 drop(history);
11065 }
11066
11067 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11076 let mut history = self
11077 .history
11078 .lock()
11079 .unwrap_or_else(|poisoned| poisoned.into_inner());
11080 let sequence = history
11081 .last_sequence
11082 .checked_add(1)
11083 .expect("node event sequence exhausted");
11084 history.last_sequence = sequence;
11085 drop(history);
11086 tracing::debug!(
11094 source_sequence = chunk.source_sequence,
11095 kind = agent_stream_chunk_kind_label(&chunk.kind),
11096 envelope_sequence = sequence,
11097 "publishing agent stream chunk",
11098 );
11099 let envelope = NodeEventEnvelope {
11100 sequence,
11101 event: NodeEvent::AgentStream { address, chunk },
11102 };
11103 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11104 }
11105
11106 fn current_sequence(&self) -> u64 {
11107 self.history
11108 .lock()
11109 .unwrap_or_else(|poisoned| poisoned.into_inner())
11110 .last_sequence
11111 }
11112
11113 fn controller_state(&self) -> Option<ControllerState> {
11114 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11115 let lease = controller.as_ref().copied()?;
11116 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11117 if remaining.is_zero() {
11118 *controller = None;
11119 return None;
11120 }
11121 Some(ControllerState {
11122 connection_id: lease.connection_id,
11123 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11124 })
11125 }
11126
11127 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11128 if role != ClientRole::Operator {
11129 tracing::warn!(
11130 node_id = %self.node_id,
11131 connection_id,
11132 "controller lease rejected: observer connections cannot acquire control",
11133 );
11134 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11135 }
11136 let lease_ms = if lease_ms == 0 {
11137 DEFAULT_CONTROLLER_LEASE_MS
11138 } else {
11139 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11140 };
11141 let now = Instant::now();
11142 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11143 if let Some(active) = controller.as_ref() {
11144 if active.connection_id != connection_id && active.expires_at > now {
11145 tracing::warn!(
11146 node_id = %self.node_id,
11147 connection_id,
11148 holder_connection_id = active.connection_id,
11149 "controller lease rejected: another operator holds it",
11150 );
11151 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11152 }
11153 }
11154 let is_new_attach = match controller.as_ref() {
11161 Some(active) => active.connection_id != connection_id,
11162 None => true,
11163 };
11164 *controller = Some(ControllerLease {
11165 connection_id,
11166 expires_at: now + Duration::from_millis(lease_ms),
11167 });
11168 drop(controller);
11169 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11170 if is_new_attach {
11171 tracing::info!(
11172 node_id = %self.node_id,
11173 connection_id,
11174 lease_ms,
11175 "controller lease acquired",
11176 );
11177 } else {
11178 tracing::debug!(
11179 node_id = %self.node_id,
11180 connection_id,
11181 lease_ms,
11182 "controller lease renewed",
11183 );
11184 }
11185 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11186 Ok(state)
11187 }
11188
11189 fn release_controller(&self, connection_id: u64) -> bool {
11190 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11191 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11192 if released {
11193 *controller = None;
11194 }
11195 drop(controller);
11196 if released {
11197 tracing::info!(
11198 node_id = %self.node_id,
11199 connection_id,
11200 "controller lease released",
11201 );
11202 self.publish(NodeEvent::ControllerChanged { controller: None });
11203 }
11204 released
11205 }
11206
11207 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11208 if role != ClientRole::Operator {
11209 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11210 }
11211 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11212 let now = Instant::now();
11213 match controller.as_ref().copied() {
11214 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11215 Some(active) if active.expires_at <= now => {
11216 *controller = None;
11217 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11218 }
11219 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11220 }
11221 }
11222
11223 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11224 if !self
11225 .workspaces
11226 .read()
11227 .unwrap_or_else(|poisoned| poisoned.into_inner())
11228 .contains_key(&address.workspace_id)
11229 {
11230 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11231 }
11232 let binding = self
11233 .session_bindings
11234 .lock()
11235 .unwrap_or_else(|poisoned| poisoned.into_inner())
11236 .get(&address.session.instance_id)
11237 .cloned()
11238 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11239 if binding.workspace_id != address.workspace_id {
11240 return Err(failure(
11241 NodeFailureCode::SessionWorkspaceMismatch,
11242 "session belongs to another workspace",
11243 ));
11244 }
11245 if binding.generation != address.session.generation {
11246 return Err(failure(
11247 NodeFailureCode::StaleGeneration,
11248 &format!(
11249 "stale session generation: expected {}, received {}",
11250 binding.generation.0,
11251 address.session.generation.0,
11252 ),
11253 ));
11254 }
11255 let snapshot = self.handle.snapshot();
11256 let Some(current) = snapshot
11257 .sessions
11258 .iter()
11259 .find(|item| item.instance_id == address.session.instance_id)
11260 else {
11261 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11262 };
11263 if current.generation != address.session.generation {
11264 return Err(failure(
11265 NodeFailureCode::StaleGeneration,
11266 &format!(
11267 "stale session generation: expected {}, received {}",
11268 current.generation.0,
11269 address.session.generation.0,
11270 ),
11271 ));
11272 }
11273 Ok(current.agent_id.clone())
11274 }
11275
11276 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11277 self.dispatch_envelope(self.prepare_command(command))
11278 }
11279
11280 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11281 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11282 CommandEnvelope {
11283 id: CommandId(id),
11284 command,
11285 }
11286 }
11287
11288 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11289 let command_id = envelope.id;
11290 self.handle
11291 .dispatch(envelope)
11292 .map_err(|error| match error {
11293 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11294 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11295 })?;
11296 Ok(command_id)
11297 }
11298
11299 fn record_rejected_command(
11308 &self,
11309 command_id: CommandId,
11310 message: String,
11311 unsupported_transport: bool,
11312 ) {
11313 let now = Instant::now();
11314 let mut reasons = self.rejected_command_reasons.lock()
11315 .unwrap_or_else(|poisoned| poisoned.into_inner());
11316 reasons.retain(|_, reason| reason.expires_at > now);
11317 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11318 if let Some(&oldest) = reasons.keys().next() {
11319 reasons.remove(&oldest);
11320 }
11321 }
11322 reasons.insert(
11323 command_id,
11324 RejectedCommandReason {
11325 message,
11326 unsupported_transport,
11327 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11328 },
11329 );
11330 }
11331
11332 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11339 let now = Instant::now();
11340 let mut reasons = self.rejected_command_reasons.lock()
11341 .unwrap_or_else(|poisoned| poisoned.into_inner());
11342 match reasons.remove(&command_id) {
11343 Some(reason) if reason.expires_at > now => Some(reason),
11344 _ => None,
11345 }
11346 }
11347
11348 async fn dispatch_bounded(
11349 &self,
11350 command: ControlCommand,
11351 timeout_duration: Duration,
11352 ) -> Result<CommandId, NodeFailure> {
11353 let deadline = Instant::now() + timeout_duration;
11354 loop {
11355 match self.dispatch(command.clone()) {
11356 Ok(command_id) => return Ok(command_id),
11357 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11358 sleep(Duration::from_millis(2)).await;
11359 }
11360 Err(error) => return Err(error),
11361 }
11362 }
11363 }
11364
11365 fn internal_session_snapshot(
11366 &self,
11367 address: &SessionAddress,
11368 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11369 self.validate_address(address)?;
11370 self.handle
11371 .snapshot()
11372 .sessions
11373 .clone()
11374 .into_iter()
11375 .find(|session| {
11376 session.instance_id == address.session.instance_id
11377 && session.generation == address.session.generation
11378 })
11379 .ok_or_else(|| {
11380 failure(
11381 NodeFailureCode::UnknownSession,
11382 "session snapshot is unavailable",
11383 )
11384 })
11385 }
11386
11387 fn require_acp_session(
11395 &self,
11396 address: &SessionAddress,
11397 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11398 let snapshot = self.internal_session_snapshot(address)?;
11399 if snapshot.transport != TransportKind::Acp {
11400 return Err(failure(
11401 NodeFailureCode::UnsupportedTransport,
11402 "this verb requires a session spawned over the ACP transport",
11403 ));
11404 }
11405 Ok(snapshot)
11406 }
11407
11408 async fn dispatch_history_bounded(
11409 &self,
11410 address: &SessionAddress,
11411 command: ControlCommand,
11412 operation: HistoryOperation,
11413 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11414 self.validate_address(address)?;
11415 let started_after = self.current_sequence();
11416 let command_id = self
11417 .dispatch_bounded(
11418 command,
11419 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11420 )
11421 .await?;
11422 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11423 let mut scan_after = started_after;
11424 let mut accepted_after = None;
11425 loop {
11426 let events = self
11427 .history
11428 .lock()
11429 .unwrap_or_else(|poisoned| poisoned.into_inner())
11430 .events
11431 .iter()
11432 .filter(|envelope| envelope.sequence > scan_after)
11433 .cloned()
11434 .collect::<Vec<_>>();
11435 for envelope in events {
11436 scan_after = scan_after.max(envelope.sequence);
11437 let NodeEvent::Control {
11438 address: event_address,
11439 event,
11440 } = envelope.event
11441 else {
11442 continue;
11443 };
11444 if event_address != *address {
11445 continue;
11446 }
11447 if event.command_id == Some(command_id) {
11448 match &event.event {
11449 ControlEventKind::CommandRejected { .. } => {
11450 return Err(failure(
11451 NodeFailureCode::InvalidRequest,
11452 "history request was rejected",
11453 ));
11454 }
11455 ControlEventKind::HistoryRequested {
11456 operation: requested,
11457 ..
11458 } if requested == &operation => {
11459 accepted_after = Some(envelope.sequence);
11460 continue;
11461 }
11462 _ => {}
11463 }
11464 }
11465 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11466 match (&operation, &event.event) {
11467 (
11468 HistoryOperation::Discover { .. },
11469 ControlEventKind::HistoryDiscovered { .. },
11470 )
11471 | (
11472 HistoryOperation::Load { .. },
11473 ControlEventKind::HistoryLoaded { .. },
11474 ) => return self.internal_session_snapshot(address),
11475 (_, ControlEventKind::HistoryFailed { .. }) => {
11476 return Err(failure(
11477 NodeFailureCode::BackendOperationFailed,
11478 "history operation failed",
11479 ));
11480 }
11481 _ => {}
11482 }
11483 }
11484 }
11485 if Instant::now() >= deadline {
11486 return Err(failure(
11487 NodeFailureCode::BackendBusy,
11488 if accepted_after.is_some() {
11489 "history operation did not settle before the bounded deadline"
11490 } else {
11491 "history operation was not accepted before the bounded deadline"
11492 },
11493 ));
11494 }
11495 sleep(Duration::from_millis(2)).await;
11496 }
11497 }
11498
11499 async fn discover_history(
11500 &self,
11501 address: &SessionAddress,
11502 limit: u16,
11503 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11504 let query = HistoryQuery {
11505 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11506 limit,
11507 };
11508 query
11509 .validate()
11510 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11511 let snapshot = self
11512 .dispatch_history_bounded(
11513 address,
11514 ControlCommand::DiscoverHistory {
11515 instance_id: address.session.instance_id,
11516 query: query.clone(),
11517 },
11518 HistoryOperation::Discover { query },
11519 )
11520 .await?;
11521 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11522 return Err(failure(
11523 NodeFailureCode::BackendOperationFailed,
11524 "history discovery settled into an invalid state",
11525 ));
11526 }
11527 Ok(snapshot.history.candidates)
11528 }
11529
11530 async fn load_history(
11531 &self,
11532 address: &SessionAddress,
11533 candidate_id: String,
11534 ) -> Result<HistorySessionRecord, NodeFailure> {
11535 validate_candidate_id(&candidate_id)
11536 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11537 let snapshot = self
11538 .dispatch_history_bounded(
11539 address,
11540 ControlCommand::LoadHistory {
11541 instance_id: address.session.instance_id,
11542 candidate_id: candidate_id.clone(),
11543 },
11544 HistoryOperation::Load {
11545 candidate_id: candidate_id.clone(),
11546 },
11547 )
11548 .await?;
11549 if snapshot.history.pending.is_some()
11550 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11551 {
11552 return Err(failure(
11553 NodeFailureCode::BackendOperationFailed,
11554 "history load settled into an invalid state",
11555 ));
11556 }
11557 snapshot.history.loaded.ok_or_else(|| {
11558 failure(
11559 NodeFailureCode::BackendOperationFailed,
11560 "history load produced no bounded session",
11561 )
11562 })
11563 }
11564
11565 async fn export_context_pack_for_session_record(
11572 &self,
11573 record_id: &SessionRecordId,
11574 session: &SessionAddress,
11575 allow_clean_detachment: bool,
11576 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11577 let outcome = self
11583 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11584 .await;
11585 if let Err(failure) = &outcome {
11586 tracing::warn!(
11587 record = %record_id,
11588 code = ?failure.code,
11589 reason = %failure.message,
11590 "session-record context-pack export refused",
11591 );
11592 }
11593 outcome
11594 }
11595
11596 async fn export_context_pack_for_session_record_inner(
11597 &self,
11598 record_id: &SessionRecordId,
11599 session: &SessionAddress,
11600 allow_clean_detachment: bool,
11601 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11602 let step = |name: &'static str| move |failure: NodeFailure| {
11607 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11608 failure
11609 };
11610 let record = self.record(record_id).map_err(step("record-lookup"))?;
11611 let identity = match record.provider_session.clone() {
11627 Some(identity) => {
11628 if identity.key != ProviderSessionKey::SessionId {
11629 return Err(step("provider-identity-key")(failure(
11630 NodeFailureCode::BackendOperationFailed,
11631 "managed session provider identity cannot bind history exactly",
11632 )));
11633 }
11634 Some(identity)
11635 }
11636 None if record.state == ManagedSessionState::IdentityPending => {
11637 return Err(step("provider-session-identity")(failure(
11638 NodeFailureCode::BackendOperationFailed,
11639 "managed session has not yet observed its provider session identity",
11640 )));
11641 }
11642 None => None,
11643 };
11644 self.revalidate_session_record_context_export(
11645 &record,
11646 identity.as_ref(),
11647 session,
11648 allow_clean_detachment,
11649 ).map_err(step("revalidate-before-discovery"))?;
11650 let candidates = self
11651 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11652 .await
11653 .map_err(step("discover-history"))?;
11654 let candidate_id = select_history_candidate(
11659 &candidates,
11660 identity.as_ref().map(|identity| identity.id.as_str()),
11661 ).map_err(step("unique-candidate"))?;
11662 let loaded = self.load_history(session, candidate_id.clone()).await
11663 .map_err(step("load-history"))?;
11664 if let Some(identity) = identity.as_ref() {
11665 if loaded.session_id != identity.id {
11666 return Err(failure(
11667 NodeFailureCode::SessionRecordConflict,
11668 "loaded history identity does not match the managed session record",
11669 ));
11670 }
11671 }
11672 self.revalidate_session_record_context_export(
11673 &record,
11674 identity.as_ref(),
11675 session,
11676 allow_clean_detachment,
11677 )?;
11678 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11679 .map_err(step("revalidate-loaded-history"))?;
11680 let pack = self.materialize_context_pack(session).await
11681 .map_err(step("materialize-context-pack"))?;
11682 self.commit_context_pack_for_session_record(
11683 &record,
11684 identity.as_ref(),
11685 session,
11686 &candidate_id,
11687 &loaded,
11688 pack,
11689 allow_clean_detachment,
11690 )
11691 }
11692
11693 async fn export_context_pack(
11694 &self,
11695 address: &SessionAddress,
11696 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11697 let pack = self.materialize_context_pack(address).await?;
11698 self.commit_context_pack(pack)
11699 }
11700
11701 async fn materialize_context_pack(
11702 &self,
11703 address: &SessionAddress,
11704 ) -> Result<NodeContextPack, NodeFailure> {
11705 let snapshot = self.internal_session_snapshot(address)?;
11706 if !context_pack_source_status_is_usable(&snapshot.status) {
11707 return Err(failure(
11708 NodeFailureCode::InvalidRequest,
11709 "context export requires a running or successfully exited source session",
11710 ));
11711 }
11712 if snapshot.history.pending.is_some()
11713 || snapshot.history.loaded_candidate_id.is_none()
11714 {
11715 return Err(failure(
11716 NodeFailureCode::InvalidRequest,
11717 "context export requires settled loaded history",
11718 ));
11719 }
11720 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11721 failure(
11722 NodeFailureCode::InvalidRequest,
11723 "context export requires settled loaded history",
11724 )
11725 })?;
11726 let source_provider = snapshot.agent_id.clone();
11727 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11728 let repository = self
11729 .context_pack_repository(&address.workspace_id)
11730 .await?;
11731 let current = self.internal_session_snapshot(address)?;
11732 if !context_pack_source_status_is_usable(¤t.status)
11733 || current.agent_id != source_provider
11734 || current.history.pending.is_some()
11735 || current.history.loaded_candidate_id != loaded_candidate_id
11736 || current.history.loaded.as_ref() != Some(&loaded)
11737 {
11738 return Err(failure(
11739 NodeFailureCode::ContextPackMaterializationFailed,
11740 "context source changed during bounded repository capture",
11741 ));
11742 }
11743 NodeContextPack::export_with_repository(
11744 ContextPackLineageReceipt {
11745 source_node_id: self.node_id.clone(),
11746 source_session: address.clone(),
11747 source_provider,
11748 },
11749 &loaded,
11750 Some(repository),
11751 )
11752 .map_err(|_| {
11753 failure(
11754 NodeFailureCode::ContextPackMaterializationFailed,
11755 "bounded context pack export failed",
11756 )
11757 })
11758 }
11759
11760 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11766 let mut store = self
11767 .context_pack_store
11768 .lock()
11769 .unwrap_or_else(|poisoned| poisoned.into_inner());
11770 let Some(store) = store.as_mut() else {
11771 return Ok(());
11772 };
11773 store.commit(pack).map_err(|_| {
11774 failure(
11775 NodeFailureCode::ContextPackMaterializationFailed,
11776 "durable context pack commit failed",
11777 )
11778 })
11779 }
11780
11781 fn commit_context_pack(
11782 &self,
11783 pack: NodeContextPack,
11784 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11785 self.commit_context_pack_durably(&pack)?;
11786 self.context_catalog
11787 .write()
11788 .unwrap_or_else(|poisoned| poisoned.into_inner())
11789 .insert(pack)
11790 .map_err(|_| {
11791 failure(
11792 NodeFailureCode::ContextPackMaterializationFailed,
11793 "context pack catalog is full",
11794 )
11795 })
11796 }
11797
11798 fn commit_context_pack_for_session_record(
11799 &self,
11800 expected: &ManagedSessionRecord,
11801 identity: Option<&ProviderSessionIdentity>,
11802 session: &SessionAddress,
11803 candidate_id: &str,
11804 loaded: &HistorySessionRecord,
11805 pack: NodeContextPack,
11806 allow_clean_detachment: bool,
11807 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11808 let current_root = self.workspace_root(&expected.workspace_id)?;
11809 let _transaction = self
11810 .state_transaction
11811 .lock()
11812 .unwrap_or_else(|poisoned| poisoned.into_inner());
11813 let records = self
11814 .session_records
11815 .lock()
11816 .unwrap_or_else(|poisoned| poisoned.into_inner());
11817 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11818 failure(
11819 NodeFailureCode::SessionRecordConflict,
11820 "managed session record disappeared during context export",
11821 )
11822 })?;
11823 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11824 return Err(failure(
11825 NodeFailureCode::SessionRecordConflict,
11826 "managed session record changed during context export",
11827 ));
11828 }
11829 let bindings = self
11830 .session_bindings
11831 .lock()
11832 .unwrap_or_else(|poisoned| poisoned.into_inner());
11833 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11834 failure(
11835 NodeFailureCode::SessionRecordConflict,
11836 "managed session runtime binding disappeared during context export",
11837 )
11838 })?;
11839 let runtime = self
11840 .handle
11841 .snapshot()
11842 .sessions
11843 .iter()
11844 .find(|snapshot| {
11845 snapshot.instance_id == session.session.instance_id
11846 && snapshot.generation == session.session.generation
11847 })
11848 .cloned()
11849 .ok_or_else(|| {
11850 failure(
11851 NodeFailureCode::SessionRecordConflict,
11852 "managed session runtime disappeared during context export",
11853 )
11854 })?;
11855 let receipt = pack.receipt();
11856 if !session_record_context_export_binding_is_exact(
11857 expected,
11858 current,
11859 identity,
11860 session,
11861 binding,
11862 &runtime.agent_id,
11863 ¤t_root,
11864 allow_clean_detachment,
11865 ) || !session_record_context_export_source_is_usable(
11866 current,
11867 &runtime.status,
11868 allow_clean_detachment,
11869 ) || runtime.history.pending.is_some()
11870 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11871 || runtime.history.loaded.as_ref() != Some(loaded)
11872 || receipt.lineage.source_node_id != self.node_id
11873 || receipt.lineage.source_session != *session
11874 || receipt.lineage.source_provider != current.provider
11875 || receipt.source_message_count != loaded.message_count
11876 {
11877 return Err(failure(
11878 NodeFailureCode::SessionRecordConflict,
11879 "managed session source changed before context catalog commit",
11880 ));
11881 }
11882 self.commit_context_pack_durably(&pack)?;
11883 self.context_catalog
11884 .write()
11885 .unwrap_or_else(|poisoned| poisoned.into_inner())
11886 .insert(pack)
11887 .map_err(|_| {
11888 failure(
11889 NodeFailureCode::ContextPackMaterializationFailed,
11890 "context pack catalog is full",
11891 )
11892 })
11893 }
11894
11895 async fn context_pack_repository(
11896 &self,
11897 workspace_id: &WorkspaceId,
11898 ) -> Result<ContextPackRepository, NodeFailure> {
11899 let first = self
11900 .context_pack_repository_observation(workspace_id)
11901 .await?;
11902 let second = self
11903 .context_pack_repository_observation(workspace_id)
11904 .await?;
11905 stable_context_pack_repository(first, second)
11906 }
11907
11908 async fn context_pack_repository_observation(
11909 &self,
11910 workspace_id: &WorkspaceId,
11911 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11912 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11913 let head = if git.is_repository {
11914 let root = self.workspace_root(workspace_id)?;
11915 match resolve_base_commit_with_timeout(
11916 &root,
11917 "HEAD",
11918 GIT_COMMAND_TIMEOUT_MS,
11919 )
11920 .await
11921 {
11922 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11923 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11924 ContextPackRepositoryHead::Unborn
11925 }
11926 Err(_) => {
11927 return Err(failure(
11928 NodeFailureCode::ContextPackMaterializationFailed,
11929 "context pack HEAD observation failed",
11930 ));
11931 }
11932 }
11933 } else {
11934 ContextPackRepositoryHead::NotRepository
11935 };
11936 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11937 for selected_path in CONTEXT_PACK_SELECTED_FILES {
11938 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11939 failure(
11940 NodeFailureCode::InvalidRepositoryPath,
11941 "context pack selected file path is invalid",
11942 )
11943 })?;
11944 match self.read_workspace_file(workspace_id.clone(), path).await {
11945 Ok(WorkspaceFileRead {
11946 content: WorkspaceFileContent::Utf8 { text, byte_len },
11947 ..
11948 }) => files.push(ContextPackRepositoryFileSource::utf8(
11949 selected_path,
11950 text,
11951 byte_len,
11952 )),
11953 Ok(WorkspaceFileRead {
11954 content: WorkspaceFileContent::NonUtf8 { byte_len },
11955 ..
11956 }) => files.push(ContextPackRepositoryFileSource::skipped(
11957 selected_path,
11958 ContextPackSelectedFileSkipReason::NonUtf8,
11959 Some(byte_len),
11960 )),
11961 Ok(WorkspaceFileRead {
11962 content: WorkspaceFileContent::TooLarge { .. },
11963 ..
11964 }) => files.push(ContextPackRepositoryFileSource::skipped(
11965 selected_path,
11966 ContextPackSelectedFileSkipReason::TooLarge,
11967 None,
11968 )),
11969 Err(error) => {
11970 let reason = if matches!(
11971 error.code,
11972 NodeFailureCode::InvalidRepositoryPath
11973 | NodeFailureCode::RepositoryFileNotRegular
11974 | NodeFailureCode::RepositoryPathUnsafe
11975 ) {
11976 ContextPackSelectedFileSkipReason::Unsafe
11977 } else {
11978 ContextPackSelectedFileSkipReason::Unavailable
11979 };
11980 files.push(ContextPackRepositoryFileSource::skipped(
11981 selected_path,
11982 reason,
11983 None,
11984 ));
11985 }
11986 }
11987 }
11988 Ok(ContextPackRepositoryObservation { head, git, files })
11989 }
11990
11991 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11992 let referenced_by_binding = self
11993 .session_bindings
11994 .lock()
11995 .unwrap_or_else(|poisoned| poisoned.into_inner())
11996 .values()
11997 .any(|binding| {
11998 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11999 });
12000 let referenced_by_record = self
12001 .session_records
12002 .lock()
12003 .unwrap_or_else(|poisoned| poisoned.into_inner())
12004 .records
12005 .values()
12006 .any(|record| record.context_id.as_ref() == Some(context_id));
12007 let referenced_by_materialization = self
12008 .materializations
12009 .lock()
12010 .unwrap_or_else(|poisoned| poisoned.into_inner())
12011 .values()
12012 .any(|ownership| {
12013 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12014 });
12015 if referenced_by_binding
12016 || referenced_by_record
12017 || referenced_by_materialization
12018 {
12019 return Err(failure(
12020 NodeFailureCode::ContextPackBusy,
12021 "context pack is still referenced",
12022 ));
12023 }
12024 let mut catalog = self.context_catalog
12025 .write()
12026 .unwrap_or_else(|poisoned| poisoned.into_inner());
12027 if catalog.get(context_id).is_none() {
12028 return Err(failure(
12029 NodeFailureCode::UnknownContextPack,
12030 "context pack is unavailable",
12031 ));
12032 }
12033 self.spawn_idempotency
12034 .lock()
12035 .unwrap_or_else(|poisoned| poisoned.into_inner())
12036 .entries
12037 .retain(|_, entry| !entry.value.references_context(context_id));
12038 let removed = catalog.remove(context_id);
12039 debug_assert!(removed.is_some(), "validated context pack remains present");
12040 Ok(())
12041 }
12042
12043 async fn dispatch_input_bounded(
12044 &self,
12045 address: &SessionAddress,
12046 action: InputAction,
12047 ) -> Result<(), NodeFailure> {
12048 let started_after = self.current_sequence();
12049 self.pty_input_marks
12050 .lock()
12051 .unwrap_or_else(|poisoned| poisoned.into_inner())
12052 .insert(address.session.instance_id, (Instant::now(), false));
12053 self.pty_input_wake.notify_one();
12054 let command_id = self
12055 .dispatch_bounded(
12056 ControlCommand::SendInput {
12057 instance_id: address.session.instance_id,
12058 action,
12059 },
12060 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12061 )
12062 .await?;
12063 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12067 let mut scan_after = started_after;
12068 let mut accepted_after = None;
12069 loop {
12070 let events = self
12071 .history
12072 .lock()
12073 .unwrap_or_else(|poisoned| poisoned.into_inner())
12074 .events
12075 .iter()
12076 .filter(|envelope| envelope.sequence > scan_after)
12077 .cloned()
12078 .collect::<Vec<_>>();
12079 for envelope in events {
12080 scan_after = scan_after.max(envelope.sequence);
12081 let NodeEvent::Control {
12082 address: event_address,
12083 event,
12084 } = envelope.event
12085 else {
12086 continue;
12087 };
12088 if event_address != *address {
12089 continue;
12090 }
12091 if event.command_id == Some(command_id) {
12092 match &event.event {
12093 ControlEventKind::CommandRejected { message } => {
12094 return Err(failure(NodeFailureCode::InvalidRequest, message));
12095 }
12096 ControlEventKind::InputRequested { .. } => {
12097 accepted_after = Some(envelope.sequence);
12098 continue;
12099 }
12100 _ => {}
12101 }
12102 }
12103 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12104 match &event.event {
12105 ControlEventKind::InputCompleted { .. } => return Ok(()),
12106 ControlEventKind::InputFailed { message, .. } => {
12107 return Err(failure(
12108 NodeFailureCode::BackendOperationFailed,
12109 message,
12110 ));
12111 }
12112 _ => {}
12113 }
12114 }
12115 }
12116 if Instant::now() >= deadline {
12117 return Err(failure(
12118 NodeFailureCode::BackendBusy,
12119 if accepted_after.is_some() {
12120 "input was accepted but did not settle before the bounded deadline"
12121 } else {
12122 "input was not accepted before the bounded deadline"
12123 },
12124 ));
12125 }
12126 sleep(Duration::from_millis(2)).await;
12127 }
12128 }
12129
12130 async fn dispatch_resize_bounded(
12131 &self,
12132 address: &SessionAddress,
12133 size: gate4agent_types::TerminalSize,
12134 ) -> Result<(), NodeFailure> {
12135 let started_after = self.current_sequence();
12136 let command_id = self
12137 .dispatch_bounded(
12138 ControlCommand::Resize {
12139 instance_id: address.session.instance_id,
12140 size,
12141 },
12142 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12143 )
12144 .await?;
12145 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12146 let mut scan_after = started_after;
12147 let mut accepted_after = None;
12148 loop {
12149 let events = self
12150 .history
12151 .lock()
12152 .unwrap_or_else(|poisoned| poisoned.into_inner())
12153 .events
12154 .iter()
12155 .filter(|envelope| envelope.sequence > scan_after)
12156 .cloned()
12157 .collect::<Vec<_>>();
12158 for envelope in events {
12159 scan_after = scan_after.max(envelope.sequence);
12160 let NodeEvent::Control {
12161 address: event_address,
12162 event,
12163 } = envelope.event
12164 else {
12165 continue;
12166 };
12167 if event_address != *address {
12168 continue;
12169 }
12170 if event.command_id == Some(command_id) {
12171 match &event.event {
12172 ControlEventKind::CommandRejected { message } => {
12173 return Err(failure(NodeFailureCode::InvalidRequest, message));
12174 }
12175 ControlEventKind::ResizeRequested {
12176 size: requested_size,
12177 ..
12178 } if *requested_size == size => {
12179 accepted_after = Some(envelope.sequence);
12180 continue;
12181 }
12182 _ => {}
12183 }
12184 }
12185 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12186 match &event.event {
12187 ControlEventKind::Resized { size: settled_size }
12188 if *settled_size == size =>
12189 {
12190 return Ok(());
12191 }
12192 ControlEventKind::ResizeFailed { message } => {
12193 return Err(failure(
12194 NodeFailureCode::BackendOperationFailed,
12195 message,
12196 ));
12197 }
12198 _ => {}
12199 }
12200 }
12201 }
12202 if Instant::now() >= deadline {
12203 return Err(failure(
12204 NodeFailureCode::BackendBusy,
12205 if accepted_after.is_some() {
12206 "resize was accepted but did not settle before the bounded deadline"
12207 } else {
12208 "resize was not accepted before the bounded deadline"
12209 },
12210 ));
12211 }
12212 sleep(Duration::from_millis(2)).await;
12213 }
12214 }
12215
12216 async fn spawn_session(
12217 &self,
12218 workspace_id: WorkspaceId,
12219 provider: AgentId,
12220 mode: SessionMode,
12221 terminal_size: gate4agent_types::TerminalSize,
12222 initial_prompt: Option<String>,
12223 ) -> Result<SessionAddress, NodeFailure> {
12224 if self.managed_worktrees.lock()
12225 .unwrap_or_else(|poisoned| poisoned.into_inner())
12226 .lease_for_workspace(&workspace_id)
12227 .is_some()
12228 {
12229 return Err(failure(
12230 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12231 "managed worktree targets may only be spawned through their lease request",
12232 ));
12233 }
12234 self
12235 .spawn_session_with_deadline(
12236 workspace_id,
12237 provider,
12238 mode,
12239 terminal_size,
12240 initial_prompt,
12241 None,
12242 None,
12243 None,
12244 None,
12245 None,
12246 SpawnRecordPolicy::ProviderIdentityOnly,
12247 None,
12248 &[],
12249 None,
12250 ApprovalLevel::default(),
12256 None,
12257 None,
12258 )
12259 .await
12260 .map(|(session, _runtime_policy)| session)
12261 }
12262
12263 async fn spawn_session_with_deadline(
12264 &self,
12265 workspace_id: WorkspaceId,
12266 provider: AgentId,
12267 mode: SessionMode,
12268 terminal_size: gate4agent_types::TerminalSize,
12269 initial_prompt: Option<String>,
12270 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12271 bundle: Option<ResolvedBundleReceipt>,
12272 context: Option<ResolvedContextPackReceipt>,
12273 managed_authority: Option<ManagedWorktreeLeaseId>,
12274 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12275 record_policy: SpawnRecordPolicy,
12276 deadline: Option<Instant>,
12277 required_capabilities: &[ProviderRuntimeCapability],
12278 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12279 approval_level: ApprovalLevel,
12280 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12281 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12287 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12288 let reserved_lease = self.managed_worktrees.lock()
12289 .unwrap_or_else(|poisoned| poisoned.into_inner())
12290 .lease_for_workspace(&workspace_id);
12291 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12292 (Some(expected), Some(provided)) if expected == provided => {}
12293 (None, None) => {}
12294 _ => {
12295 return Err(failure(
12296 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12297 "spawn lacks exact managed worktree lease authority",
12298 ));
12299 }
12300 }
12301 self.ensure_workspace_environment_profile_compatible(
12305 &workspace_id,
12306 environment_profile.as_ref(),
12307 )?;
12308 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12309 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12310 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12311 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12312 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12313 };
12314 let transport = match mode {
12320 SessionMode::Pty => TransportKind::Pty,
12321 SessionMode::Inline => TransportKind::Pipe,
12322 SessionMode::Acp => TransportKind::Acp,
12323 };
12324 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12325 let acp_transport = self.provider_supports_acp_transport(&provider);
12326 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12327 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12328 NodeFailureCode::UnsupportedSpawnCapability,
12329 "pre-admitted provider runtime no longer satisfies spawn requirements",
12330 ))?;
12331 runtime_policy
12332 } else if let Some(deadline) = deadline {
12333 let remaining = spawn_deadline_remaining(deadline)?;
12334 timeout(
12335 remaining,
12336 self.admit_provider_runtime(&provider, runtime_requirement),
12337 )
12338 .await
12339 .map_err(|_| {
12340 failure(
12341 NodeFailureCode::SpawnDeadlineExceeded,
12342 "provider admission exceeded the spawn deadline",
12343 )
12344 })??
12345 } else {
12346 self.admit_provider_runtime(&provider, runtime_requirement).await?
12347 };
12348 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12349 let runtime_policy =
12361 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12362 if required_capabilities
12363 .iter()
12364 .any(|capability| !runtime_policy.admits(*capability))
12365 {
12366 return Err(failure(
12367 NodeFailureCode::UnsupportedSpawnCapability,
12368 "provider runtime does not admit a required spawn capability",
12369 ));
12370 }
12371 if let Some(deadline) = deadline {
12372 spawn_deadline_remaining(deadline)?;
12373 }
12374 self.ensure_binding_capacity()?;
12375 let working_directory = self.workspace_root(&workspace_id)?;
12376 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12377 let session = SessionKey {
12378 instance_id,
12379 generation: SessionGeneration(1),
12380 };
12381 let address = SessionAddress {
12382 workspace_id: workspace_id.clone(),
12383 session,
12384 };
12385 tracing::info!(
12393 node_id = %self.node_id,
12394 workspace_id = %workspace_id,
12395 provider = %provider,
12396 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12397 session = ?address.session,
12398 "session spawn request accepted",
12399 );
12400 let mut prepared_spawn = SpawnPreparer::prepare(
12401 self,
12402 SpawnPrepareRequest {
12403 instance_id,
12404 address: &address,
12405 provider: &provider,
12406 mode,
12407 environment_profile: environment_profile.as_ref(),
12408 bundle: bundle.as_ref(),
12409 context: context.as_ref(),
12410 managed_authority: managed_authority.as_ref(),
12411 harness_mcp,
12412 approval_level,
12413 network_allowlist: network_allowlist.as_ref(),
12414 browser_profile_id,
12415 },
12416 )?;
12417 let materialization_id = prepared_spawn.materialization_id.take();
12418 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12419 let record_id = self.bind_spawn_session_with_materialization(
12420 &address,
12421 provider.clone(),
12422 mode,
12423 runtime_policy,
12424 record_policy,
12425 environment_profile,
12426 bundle,
12427 context,
12428 materialization_id,
12429 )?;
12430 prepared_spawn.retain_session_guards();
12431 let agent_id = provider;
12435 let register_command_id = match self.dispatch_bounded(
12436 ControlCommand::Register {
12437 instance_id,
12438 agent_id,
12439 transport,
12440 },
12441 dispatch_timeout,
12442 )
12443 .await {
12444 Ok(command_id) => command_id,
12445 Err(error) => {
12446 if let Some(binding) = self.remove_binding(&address) {
12447 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12448 }
12449 if let Some(record_id) = record_id.as_ref() {
12450 self.discard_record(record_id)?;
12451 }
12452 return Err(spawn_dispatch_error(error, deadline));
12453 }
12454 };
12455 let start_timeout = match spawn_dispatch_timeout(deadline) {
12456 Ok(timeout) => timeout,
12457 Err(error) => {
12458 let recovery = self
12459 .rollback_spawn(
12460 &address,
12461 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12462 )
12463 .await;
12464 return match recovery {
12465 Ok(()) => {
12466 if let Some(record_id) = record_id.as_ref() {
12467 self.discard_record(record_id)?;
12468 }
12469 Err(error)
12470 }
12471 Err(recovery_error) => {
12472 if let Some(record_id) = record_id.as_ref() {
12473 self.mark_record_error(
12474 record_id,
12475 "spawn deadline elapsed and registration recovery failed",
12476 )?;
12477 }
12478 Err(recovery_error)
12479 }
12480 };
12481 }
12482 };
12483 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12484 if let Err(recovery_error) = self
12485 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12486 .await
12487 {
12488 return Err(recovery_error);
12489 }
12490 if let Some(record_id) = record_id.as_ref() {
12491 self.discard_record(record_id)?;
12492 }
12493 return Err(start_error);
12494 }
12495 let start_result = self
12496 .dispatch_bounded(
12497 ControlCommand::Start {
12498 instance_id,
12499 runtime_policy,
12500 request: StartRequest {
12501 working_directory,
12502 terminal_size,
12503 initial_prompt,
12504 session_options: None,
12505 approval_level,
12506 },
12507 },
12508 start_timeout,
12509 )
12510 .await;
12511 let start_command_id = match start_result {
12512 Ok(command_id) => command_id,
12513 Err(start_error) => {
12514 let start_error = spawn_dispatch_error(start_error, deadline);
12515 let recovery = self
12516 .rollback_spawn(
12517 &address,
12518 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12519 )
12520 .await;
12521 return match recovery {
12522 Ok(()) => {
12523 if let Some(record_id) = record_id.as_ref() {
12524 self.discard_record(record_id)?;
12525 }
12526 Err(start_error)
12527 }
12528 Err(recovery_error) => {
12529 let diagnostic = format!(
12530 "start failed and registration recovery failed: {}",
12531 recovery_error.message,
12532 );
12533 if let Some(record_id) = record_id.as_ref() {
12534 self.mark_record_error(record_id, &diagnostic)?;
12535 }
12536 Err(failure(recovery_error.code, &diagnostic))
12537 }
12538 };
12539 }
12540 };
12541
12542 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12543 loop {
12544 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12553 .or_else(|| self.take_rejected_command_reason(start_command_id))
12554 {
12555 self.rollback_spawn(
12556 &address,
12557 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12558 ).await.map_err(|error| {
12559 failure(
12560 error.code,
12561 &format!(
12562 "spawn was rejected ({}) and registration recovery failed: {}",
12563 reason.message, error.message
12564 ),
12565 )
12566 })?;
12567 if let Some(record_id) = record_id.as_ref() {
12568 self.discard_record(record_id)?;
12569 }
12570 let code = if reason.unsupported_transport {
12571 NodeFailureCode::UnsupportedTransport
12572 } else {
12573 NodeFailureCode::BackendOperationFailed
12574 };
12575 return Err(failure(code, &reason.message));
12576 }
12577 if let Some(current) = self
12578 .handle
12579 .snapshot()
12580 .sessions
12581 .iter()
12582 .find(|current| current.instance_id == instance_id)
12583 {
12584 if current.generation != session.generation {
12585 let divergence = failure(
12586 NodeFailureCode::StaleGeneration,
12587 "spawn generation diverged",
12588 );
12589 return match self
12590 .rollback_spawn(
12591 &address,
12592 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12593 )
12594 .await
12595 {
12596 Ok(()) => {
12597 if let Some(record_id) = record_id.as_ref() {
12598 self.discard_record(record_id)?;
12599 }
12600 Err(divergence)
12601 }
12602 Err(recovery_error) => {
12603 if let Some(record_id) = record_id.as_ref() {
12604 self.mark_record_error(
12605 record_id,
12606 "spawn generation diverged and recovery failed",
12607 )?;
12608 }
12609 Err(recovery_error)
12610 }
12611 };
12612 }
12613 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12614 prepared_spawn.retain_launch_guards();
12615 return Ok((address, runtime_policy));
12616 }
12617 }
12618 if Instant::now() >= commit_deadline {
12619 self.rollback_spawn(
12620 &address,
12621 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12622 ).await.map_err(|error| {
12623 failure(
12624 error.code,
12625 &format!(
12626 "start did not commit and registration recovery failed: {}",
12627 error.message
12628 ),
12629 )
12630 })?;
12631 if let Some(record_id) = record_id.as_ref() {
12632 self.discard_record(record_id)?;
12633 }
12634 return Err(failure(
12635 if deadline.is_some() {
12636 NodeFailureCode::SpawnDeadlineExceeded
12637 } else {
12638 NodeFailureCode::BackendBusy
12639 },
12640 "start did not commit before the bounded deadline; registration was removed",
12641 ));
12642 }
12643 sleep(Duration::from_millis(2)).await;
12644 }
12645 }
12646
12647 fn effective_spawn_runtime_policy(
12648 &self,
12649 runtime_policy: ProviderRuntimePolicy,
12650 ) -> ProviderRuntimePolicy {
12651 #[cfg(feature = "fixture")]
12652 let runtime_policy = if self.fixture_semantic_hook_policy {
12653 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12654 .expect("monitoring Hook fixture policy is internally valid")
12655 } else {
12656 runtime_policy
12657 };
12658 runtime_policy
12659 }
12660
12661 fn verify_harness_mcp_before_start(
12662 &self,
12663 prepared: Option<&PreparedHarnessMcpSpawn>,
12664 ) -> Result<(), NodeFailure> {
12665 prepared
12666 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12667 .transpose()
12668 .map(|_| ())
12669 .map_err(harness_mcp_failure)
12670 }
12671
12672 async fn rollback_spawn(
12673 &self,
12674 address: &SessionAddress,
12675 commit_timeout: Duration,
12676 ) -> Result<(), NodeFailure> {
12677 self.dispatch_bounded(
12678 ControlCommand::Remove {
12679 instance_id: address.session.instance_id,
12680 },
12681 commit_timeout,
12682 )
12683 .await?;
12684 self.wait_until_removed(address, commit_timeout).await?;
12685 if let Some(binding) = self.remove_binding(address) {
12686 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12687 }
12688 Ok(())
12689 }
12690
12691 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12692 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
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 let removed = self.remove_binding(address);
12702 if let Some(binding) = removed.as_ref() {
12703 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12704 }
12705 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12706 let snapshot = self.managed_worktrees.lock()
12707 .unwrap_or_else(|poisoned| poisoned.into_inner())
12708 .release_session(
12709 &lease_id,
12710 self.incarnation_id,
12711 address.session.instance_id,
12712 address.session.generation,
12713 unix_time_ms(),
12714 );
12715 if self.persist_state().is_err() {
12716 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12717 }
12718 if let Some(snapshot) = snapshot {
12719 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12720 }
12721 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12722 }
12723 Ok(())
12724 }
12725
12726 async fn wait_until_removed(
12772 &self,
12773 address: &SessionAddress,
12774 commit_timeout: Duration,
12775 ) -> Result<(), NodeFailure> {
12776 let deadline = Instant::now() + commit_timeout;
12777 let mut next_dispatch = Instant::now();
12778 loop {
12779 let snapshot = self.handle.snapshot();
12780 let status = snapshot
12781 .sessions
12782 .iter()
12783 .find(|session| session.instance_id == address.session.instance_id)
12784 .map(|session| session.status.clone());
12785 let Some(status) = status else {
12786 return Ok(());
12787 };
12788 let now = Instant::now();
12789 if now >= deadline {
12790 return Err(failure(
12791 NodeFailureCode::BackendBusy,
12792 "remove did not commit before the bounded deadline; session binding was retained",
12793 ));
12794 }
12795 if status.allows_remove() && now >= next_dispatch {
12796 let _ = self.dispatch(ControlCommand::Remove {
12797 instance_id: address.session.instance_id,
12798 });
12799 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12800 }
12801 sleep(Duration::from_millis(2)).await;
12802 }
12803 }
12804
12805 fn resync(&self, after_sequence: u64) -> NodeResponse {
12806 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12807 let event_sequence = history.last_sequence;
12808 let oldest_available_sequence = history.replay_floor_sequence;
12809 let events = history
12810 .events
12811 .iter()
12812 .filter(|event| event.sequence > after_sequence)
12813 .cloned()
12814 .collect();
12815 drop(history);
12816 NodeResponse::Resync {
12817 event_sequence,
12818 oldest_available_sequence,
12819 snapshot: self.snapshot(),
12820 events,
12821 }
12822 }
12823}
12824
12825fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12830 match kind {
12831 AgentStreamChunkKindV1::Text { .. } => "text",
12832 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12833 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12834 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12835 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12836 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12837 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12838 }
12839}
12840
12841async fn accept_connections(
12855 endpoint: &str,
12856 call_home: Option<std::net::SocketAddr>,
12857 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12858 shared: Arc<NodeShared>,
12859) -> Result<(), NodeServerError> {
12860 let mut connections = JoinSet::new();
12861 let result = match call_home {
12862 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12863 Some(relay) => {
12864 let listener =
12865 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12866 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12867 tokio::pin!(listener);
12868 tokio::pin!(dialler);
12869 tokio::select! {
12870 result = &mut listener => result,
12871 result = &mut dialler => result,
12872 }
12873 }
12874 };
12875 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12876 while !connections.is_empty() {
12877 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12878 if remaining.is_zero()
12879 || timeout(remaining, connections.join_next()).await.is_err()
12880 {
12881 break;
12882 }
12883 }
12884 connections.abort_all();
12885 while connections.join_next().await.is_some() {}
12886 result
12887}
12888
12889const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12893
12894async fn call_home_loop(
12908 relay: std::net::SocketAddr,
12909 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12910 shared: Arc<NodeShared>,
12911) -> Result<(), NodeServerError> {
12912 let mut failures = 0_usize;
12913 loop {
12914 if shared.shutdown.load(Ordering::Acquire) {
12915 return Ok(());
12916 }
12917 let permit = tokio::select! {
12918 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12919 permit.map_err(|_| NodeServerError::PreauthClosed)?
12920 }
12921 _ = shared.shutdown_notify.notified() => return Ok(()),
12922 };
12923 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
12924 Ok(()) => {
12925 failures = 0;
12929 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12930 }
12931 Err(error) => {
12932 failures = failures.saturating_add(1);
12933 tracing::warn!(
12934 node_id = %shared.node_id,
12935 %relay,
12936 cause = %error,
12937 attempt = failures,
12938 "call-home connection failed",
12939 );
12940 let backoff = CALL_HOME_BACKOFFS_MS
12941 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12942 tokio::select! {
12943 _ = sleep(Duration::from_millis(backoff)) => {}
12944 _ = shared.shutdown_notify.notified() => return Ok(()),
12945 }
12946 }
12947 }
12948 }
12949}
12950
12951async fn dial_relay_and_serve(
12953 relay: std::net::SocketAddr,
12954 identity: Option<&hatchery_enroll::NodeIdentity>,
12955 shared: Arc<NodeShared>,
12956 preauth_permit: OwnedSemaphorePermit,
12957) -> Result<(), NodeServerError> {
12958 let mut stream = tokio::net::TcpStream::connect(relay)
12959 .await
12960 .map_err(NodeServerError::CallHomeTransport)?;
12961 let _ = stream.set_nodelay(true);
12965 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12966 .await
12967 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12968 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12969 let mut session_token = None;
12970 if let Some(identity) = identity {
12971 let mut magic = [0_u8; 4];
12975 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
12976 .await
12977 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
12978 .map_err(NodeServerError::CallHomeTransport)?;
12979 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
12980 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
12981 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
12982 .await
12983 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
12984 .map_err(NodeServerError::CallHomeTransport)?;
12985 let (answer, token) = identity
12986 .answer_challenge(shared.node_id.as_str(), &frame)
12987 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12988 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
12989 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
12990 session_token = Some(token);
12991 }
12992 }
12993 serve_connection(stream, shared, session_token, preauth_permit).await
12994}
12995
12996async fn accept_connections_inner(
12997 endpoint: &str,
12998 shared: Arc<NodeShared>,
12999 connections: &mut JoinSet<Result<(), NodeServerError>>,
13000) -> Result<(), NodeServerError> {
13001 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13002 loop {
13003 while connections.try_join_next().is_some() {}
13004 if shared.shutdown.load(Ordering::Acquire) {
13005 return Ok(());
13006 }
13007 let preauth_permit = tokio::select! {
13008 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13009 permit.map_err(|_| NodeServerError::PreauthClosed)?
13010 }
13011 _ = shared.shutdown_notify.notified() => return Ok(()),
13012 };
13013 let server = tokio::select! {
13014 result = listener.accept() => result?,
13015 _ = shared.shutdown_notify.notified() => return Ok(()),
13016 };
13017 let connection_shared = Arc::clone(&shared);
13018 connections.spawn(async move {
13019 serve_connection(server, connection_shared, None, preauth_permit).await
13020 });
13021 if shared.shutdown.load(Ordering::Acquire) {
13022 return Ok(());
13023 }
13024 }
13025}
13026
13027async fn serve_connection<S>(
13035 mut pipe: S,
13036 shared: Arc<NodeShared>,
13037 session_token: Option<String>,
13038 preauth_permit: OwnedSemaphorePermit,
13039) -> Result<(), NodeServerError>
13040where
13041 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13042{
13043 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13045 let hello_frame = timeout(
13046 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13047 read_json_frame_limited_body_timeout(
13048 &mut pipe,
13049 MAX_NODE_HELLO_FRAME_BYTES,
13050 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13051 ),
13052 )
13053 .await
13054 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13055 let ClientFrame::Hello(hello) = hello_frame else {
13056 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13057 };
13058 if hello.build_stamp != BUILD_STAMP {
13059 return Err(NodeServerError::Handshake(format!(
13060 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13061 hello.build_stamp,
13062 )));
13063 }
13064 let compatibility = match hello.compatibility.as_ref() {
13065 Some(offer) => {
13066 let mut selected = node_compatibility_support(&shared)?
13067 .negotiate(offer)
13068 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13069 project_negotiated_provider_ids(&mut selected);
13070 Some(selected)
13071 }
13072 None => None,
13073 };
13074 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13075 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13076 (Some(offer), Some(selected)) => negotiated_auth_proof(
13077 auth_token,
13078 AuthDirection::Server,
13079 hello.role,
13080 &hello.client_nonce,
13081 &server_nonce,
13082 offer,
13083 selected,
13084 ),
13085 (None, None) => auth_proof(
13086 auth_token,
13087 AuthDirection::Server,
13088 hello.role,
13089 &hello.client_nonce,
13090 &server_nonce,
13091 ),
13092 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13093 }
13094 .map_err(NodeServerError::Authentication)?;
13095 write_json_frame_limited(
13096 &mut pipe,
13097 &ServerFrame::Challenge(ServerChallenge {
13098 build_stamp: BUILD_STAMP.to_owned(),
13099 server_nonce,
13100 server_proof,
13101 compatibility: compatibility.clone(),
13102 }),
13103 MAX_NODE_HELLO_FRAME_BYTES,
13104 )
13105 .await?;
13106 let authentication = timeout(
13107 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13108 read_json_frame_limited_body_timeout(
13109 &mut pipe,
13110 MAX_NODE_HELLO_FRAME_BYTES,
13111 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13112 ),
13113 )
13114 .await
13115 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13116 let ClientFrame::Authenticate(authentication) = authentication else {
13117 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13118 };
13119 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13120 (Some(offer), Some(selected)) => negotiated_auth_proof(
13121 auth_token,
13122 AuthDirection::Client,
13123 hello.role,
13124 &hello.client_nonce,
13125 &server_nonce,
13126 offer,
13127 selected,
13128 ),
13129 (None, None) => auth_proof(
13130 auth_token,
13131 AuthDirection::Client,
13132 hello.role,
13133 &hello.client_nonce,
13134 &server_nonce,
13135 ),
13136 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13137 }
13138 .map_err(NodeServerError::Authentication)?;
13139 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13140 return Err(NodeServerError::Handshake("access denied".to_owned()));
13141 }
13142 let selected_capabilities = compatibility
13143 .as_ref()
13144 .map(|selected| selected.capabilities.clone())
13145 .unwrap_or_default();
13146 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13147 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13148 });
13149 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13150 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13151 });
13152 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13153 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13154 });
13155 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13156 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13157 });
13158 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13159 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13160 });
13161 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13162 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13163 }) && selected_capabilities.iter().any(|capability| {
13164 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13165 });
13166 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13167 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13168 });
13169 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13170 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13171 });
13172 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13173 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13174 });
13175 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13176 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13177 });
13178 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13179 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13180 });
13181 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13182 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13183 });
13184 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13185 .try_acquire_owned()
13186 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13187 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13188 let _release_guard = ControllerReleaseGuard {
13189 shared: Arc::clone(&shared),
13190 connection_id,
13191 };
13192 drop(preauth_permit);
13193 let _authenticated_permit = authenticated_permit;
13194 let mut event_rx = shared.event_tx.subscribe();
13195 let mut terminal_event_rx = terminal_event_subscription(
13196 &shared,
13197 include_terminal_frame_events,
13198 );
13199 let mut agent_stream_event_rx = agent_stream_event_subscription(
13200 &shared,
13201 include_agent_stream_events,
13202 );
13203 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13204 &shared,
13205 include_harness_mcp_proxy,
13206 );
13207 write_json_frame(
13208 &mut pipe,
13209 &ServerFrame::Hello(NodeHello {
13210 build_stamp: BUILD_STAMP.to_owned(),
13211 incarnation_id: shared.incarnation_id,
13212 connection_id,
13213 role: hello.role,
13214 event_sequence: shared.current_sequence(),
13215 controller: shared.controller_state(),
13216 snapshot: snapshot_for_wire(
13217 &shared,
13218 include_provider_runtime_status,
13219 include_open_provider_ids,
13220 include_spawn_profiles,
13221 include_managed_worktrees,
13222 include_child_environment_profiles,
13223 include_session_bundles,
13224 include_history_context_packs,
13225 include_agent_progress,
13226 include_session_task_correlation,
13227 ),
13228 compatibility,
13229 }),
13230 )
13231 .await?;
13232 let (mut reader, mut writer) = tokio::io::split(pipe);
13233 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13234 let reader_task = tokio::spawn(async move {
13235 loop {
13236 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13237 &mut reader,
13238 MAX_NODE_CLIENT_FRAME_BYTES,
13239 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13240 )
13241 .await;
13242 let terminal = frame.is_err();
13243 if frame_tx.send(frame).await.is_err() || terminal {
13244 break;
13245 }
13246 }
13247 });
13248 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13249 let mut pending_events = Vec::new();
13250 let mut resync_required = false;
13251 let mut discard_events_through = 0_u64;
13252 loop {
13253 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13258 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13259 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13260 for _ in 0..durable_drain_budget {
13261 match event_rx.try_recv() {
13262 Ok(event) => queue_connection_event(
13263 &mut pending_events,
13264 event,
13265 discard_events_through,
13266 ),
13267 Err(broadcast::error::TryRecvError::Empty) => break,
13268 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13269 resync_required = true;
13270 break;
13271 }
13272 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13273 }
13274 }
13275 if let Some(receiver) = terminal_event_rx.as_mut() {
13276 match receiver.try_recv() {
13277 Ok(events) => queue_connection_event_batch(
13278 &mut pending_events,
13279 &events,
13280 discard_events_through,
13281 ),
13282 Err(broadcast::error::TryRecvError::Empty) => {}
13283 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13284 resync_required = true;
13285 }
13286 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13287 }
13288 }
13289 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13290 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13300 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13301 for _ in 0..agent_stream_drain_budget {
13302 match receiver.try_recv() {
13303 Ok(events) => queue_connection_event_batch(
13304 &mut pending_events,
13305 &events,
13306 discard_events_through,
13307 ),
13308 Err(broadcast::error::TryRecvError::Empty) => break,
13309 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13310 tracing::warn!(
13311 connection_id,
13312 dropped,
13313 "agent stream event subscription lagged; forcing a resync",
13314 );
13315 resync_required = true;
13316 break;
13317 }
13318 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13319 }
13320 }
13321 }
13322 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13323 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13329 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13330 for _ in 0..harness_mcp_proxy_drain_budget {
13331 match receiver.try_recv() {
13332 Ok(events) => queue_connection_event_batch(
13333 &mut pending_events,
13334 &events,
13335 discard_events_through,
13336 ),
13337 Err(broadcast::error::TryRecvError::Empty) => break,
13338 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13339 tracing::warn!(
13340 connection_id,
13341 dropped,
13342 "harness MCP proxy event subscription lagged; forcing a resync",
13343 );
13344 resync_required = true;
13345 break;
13346 }
13347 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13348 }
13349 }
13350 }
13351 if resync_required {
13352 let event = resync_required_event(&shared);
13353 apply_connection_resync_watermark(
13354 &mut pending_events,
13355 &mut discard_events_through,
13356 event.sequence,
13357 );
13358 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13359 resync_required = false;
13360 continue;
13361 }
13362 if !pending_events.is_empty() {
13363 pending_events.sort_unstable_by_key(|event| event.sequence);
13364 let burst_len = connection_event_burst_len(pending_events.len());
13365 shared
13366 .connection_events_sent
13367 .fetch_add(burst_len as u64, Ordering::Relaxed);
13368 for event in pending_events.drain(..burst_len) {
13369 let event = if include_managed_worktrees {
13370 Some(event)
13371 } else {
13372 project_event_without_managed_worktrees(event)
13373 };
13374 let event = event.and_then(|event| if include_open_provider_ids {
13375 Some(event)
13376 } else {
13377 project_event_legacy_provider_ids(&shared, event)
13378 });
13379 let event = event.map(|event| if include_child_environment_profiles {
13380 event
13381 } else {
13382 project_event_without_child_environment_profile(event)
13383 });
13384 let event = event.map(|event| if include_session_bundles {
13385 event
13386 } else {
13387 project_event_without_session_bundle(event)
13388 });
13389 let event = event.map(|event| {
13390 project_event_history_for_wire(event, include_history_context_packs)
13391 });
13392 let event = event.map(|event| if include_history_context_packs {
13393 event
13394 } else {
13395 project_event_without_context_pack(event)
13396 });
13397 let event = event.map(|event| if include_session_task_correlation {
13398 event
13399 } else {
13400 project_event_without_session_task_binding(event)
13401 });
13402 let exact_harness_controller = hello.role == ClientRole::Operator
13403 && shared.controller_state().is_some_and(|controller| {
13404 controller.connection_id == connection_id
13405 });
13406 let event = event.and_then(|event| {
13407 if include_harness_mcp_proxy && exact_harness_controller {
13408 Some(event)
13409 } else {
13410 project_event_without_harness_mcp_proxy(event)
13411 }
13412 });
13413 if let Some(event) = event {
13414 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13415 }
13416 }
13417 }
13418 tokio::select! {
13419 biased;
13420 frame = frame_rx.recv() => {
13421 let frame = match frame {
13422 Some(frame) => frame,
13423 None => break,
13424 };
13425 let frame = match frame {
13426 Ok(frame) => frame,
13427 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13428 Err(error) => return Err(error.into()),
13429 };
13430 let ClientFrame::Request(request) = frame else {
13431 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13432 };
13433 #[cfg(feature = "fixture")]
13434 let probe_spawn_managed_worktree_v2 = matches!(
13435 &request.request,
13436 NodeRequest::SpawnManagedWorktreeV2 { .. }
13437 );
13438 let requires_open_provider_ids =
13439 request_requires_open_provider_ids(&shared, &request.request);
13440 let requires_child_environment_profile =
13441 request_requires_child_environment_profile(&shared, &request.request);
13442 let requires_session_bundle =
13443 request_requires_session_bundle(&shared, &request.request);
13444 let requires_history_context_pack =
13445 request_requires_history_context_pack(&shared, &request.request);
13446 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13447 ResponseEnvelope {
13448 request_id: request.request_id,
13449 result: Err(failure(
13450 NodeFailureCode::UnsupportedCapability,
13451 "open provider IDs were not negotiated",
13452 )),
13453 }
13454 } else if requires_child_environment_profile
13455 && !include_child_environment_profiles
13456 {
13457 ResponseEnvelope {
13458 request_id: request.request_id,
13459 result: Err(failure(
13460 NodeFailureCode::UnsupportedCapability,
13461 "child environment profiles were not negotiated",
13462 )),
13463 }
13464 } else if requires_session_bundle && !include_session_bundles {
13465 ResponseEnvelope {
13466 request_id: request.request_id,
13467 result: Err(failure(
13468 NodeFailureCode::UnsupportedCapability,
13469 "session bundles were not negotiated",
13470 )),
13471 }
13472 } else if requires_history_context_pack && !include_history_context_packs {
13473 ResponseEnvelope {
13474 request_id: request.request_id,
13475 result: Err(failure(
13476 NodeFailureCode::UnsupportedCapability,
13477 "history context packs were not negotiated",
13478 )),
13479 }
13480 } else if request_uses_unnegotiated_capability(
13481 &request.request,
13482 &selected_capabilities,
13483 )
13484 {
13485 ResponseEnvelope {
13486 request_id: request.request_id,
13487 result: Err(failure(
13488 NodeFailureCode::UnsupportedCapability,
13489 "request capability was not negotiated",
13490 )),
13491 }
13492 } else {
13493 process_request(&shared, connection_id, hello.role, request).await
13494 };
13495 #[cfg(feature = "fixture")]
13496 if probe_spawn_managed_worktree_v2 {
13497 shared
13498 .fixture_spawn_managed_worktree_v2_failure_probe
13499 .record(reply.result.as_ref().err().map(|failure| failure.code));
13500 }
13501 if !include_provider_runtime_status {
13502 clear_response_provider_runtime_status(&mut reply);
13503 }
13504 if !include_managed_worktrees {
13505 project_response_without_managed_worktrees(&mut reply);
13506 }
13507 if !include_open_provider_ids {
13508 project_response_legacy_provider_ids(&shared, &mut reply);
13509 }
13510 if !include_child_environment_profiles {
13511 project_response_without_child_environment_profile(&mut reply);
13512 }
13513 if !include_session_bundles {
13514 project_response_without_session_bundle(&mut reply);
13515 }
13516 project_response_history_for_wire(
13517 &mut reply,
13518 include_history_context_packs,
13519 );
13520 if !include_history_context_packs {
13521 project_response_without_context_pack(&mut reply);
13522 }
13523 if !include_agent_progress {
13524 project_response_without_agent_progress(&mut reply);
13525 }
13526 if !include_session_task_correlation {
13527 project_response_without_session_task_binding(&mut reply);
13528 }
13529 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13530 }
13531 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13532 event = event_rx.recv() => {
13533 match event {
13534 Ok(event) => queue_connection_event(
13535 &mut pending_events,
13536 event,
13537 discard_events_through,
13538 ),
13539 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13540 Err(broadcast::error::RecvError::Closed) => break,
13541 }
13542 }
13543 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13544 match events {
13545 Ok(events) => queue_connection_event_batch(
13546 &mut pending_events,
13547 &events,
13548 discard_events_through,
13549 ),
13550 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13551 Err(broadcast::error::RecvError::Closed) => break,
13552 }
13553 }
13554 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13555 match events {
13556 Ok(events) => queue_connection_event_batch(
13557 &mut pending_events,
13558 &events,
13559 discard_events_through,
13560 ),
13561 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13562 Err(broadcast::error::RecvError::Closed) => break,
13563 }
13564 }
13565 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13566 match events {
13567 Ok(events) => queue_connection_event_batch(
13568 &mut pending_events,
13569 &events,
13570 discard_events_through,
13571 ),
13572 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13573 Err(broadcast::error::RecvError::Closed) => break,
13574 }
13575 }
13576 _ = shared.shutdown_notify.notified() => break,
13577 }
13578 }
13579 Ok(())
13580}
13581
13582async fn receive_terminal_event_batch(
13583 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13584) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13585 match receiver {
13586 Some(receiver) => receiver.recv().await,
13587 None => std::future::pending().await,
13588 }
13589}
13590
13591fn terminal_event_subscription(
13592 shared: &NodeShared,
13593 enabled: bool,
13594) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13595 enabled.then(|| shared.terminal_event_tx.subscribe())
13596}
13597
13598fn agent_stream_event_subscription(
13602 shared: &NodeShared,
13603 enabled: bool,
13604) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13605 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13606}
13607
13608fn harness_mcp_proxy_event_subscription(
13613 shared: &NodeShared,
13614 enabled: bool,
13615) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13616 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13617}
13618
13619fn queue_connection_event(
13620 pending: &mut Vec<NodeEventEnvelope>,
13621 event: NodeEventEnvelope,
13622 discard_through: u64,
13623) {
13624 if event.sequence <= discard_through
13625 && !event.event.requires_harness_mcp_proxy_capability()
13626 {
13627 return;
13628 }
13629 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13630 if let Some(index) = pending.iter().position(|current| {
13631 matches!(
13632 ¤t.event,
13633 NodeEvent::TerminalFrame {
13634 address: current_address,
13635 ..
13636 } if current_address == address
13637 )
13638 }) {
13639 pending.remove(index);
13640 }
13641 }
13642 pending.push(event);
13643}
13644
13645fn project_event_without_harness_mcp_proxy(
13646 envelope: NodeEventEnvelope,
13647) -> Option<NodeEventEnvelope> {
13648 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13649}
13650
13651fn queue_connection_event_batch(
13652 pending: &mut Vec<NodeEventEnvelope>,
13653 events: &[NodeEventEnvelope],
13654 discard_through: u64,
13655) {
13656 for event in events {
13657 queue_connection_event(pending, event.clone(), discard_through);
13658 }
13659}
13660
13661fn apply_connection_resync_watermark(
13662 pending: &mut Vec<NodeEventEnvelope>,
13663 discard_through: &mut u64,
13664 marker_sequence: u64,
13665) {
13666 *discard_through = (*discard_through).max(marker_sequence);
13667 pending.retain(|event| event.sequence > *discard_through);
13668}
13669
13670fn connection_event_burst_len(pending_len: usize) -> usize {
13671 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13672}
13673
13674fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13675 let history = shared
13676 .history
13677 .lock()
13678 .unwrap_or_else(|poisoned| poisoned.into_inner());
13679 let sequence = history.last_sequence;
13680 let oldest_available_sequence = history.replay_floor_sequence;
13681 NodeEventEnvelope {
13682 sequence,
13683 event: NodeEvent::ResyncRequired {
13684 oldest_available_sequence,
13685 },
13686 }
13687}
13688
13689fn node_compatibility_support(
13690 shared: &NodeShared,
13691) -> Result<NodeCompatibilitySupport, NodeServerError> {
13692 let mut support = node_compatibility_support_for_manifest(
13693 &shared.provider_contracts,
13694 &shared.provider_adapter_contracts,
13695 )?;
13696 if shared
13697 .delivery_store
13698 .lock()
13699 .unwrap_or_else(|poisoned| poisoned.into_inner())
13700 .is_some()
13701 {
13702 support.capabilities.push(
13703 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13704 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13705 );
13706 }
13707 if shared.harness_mcp_registry.is_some() {
13708 support.capabilities.push(
13709 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13710 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13711 );
13712 }
13713 Ok(support)
13714}
13715
13716fn node_compatibility_support_for_manifest(
13717 provider_contracts: &[ProviderContractSupport],
13718 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13719) -> Result<NodeCompatibilitySupport, NodeServerError> {
13720 Ok(NodeCompatibilitySupport {
13721 build_stamp: BUILD_STAMP.to_owned(),
13722 capabilities: baseline_capabilities()?,
13723 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13724 path_semantics: platform::path_semantics(),
13725 local_transport: platform::local_transport(),
13726 state_schema: StateSchemaSupport {
13727 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13728 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13729 },
13730 provider_contracts: provider_contracts.to_vec(),
13731 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13732 })
13733}
13734
13735fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13736 let capabilities = [
13737 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13738 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13739 NODE_REPOSITORY_PATH_CAPABILITY,
13740 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13741 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13742 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13743 NODE_GIT_READ_CAPABILITY,
13744 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13745 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13746 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13747 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13748 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13749 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13750 NODE_ACP_CONTROL_CAPABILITY,
13751 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13752 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13753 NODE_WORKTREE_SELECTION_CAPABILITY,
13754 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13755 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13756 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13757 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13758 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13759 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13760 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13761 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13762 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13763 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13764 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13765 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13766 ]
13767 .into_iter()
13768 .map(|capability| {
13769 CapabilityId::new(capability)
13770 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13771 })
13772 .collect::<Result<Vec<_>, _>>()?;
13773 #[cfg(windows)]
13774 let capabilities = {
13775 let mut capabilities = capabilities;
13776 capabilities.push(
13777 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13778 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13779 );
13780 capabilities
13781 };
13782 Ok(capabilities)
13783}
13784
13785fn request_uses_unnegotiated_capability(
13786 request: &NodeRequest,
13787 selected_capabilities: &[CapabilityId],
13788) -> bool {
13789 let selected = |required: &str| {
13790 selected_capabilities
13791 .iter()
13792 .any(|capability| capability.as_str() == required)
13793 };
13794 request
13795 .required_capability()
13796 .is_some_and(|required| !selected(required))
13797 || (request.requires_worktree_selection_capability()
13798 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13799 || (request.requires_spawn_spec_defaults_overrides_capability()
13800 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13801 || (request.requires_spawn_profile_revision_capability()
13802 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13803 || (request.requires_child_environment_profile_capability()
13804 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13805 || (request.requires_session_bundle_materialization_capability()
13806 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13807 || (request.requires_history_context_pack_capability()
13808 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13809}
13810
13811fn snapshot_for_wire(
13812 shared: &NodeShared,
13813 include_provider_runtime_status: bool,
13814 include_open_provider_ids: bool,
13815 include_spawn_profiles: bool,
13816 include_managed_worktrees: bool,
13817 include_child_environment_profiles: bool,
13818 include_session_bundles: bool,
13819 include_history_context_packs: bool,
13820 include_agent_progress: bool,
13821 include_session_task_correlation: bool,
13822) -> NodeSnapshot {
13823 let mut snapshot = shared.snapshot();
13824 if !include_provider_runtime_status {
13825 snapshot.provider_runtime_statuses.clear();
13826 }
13827 if !include_open_provider_ids {
13828 project_snapshot_legacy_provider_ids(&mut snapshot);
13829 }
13830 if !include_managed_worktrees {
13831 snapshot.managed_worktrees.clear();
13832 for workspace in &mut snapshot.workspaces {
13833 workspace.managed_worktree_profiles = None;
13834 }
13835 }
13836 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13837 if !include_spawn_profiles {
13838 inventory.spawn_profiles = None;
13839 }
13840 if !include_session_bundles {
13841 inventory.bundles = None;
13842 }
13843 if inventory.spawn_profiles.is_none()
13844 && inventory.bundles.is_none()
13845 && inventory.network_allowlists.is_none()
13846 {
13847 snapshot.launch_inventory = None;
13848 }
13849 }
13850 if !include_child_environment_profiles {
13851 clear_snapshot_child_environment_profiles(&mut snapshot);
13852 }
13853 if !include_session_bundles {
13854 clear_snapshot_session_bundles(&mut snapshot);
13855 }
13856 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13857 if !include_history_context_packs {
13858 clear_snapshot_context_packs(&mut snapshot);
13859 }
13860 if !include_agent_progress {
13861 snapshot.agent_progress.clear();
13862 }
13863 if !include_session_task_correlation {
13864 clear_snapshot_session_task_bindings(&mut snapshot);
13865 }
13866 snapshot
13867}
13868
13869fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13870 for record in &mut snapshot.session_records {
13871 record.task_binding = None;
13872 }
13873}
13874
13875fn project_event_without_session_task_binding(
13876 mut envelope: NodeEventEnvelope,
13877) -> NodeEventEnvelope {
13878 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13879 record.task_binding = None;
13880 }
13881 envelope
13882}
13883
13884fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13885 let Ok(response) = reply.result.as_mut() else { return };
13886 match response {
13887 NodeResponse::Snapshot { snapshot, .. } => {
13888 clear_snapshot_session_task_bindings(snapshot);
13889 }
13890 NodeResponse::Resync { snapshot, events, .. } => {
13891 clear_snapshot_session_task_bindings(snapshot);
13892 for event in events {
13893 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13894 record.task_binding = None;
13895 }
13896 }
13897 }
13898 NodeResponse::SessionRecordUpdated { record }
13899 | NodeResponse::ProviderSessionIndexed { record }
13900 | NodeResponse::NativeSessionIndexed { record, .. }
13901 | NodeResponse::SessionRecordResumed { record, .. } => {
13902 record.task_binding = None;
13903 }
13904 _ => {}
13905 }
13906}
13907
13908fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13909 let Ok(response) = reply.result.as_mut() else { return };
13910 match response {
13911 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13912 snapshot.agent_progress.clear();
13913 }
13914 _ => {}
13915 }
13916}
13917
13918fn project_event_without_managed_worktrees(
13919 mut envelope: NodeEventEnvelope,
13920) -> Option<NodeEventEnvelope> {
13921 if matches!(
13922 envelope.event,
13923 NodeEvent::ManagedWorktreeUpserted { .. }
13924 | NodeEvent::ManagedWorktreeRemoved { .. }
13925 ) {
13926 return None;
13927 }
13928 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13929 workspace.managed_worktree_profiles = None;
13930 }
13931 Some(envelope)
13932}
13933
13934fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13935 if matches!(
13936 reply.result,
13937 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13938 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13939 ) {
13940 reply.result = Err(failure(
13941 NodeFailureCode::UnsupportedCapability,
13942 "managed worktree capability was not negotiated",
13943 ));
13944 return;
13945 }
13946 let Ok(response) = reply.result.as_mut() else { return };
13947 match response {
13948 NodeResponse::Snapshot { snapshot, .. } => {
13949 snapshot.managed_worktrees.clear();
13950 for workspace in &mut snapshot.workspaces {
13951 workspace.managed_worktree_profiles = None;
13952 }
13953 }
13954 NodeResponse::Resync { snapshot, events, .. } => {
13955 snapshot.managed_worktrees.clear();
13956 for workspace in &mut snapshot.workspaces {
13957 workspace.managed_worktree_profiles = None;
13958 }
13959 *events = std::mem::take(events)
13960 .into_iter()
13961 .filter_map(project_event_without_managed_worktrees)
13962 .collect();
13963 }
13964 NodeResponse::WorkspaceRegistered { workspace }
13965 | NodeResponse::StandaloneWorkspaceCreated { workspace }
13966 | NodeResponse::WorktreeCreated { workspace, .. } => {
13967 workspace.managed_worktree_profiles = None;
13968 }
13969 NodeResponse::WorkspaceInspected { inspection } => {
13970 inspection.git.managed_worktree = None;
13971 }
13972 _ => {}
13973 }
13974}
13975
13976fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13977 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13978 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13979 });
13980 if includes_open_provider_ids {
13981 return;
13982 }
13983 compatibility
13984 .provider_contracts
13985 .retain(|contract| provider_id_is_legacy(&contract.provider));
13986 compatibility
13987 .provider_adapter_contracts
13988 .retain(|contract| provider_id_is_legacy(&contract.provider));
13989}
13990
13991fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13992 if let Some(spec) = match request {
13993 NodeRequest::SpawnSpec { spec }
13994 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13995 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13996 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13997 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13998 _ => None,
13999 } {
14000 return shared
14001 .resolve_spawn_spec(spec)
14002 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14003 }
14004 request_requires_open_provider_ids_with(
14005 request,
14006 |session| shared.validate_address(session).ok(),
14007 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14008 )
14009}
14010
14011fn request_requires_child_environment_profile(
14012 shared: &NodeShared,
14013 request: &NodeRequest,
14014) -> bool {
14015 if let Some(spec) = match request {
14016 NodeRequest::SpawnSpec { spec }
14017 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14018 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14019 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14020 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14021 _ => None,
14022 } {
14023 return shared
14024 .resolve_spawn_spec(spec)
14025 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14026 }
14027 match request {
14028 NodeRequest::Resume { session, .. }
14029 | NodeRequest::Prompt { session, .. }
14030 | NodeRequest::Paste { session, .. }
14031 | NodeRequest::Input { session, .. }
14032 | NodeRequest::TerminalBytes { session, .. }
14033 | NodeRequest::TerminalControl { session, .. }
14034 | NodeRequest::Resize { session, .. }
14035 | NodeRequest::Interrupt { session }
14036 | NodeRequest::Stop { session, .. }
14037 | NodeRequest::Remove { session }
14038 | NodeRequest::ResolveInteraction { session, .. }
14039 | NodeRequest::SetSessionMode { session, .. }
14040 | NodeRequest::SetSessionConfigOption { session, .. }
14041 | NodeRequest::SetSessionModel { session, .. }
14042 | NodeRequest::DiscoverHistory { session, .. }
14043 | NodeRequest::LoadHistory { session, .. }
14044 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14045 | NodeRequest::ExportContextPack { session } => shared
14046 .session_bindings
14047 .lock()
14048 .unwrap_or_else(|poisoned| poisoned.into_inner())
14049 .get(&session.session.instance_id)
14050 .filter(|binding| {
14051 binding.workspace_id == session.workspace_id
14052 && binding.generation == session.session.generation
14053 })
14054 .map_or(true, |binding| binding.environment_profile.is_some()),
14055 NodeRequest::RenameSessionRecord { record_id, .. }
14056 | NodeRequest::SetSessionTask { record_id, .. }
14057 | NodeRequest::ResumeSessionRecord { record_id, .. }
14058 | NodeRequest::ForgetSessionRecord { record_id } => shared
14059 .record(record_id)
14060 .map_or(true, |record| record.environment_profile.is_some()),
14061 NodeRequest::ArmHarnessMcpReservation { .. }
14062 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14063 | NodeRequest::ActivateHarnessMcpReservation { .. }
14064 | NodeRequest::AbortHarnessMcpReservation { .. }
14065 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14066 | NodeRequest::RejectHarnessMcpCall { .. }
14067 | NodeRequest::Snapshot
14068 | NodeRequest::Resync { .. }
14069 | NodeRequest::BeginDeliveryStage { .. }
14070 | NodeRequest::PutDeliveryBlobChunk { .. }
14071 | NodeRequest::CommitDeliveryStage { .. }
14072 | NodeRequest::AbortDeliveryStage { .. }
14073 | NodeRequest::BrowseHostDirectories { .. }
14074 | NodeRequest::InspectWorkspace { .. }
14075 | NodeRequest::ReadWorkspaceFile { .. }
14076 | NodeRequest::WriteWorkspaceFile { .. }
14077 | NodeRequest::CreateWorkspaceFile { .. }
14078 | NodeRequest::CreateWorkspaceDirectory { .. }
14079 | NodeRequest::ReadGitHistory { .. }
14080 | NodeRequest::ReadGitDiff { .. }
14081 | NodeRequest::AcquireController { .. }
14082 | NodeRequest::ReleaseController
14083 | NodeRequest::Spawn { .. }
14084 | NodeRequest::SpawnSpec { .. }
14085 | NodeRequest::RegisterWorkspace { .. }
14086 | NodeRequest::CreateStandaloneWorkspace { .. }
14087 | NodeRequest::UnregisterWorkspace { .. }
14088 | NodeRequest::CreateWorktree { .. }
14089 | NodeRequest::RemoveWorktree { .. }
14090 | NodeRequest::SpawnManagedWorktree { .. }
14091 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14092 | NodeRequest::CleanupManagedWorktree { .. }
14093 | NodeRequest::IndexProviderSession { .. }
14094 | NodeRequest::IndexNativeSession { .. }
14095 | NodeRequest::CatalogNativeSessions { .. }
14096 | NodeRequest::PageNativeSessions { .. }
14097 | NodeRequest::PreviewNativeSession { .. }
14098 | NodeRequest::PreviewSessionRecord { .. }
14099 | NodeRequest::ForgetContextPack { .. }
14100 | NodeRequest::ResolveDurableContextPack { .. }
14101 | NodeRequest::ReadContextPack { .. }
14102 | NodeRequest::Shutdown => false,
14103 }
14104}
14105
14106fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14107 if let Some(spec) = match request {
14108 NodeRequest::SpawnSpec { spec }
14109 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14110 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14111 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14112 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14113 _ => None,
14114 } {
14115 return shared
14116 .resolve_spawn_spec(spec)
14117 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14118 }
14119 match request {
14120 NodeRequest::Resume { session, .. }
14121 | NodeRequest::Prompt { session, .. }
14122 | NodeRequest::Paste { session, .. }
14123 | NodeRequest::Input { session, .. }
14124 | NodeRequest::TerminalBytes { session, .. }
14125 | NodeRequest::TerminalControl { session, .. }
14126 | NodeRequest::Resize { session, .. }
14127 | NodeRequest::Interrupt { session }
14128 | NodeRequest::Stop { session, .. }
14129 | NodeRequest::Remove { session }
14130 | NodeRequest::ResolveInteraction { session, .. }
14131 | NodeRequest::SetSessionMode { session, .. }
14132 | NodeRequest::SetSessionConfigOption { session, .. }
14133 | NodeRequest::SetSessionModel { session, .. }
14134 | NodeRequest::DiscoverHistory { session, .. }
14135 | NodeRequest::LoadHistory { session, .. }
14136 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14137 | NodeRequest::ExportContextPack { session } => shared
14138 .session_bindings
14139 .lock()
14140 .unwrap_or_else(|poisoned| poisoned.into_inner())
14141 .get(&session.session.instance_id)
14142 .filter(|binding| {
14143 binding.workspace_id == session.workspace_id
14144 && binding.generation == session.session.generation
14145 })
14146 .map_or(true, |binding| binding.bundle.is_some()),
14147 NodeRequest::RenameSessionRecord { record_id, .. }
14148 | NodeRequest::SetSessionTask { record_id, .. }
14149 | NodeRequest::ResumeSessionRecord { record_id, .. }
14150 | NodeRequest::ForgetSessionRecord { record_id } => shared
14151 .record(record_id)
14152 .map_or(true, |record| record.bundle.is_some()),
14153 NodeRequest::ArmHarnessMcpReservation { .. }
14154 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14155 | NodeRequest::ActivateHarnessMcpReservation { .. }
14156 | NodeRequest::AbortHarnessMcpReservation { .. }
14157 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14158 | NodeRequest::RejectHarnessMcpCall { .. }
14159 | NodeRequest::Snapshot
14160 | NodeRequest::Resync { .. }
14161 | NodeRequest::BeginDeliveryStage { .. }
14162 | NodeRequest::PutDeliveryBlobChunk { .. }
14163 | NodeRequest::CommitDeliveryStage { .. }
14164 | NodeRequest::AbortDeliveryStage { .. }
14165 | NodeRequest::BrowseHostDirectories { .. }
14166 | NodeRequest::InspectWorkspace { .. }
14167 | NodeRequest::ReadWorkspaceFile { .. }
14168 | NodeRequest::WriteWorkspaceFile { .. }
14169 | NodeRequest::CreateWorkspaceFile { .. }
14170 | NodeRequest::CreateWorkspaceDirectory { .. }
14171 | NodeRequest::ReadGitHistory { .. }
14172 | NodeRequest::ReadGitDiff { .. }
14173 | NodeRequest::AcquireController { .. }
14174 | NodeRequest::ReleaseController
14175 | NodeRequest::Spawn { .. }
14176 | NodeRequest::SpawnSpec { .. }
14177 | NodeRequest::RegisterWorkspace { .. }
14178 | NodeRequest::CreateStandaloneWorkspace { .. }
14179 | NodeRequest::UnregisterWorkspace { .. }
14180 | NodeRequest::CreateWorktree { .. }
14181 | NodeRequest::RemoveWorktree { .. }
14182 | NodeRequest::SpawnManagedWorktree { .. }
14183 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14184 | NodeRequest::CleanupManagedWorktree { .. }
14185 | NodeRequest::IndexProviderSession { .. }
14186 | NodeRequest::IndexNativeSession { .. }
14187 | NodeRequest::CatalogNativeSessions { .. }
14188 | NodeRequest::PageNativeSessions { .. }
14189 | NodeRequest::PreviewNativeSession { .. }
14190 | NodeRequest::PreviewSessionRecord { .. }
14191 | NodeRequest::ForgetContextPack { .. }
14192 | NodeRequest::ResolveDurableContextPack { .. }
14193 | NodeRequest::ReadContextPack { .. }
14194 | NodeRequest::Shutdown => false,
14195 }
14196}
14197
14198fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14199 if request.requires_history_context_pack_capability() {
14200 return true;
14201 }
14202 if let Some(spec) = match request {
14203 NodeRequest::SpawnSpec { spec }
14204 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14205 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14206 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14207 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14208 _ => None,
14209 } {
14210 return shared
14211 .resolve_spawn_spec(spec)
14212 .map_or(true, |resolved| resolved.context_id.is_some());
14213 }
14214 match request {
14215 NodeRequest::Resume { session, .. }
14216 | NodeRequest::Prompt { session, .. }
14217 | NodeRequest::Paste { session, .. }
14218 | NodeRequest::Input { session, .. }
14219 | NodeRequest::TerminalBytes { session, .. }
14220 | NodeRequest::TerminalControl { session, .. }
14221 | NodeRequest::Resize { session, .. }
14222 | NodeRequest::Interrupt { session }
14223 | NodeRequest::Stop { session, .. }
14224 | NodeRequest::Remove { session }
14225 | NodeRequest::ResolveInteraction { session, .. }
14226 | NodeRequest::SetSessionMode { session, .. }
14227 | NodeRequest::SetSessionConfigOption { session, .. }
14228 | NodeRequest::SetSessionModel { session, .. } => shared
14229 .session_bindings
14230 .lock()
14231 .unwrap_or_else(|poisoned| poisoned.into_inner())
14232 .get(&session.session.instance_id)
14233 .filter(|binding| {
14234 binding.workspace_id == session.workspace_id
14235 && binding.generation == session.session.generation
14236 })
14237 .map_or(true, |binding| binding.context.is_some()),
14238 NodeRequest::RenameSessionRecord { record_id, .. }
14239 | NodeRequest::SetSessionTask { record_id, .. }
14240 | NodeRequest::ResumeSessionRecord { record_id, .. }
14241 | NodeRequest::ForgetSessionRecord { record_id } => shared
14242 .record(record_id)
14243 .map_or(true, |record| record.context.is_some()),
14244 NodeRequest::ArmHarnessMcpReservation { .. }
14245 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14246 | NodeRequest::ActivateHarnessMcpReservation { .. }
14247 | NodeRequest::AbortHarnessMcpReservation { .. }
14248 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14249 | NodeRequest::RejectHarnessMcpCall { .. }
14250 | NodeRequest::Snapshot
14251 | NodeRequest::Resync { .. }
14252 | NodeRequest::BeginDeliveryStage { .. }
14253 | NodeRequest::PutDeliveryBlobChunk { .. }
14254 | NodeRequest::CommitDeliveryStage { .. }
14255 | NodeRequest::AbortDeliveryStage { .. }
14256 | NodeRequest::BrowseHostDirectories { .. }
14257 | NodeRequest::InspectWorkspace { .. }
14258 | NodeRequest::ReadWorkspaceFile { .. }
14259 | NodeRequest::WriteWorkspaceFile { .. }
14260 | NodeRequest::CreateWorkspaceFile { .. }
14261 | NodeRequest::CreateWorkspaceDirectory { .. }
14262 | NodeRequest::ReadGitHistory { .. }
14263 | NodeRequest::ReadGitDiff { .. }
14264 | NodeRequest::AcquireController { .. }
14265 | NodeRequest::ReleaseController
14266 | NodeRequest::Spawn { .. }
14267 | NodeRequest::SpawnSpec { .. }
14268 | NodeRequest::RegisterWorkspace { .. }
14269 | NodeRequest::CreateStandaloneWorkspace { .. }
14270 | NodeRequest::UnregisterWorkspace { .. }
14271 | NodeRequest::CreateWorktree { .. }
14272 | NodeRequest::RemoveWorktree { .. }
14273 | NodeRequest::SpawnManagedWorktree { .. }
14274 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14275 | NodeRequest::CleanupManagedWorktree { .. }
14276 | NodeRequest::IndexProviderSession { .. }
14277 | NodeRequest::IndexNativeSession { .. }
14278 | NodeRequest::CatalogNativeSessions { .. }
14279 | NodeRequest::PageNativeSessions { .. }
14280 | NodeRequest::PreviewNativeSession { .. }
14281 | NodeRequest::PreviewSessionRecord { .. }
14282 | NodeRequest::DiscoverHistory { .. }
14283 | NodeRequest::LoadHistory { .. }
14284 | NodeRequest::ExportContextPackForSessionRecord { .. }
14285 | NodeRequest::ExportContextPack { .. }
14286 | NodeRequest::ForgetContextPack { .. }
14287 | NodeRequest::ResolveDurableContextPack { .. }
14288 | NodeRequest::ReadContextPack { .. }
14289 | NodeRequest::Shutdown => false,
14290 }
14291}
14292
14293fn request_requires_open_provider_ids_with(
14294 request: &NodeRequest,
14295 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14296 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14297) -> bool {
14298 match request {
14299 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14300 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14301 NodeRequest::CatalogNativeSessions { route, .. }
14302 | NodeRequest::PageNativeSessions { route, .. } => {
14303 !provider_id_is_legacy(&route.provider)
14304 }
14305 NodeRequest::PreviewNativeSession { selection, .. }
14306 | NodeRequest::IndexNativeSession { selection, .. } => {
14307 !provider_id_is_legacy(&selection.route.provider)
14308 }
14309 NodeRequest::SpawnSpec { .. }
14310 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14311 NodeRequest::SpawnManagedWorktree { .. }
14312 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14313 NodeRequest::Resume { session, .. }
14314 | NodeRequest::Prompt { session, .. }
14315 | NodeRequest::Paste { session, .. }
14316 | NodeRequest::Input { session, .. }
14317 | NodeRequest::TerminalBytes { session, .. }
14318 | NodeRequest::TerminalControl { session, .. }
14319 | NodeRequest::Resize { session, .. }
14320 | NodeRequest::Interrupt { session }
14321 | NodeRequest::Stop { session, .. }
14322 | NodeRequest::Remove { session }
14323 | NodeRequest::ResolveInteraction { session, .. }
14324 | NodeRequest::SetSessionMode { session, .. }
14325 | NodeRequest::SetSessionConfigOption { session, .. }
14326 | NodeRequest::SetSessionModel { session, .. }
14327 | NodeRequest::DiscoverHistory { session, .. }
14328 | NodeRequest::LoadHistory { session, .. }
14329 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14330 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14331 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14332 NodeRequest::RenameSessionRecord { record_id, .. }
14333 | NodeRequest::SetSessionTask { record_id, .. }
14334 | NodeRequest::ResumeSessionRecord { record_id, .. }
14335 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14336 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14337 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14338 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14339 NodeRequest::ArmHarnessMcpReservation { .. }
14340 | NodeRequest::ActivateHarnessMcpReservation { .. }
14341 | NodeRequest::AbortHarnessMcpReservation { .. }
14342 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14343 | NodeRequest::RejectHarnessMcpCall { .. }
14344 | NodeRequest::Snapshot
14345 | NodeRequest::Resync { .. }
14346 | NodeRequest::BeginDeliveryStage { .. }
14347 | NodeRequest::PutDeliveryBlobChunk { .. }
14348 | NodeRequest::CommitDeliveryStage { .. }
14349 | NodeRequest::AbortDeliveryStage { .. }
14350 | NodeRequest::BrowseHostDirectories { .. }
14351 | NodeRequest::InspectWorkspace { .. }
14352 | NodeRequest::ReadWorkspaceFile { .. }
14353 | NodeRequest::WriteWorkspaceFile { .. }
14354 | NodeRequest::CreateWorkspaceFile { .. }
14355 | NodeRequest::CreateWorkspaceDirectory { .. }
14356 | NodeRequest::ReadGitHistory { .. }
14357 | NodeRequest::ReadGitDiff { .. }
14358 | NodeRequest::AcquireController { .. }
14359 | NodeRequest::ReleaseController
14360 | NodeRequest::RegisterWorkspace { .. }
14361 | NodeRequest::CreateStandaloneWorkspace { .. }
14362 | NodeRequest::UnregisterWorkspace { .. }
14363 | NodeRequest::CreateWorktree { .. }
14364 | NodeRequest::RemoveWorktree { .. }
14365 | NodeRequest::CleanupManagedWorktree { .. }
14366 | NodeRequest::Shutdown => false,
14367 NodeRequest::ForgetContextPack { .. } => true,
14368 NodeRequest::ResolveDurableContextPack { .. }
14374 | NodeRequest::ReadContextPack { .. } => true,
14375 }
14376}
14377
14378fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14379 snapshot.enabled_providers.retain(provider_id_is_legacy);
14380 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14381 snapshot
14382 .provider_runtime_statuses
14383 .as_slice()
14384 .iter()
14385 .filter(|status| provider_id_is_legacy(status.provider()))
14386 .cloned(),
14387 )
14388 .expect("a filtered provider runtime projection remains valid");
14389 snapshot
14390 .session_records
14391 .retain(|record| provider_id_is_legacy(&record.provider));
14392 for workspace in &mut snapshot.workspaces {
14393 workspace
14394 .sessions
14395 .retain(|session| provider_id_is_legacy(&session.agent_id));
14396 }
14397}
14398
14399fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14400 for record in &mut snapshot.session_records {
14401 record.environment_profile = None;
14402 }
14403 if let Some(profiles) = snapshot
14404 .launch_inventory
14405 .as_mut()
14406 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14407 {
14408 for profile in profiles {
14409 profile.environment_profile = None;
14410 }
14411 }
14412}
14413
14414fn project_event_without_child_environment_profile(
14415 mut envelope: NodeEventEnvelope,
14416) -> NodeEventEnvelope {
14417 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14418 record.environment_profile = None;
14419 }
14420 envelope
14421}
14422
14423fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14424 let contains_environment_profile = match reply.result.as_ref() {
14425 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14426 receipt.environment_profile.is_some()
14427 }
14428 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14429 receipt.spawn.environment_profile.is_some()
14430 }
14431 _ => false,
14432 };
14433 if contains_environment_profile {
14434 reply.result = Err(failure(
14435 NodeFailureCode::UnsupportedCapability,
14436 "child environment profiles were not negotiated",
14437 ));
14438 return;
14439 }
14440 let Ok(response) = reply.result.as_mut() else {
14441 return;
14442 };
14443 match response {
14444 NodeResponse::Snapshot { snapshot, .. } => {
14445 clear_snapshot_child_environment_profiles(snapshot);
14446 }
14447 NodeResponse::Resync { snapshot, events, .. } => {
14448 clear_snapshot_child_environment_profiles(snapshot);
14449 for event in events {
14450 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14451 record.environment_profile = None;
14452 }
14453 }
14454 }
14455 NodeResponse::SessionRecordUpdated { record }
14456 | NodeResponse::ProviderSessionIndexed { record }
14457 | NodeResponse::NativeSessionIndexed { record, .. }
14458 | NodeResponse::SessionRecordResumed { record, .. } => {
14459 record.environment_profile = None;
14460 }
14461 NodeResponse::Armed { .. }
14462 | NodeResponse::Spawned { .. }
14463 | NodeResponse::Activated { .. }
14464 | NodeResponse::Aborted { .. }
14465 | NodeResponse::ReplyChunkAccepted { .. }
14466 | NodeResponse::CallRejected { .. }
14467 | NodeResponse::DeliveryStageBegun { .. }
14468 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14469 | NodeResponse::DeliveryCommitted { .. }
14470 | NodeResponse::DeliveryStageAborted { .. }
14471 | NodeResponse::WorkspaceInspected { .. }
14472 | NodeResponse::HostDirectoriesBrowsed { .. }
14473 | NodeResponse::WorkspaceFileRead { .. }
14474 | NodeResponse::WorkspaceFileWritten { .. }
14475 | NodeResponse::WorkspaceFileCreated { .. }
14476 | NodeResponse::WorkspaceDirectoryCreated { .. }
14477 | NodeResponse::GitHistoryRead { .. }
14478 | NodeResponse::GitDiffRead { .. }
14479 | NodeResponse::Controller { .. }
14480 | NodeResponse::SpawnAccepted { .. }
14481 | NodeResponse::SpawnSpecAccepted { .. }
14482 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14483 | NodeResponse::ManagedWorktreeCleanup { .. }
14484 | NodeResponse::NativeSessionsCataloged { .. }
14485 | NodeResponse::NativeSessionsPaged { .. }
14486 | NodeResponse::NativeSessionPreviewed { .. }
14487 | NodeResponse::SessionRecordPreviewed { .. }
14488 | NodeResponse::HistoryDiscovered { .. }
14489 | NodeResponse::HistoryLoaded { .. }
14490 | NodeResponse::ContextPackForSessionRecordExported { .. }
14491 | NodeResponse::ContextPackExported { .. }
14492 | NodeResponse::ContextPackForgotten { .. }
14493 | NodeResponse::DurableContextPackResolved { .. }
14494 | NodeResponse::ContextPackBytesRead { .. }
14495 | NodeResponse::SessionRecordForgotten { .. }
14496 | NodeResponse::WorkspaceRegistered { .. }
14497 | NodeResponse::StandaloneWorkspaceCreated { .. }
14498 | NodeResponse::WorkspaceUnregistered { .. }
14499 | NodeResponse::WorktreeCreated { .. }
14500 | NodeResponse::WorktreeRemoved { .. }
14501 | NodeResponse::Accepted
14502 | NodeResponse::ShuttingDown => {}
14503 }
14504}
14505
14506fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14507 for record in &mut snapshot.session_records {
14508 record.bundle = None;
14509 }
14510}
14511
14512fn project_event_without_session_bundle(
14513 mut envelope: NodeEventEnvelope,
14514) -> NodeEventEnvelope {
14515 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14516 record.bundle = None;
14517 }
14518 envelope
14519}
14520
14521fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14522 let contains_bundle = match reply.result.as_ref() {
14523 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14524 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14525 receipt.spawn.bundle.is_some()
14526 }
14527 _ => false,
14528 };
14529 if contains_bundle {
14530 reply.result = Err(failure(
14531 NodeFailureCode::UnsupportedCapability,
14532 "session bundles were not negotiated",
14533 ));
14534 return;
14535 }
14536 let Ok(response) = reply.result.as_mut() else {
14537 return;
14538 };
14539 match response {
14540 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14541 NodeResponse::Resync { snapshot, events, .. } => {
14542 clear_snapshot_session_bundles(snapshot);
14543 for event in events {
14544 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14545 record.bundle = None;
14546 }
14547 }
14548 }
14549 NodeResponse::SessionRecordUpdated { record }
14550 | NodeResponse::ProviderSessionIndexed { record }
14551 | NodeResponse::NativeSessionIndexed { record, .. }
14552 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14553 NodeResponse::Armed { .. }
14554 | NodeResponse::Spawned { .. }
14555 | NodeResponse::Activated { .. }
14556 | NodeResponse::Aborted { .. }
14557 | NodeResponse::ReplyChunkAccepted { .. }
14558 | NodeResponse::CallRejected { .. }
14559 | NodeResponse::DeliveryStageBegun { .. }
14560 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14561 | NodeResponse::DeliveryCommitted { .. }
14562 | NodeResponse::DeliveryStageAborted { .. }
14563 | NodeResponse::WorkspaceInspected { .. }
14564 | NodeResponse::HostDirectoriesBrowsed { .. }
14565 | NodeResponse::WorkspaceFileRead { .. }
14566 | NodeResponse::WorkspaceFileWritten { .. }
14567 | NodeResponse::WorkspaceFileCreated { .. }
14568 | NodeResponse::WorkspaceDirectoryCreated { .. }
14569 | NodeResponse::GitHistoryRead { .. }
14570 | NodeResponse::GitDiffRead { .. }
14571 | NodeResponse::Controller { .. }
14572 | NodeResponse::SpawnAccepted { .. }
14573 | NodeResponse::SpawnSpecAccepted { .. }
14574 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14575 | NodeResponse::ManagedWorktreeCleanup { .. }
14576 | NodeResponse::NativeSessionsCataloged { .. }
14577 | NodeResponse::NativeSessionsPaged { .. }
14578 | NodeResponse::NativeSessionPreviewed { .. }
14579 | NodeResponse::SessionRecordPreviewed { .. }
14580 | NodeResponse::HistoryDiscovered { .. }
14581 | NodeResponse::HistoryLoaded { .. }
14582 | NodeResponse::ContextPackForSessionRecordExported { .. }
14583 | NodeResponse::ContextPackExported { .. }
14584 | NodeResponse::ContextPackForgotten { .. }
14585 | NodeResponse::DurableContextPackResolved { .. }
14586 | NodeResponse::ContextPackBytesRead { .. }
14587 | NodeResponse::SessionRecordForgotten { .. }
14588 | NodeResponse::WorkspaceRegistered { .. }
14589 | NodeResponse::StandaloneWorkspaceCreated { .. }
14590 | NodeResponse::WorkspaceUnregistered { .. }
14591 | NodeResponse::WorktreeCreated { .. }
14592 | NodeResponse::WorktreeRemoved { .. }
14593 | NodeResponse::Accepted
14594 | NodeResponse::ShuttingDown => {}
14595 }
14596}
14597
14598fn project_snapshot_history_for_wire(
14599 snapshot: &mut NodeSnapshot,
14600 include_history_metadata: bool,
14601) {
14602 for workspace in &mut snapshot.workspaces {
14603 for session in &mut workspace.sessions {
14604 if include_history_metadata {
14605 session.history.loaded = None;
14606 } else {
14607 session.history = Default::default();
14608 }
14609 }
14610 }
14611}
14612
14613fn project_event_history_for_wire(
14614 mut envelope: NodeEventEnvelope,
14615 include_history_metadata: bool,
14616) -> NodeEventEnvelope {
14617 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14618 for session in &mut workspace.sessions {
14619 if include_history_metadata {
14620 session.history.loaded = None;
14621 } else {
14622 session.history = Default::default();
14623 }
14624 }
14625 }
14626 envelope
14627}
14628
14629fn project_response_history_for_wire(
14630 reply: &mut ResponseEnvelope,
14631 include_history_metadata: bool,
14632) {
14633 let Ok(response) = reply.result.as_mut() else {
14634 return;
14635 };
14636 match response {
14637 NodeResponse::Snapshot { snapshot, .. } => {
14638 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14639 }
14640 NodeResponse::Resync {
14641 snapshot, events, ..
14642 } => {
14643 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14644 for event in events {
14645 *event = project_event_history_for_wire(
14646 event.clone(),
14647 include_history_metadata,
14648 );
14649 }
14650 }
14651 NodeResponse::WorkspaceRegistered { workspace }
14652 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14653 | NodeResponse::WorktreeCreated { workspace, .. } => {
14654 for session in &mut workspace.sessions {
14655 if include_history_metadata {
14656 session.history.loaded = None;
14657 } else {
14658 session.history = Default::default();
14659 }
14660 }
14661 }
14662 _ => {}
14663 }
14664}
14665
14666fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14667 for record in &mut snapshot.session_records {
14668 record.context_id = None;
14669 record.context = None;
14670 record.exported_context = None;
14671 }
14672}
14673
14674fn project_event_without_context_pack(
14675 mut envelope: NodeEventEnvelope,
14676) -> NodeEventEnvelope {
14677 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14678 record.context_id = None;
14679 record.context = None;
14680 record.exported_context = None;
14681 }
14682 envelope
14683}
14684
14685fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14686 let contains_context = match reply.result.as_ref() {
14687 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14688 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14689 receipt.spawn.context.is_some()
14690 }
14691 Ok(NodeResponse::HistoryDiscovered { .. })
14692 | Ok(NodeResponse::HistoryLoaded { .. })
14693 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14694 | Ok(NodeResponse::ContextPackExported { .. })
14695 | Ok(NodeResponse::ContextPackForgotten { .. })
14696 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14697 _ => false,
14698 };
14699 if contains_context {
14700 reply.result = Err(failure(
14701 NodeFailureCode::UnsupportedCapability,
14702 "history context packs were not negotiated",
14703 ));
14704 return;
14705 }
14706 let Ok(response) = reply.result.as_mut() else {
14707 return;
14708 };
14709 match response {
14710 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14711 NodeResponse::Resync {
14712 snapshot, events, ..
14713 } => {
14714 clear_snapshot_context_packs(snapshot);
14715 for event in events {
14716 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14717 record.context_id = None;
14718 record.context = None;
14719 record.exported_context = None;
14720 }
14721 }
14722 }
14723 NodeResponse::SessionRecordUpdated { record }
14724 | NodeResponse::ProviderSessionIndexed { record }
14725 | NodeResponse::NativeSessionIndexed { record, .. }
14726 | NodeResponse::SessionRecordResumed { record, .. } => {
14727 record.context_id = None;
14728 record.context = None;
14729 record.exported_context = None;
14730 }
14731 _ => {}
14732 }
14733}
14734
14735fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14736 let contains_open_record = match reply.result.as_ref() {
14737 Ok(NodeResponse::SessionRecordUpdated { record })
14738 | Ok(NodeResponse::ProviderSessionIndexed { record })
14739 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14740 !provider_id_is_legacy(&record.provider)
14741 }
14742 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14743 !provider_id_is_legacy(&selection.route.provider)
14744 || !provider_id_is_legacy(&record.provider)
14745 }
14746 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14747 !provider_id_is_legacy(&receipt.provider)
14748 }
14749 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14750 !provider_id_is_legacy(&receipt.spawn.provider)
14751 }
14752 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14753 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14754 !provider_id_is_legacy(&route.provider)
14755 }
14756 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14757 !provider_id_is_legacy(&selection.route.provider)
14758 }
14759 _ => false,
14760 };
14761 if contains_open_record {
14762 reply.result = Err(failure(
14763 NodeFailureCode::UnsupportedCapability,
14764 "open provider IDs were not negotiated",
14765 ));
14766 return;
14767 }
14768 let Ok(response) = reply.result.as_mut() else {
14769 return;
14770 };
14771 match response {
14772 NodeResponse::Snapshot { snapshot, .. } => {
14773 project_snapshot_legacy_provider_ids(snapshot);
14774 }
14775 NodeResponse::Resync { snapshot, events, .. } => {
14776 project_snapshot_legacy_provider_ids(snapshot);
14777 *events = std::mem::take(events)
14778 .into_iter()
14779 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14780 .collect();
14781 }
14782 NodeResponse::WorkspaceRegistered { workspace }
14783 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14784 | NodeResponse::WorktreeCreated { workspace, .. } => {
14785 workspace
14786 .sessions
14787 .retain(|session| provider_id_is_legacy(&session.agent_id));
14788 }
14789 NodeResponse::Armed { .. }
14790 | NodeResponse::Spawned { .. }
14791 | NodeResponse::Activated { .. }
14792 | NodeResponse::Aborted { .. }
14793 | NodeResponse::ReplyChunkAccepted { .. }
14794 | NodeResponse::CallRejected { .. }
14795 | NodeResponse::DeliveryStageBegun { .. }
14796 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14797 | NodeResponse::DeliveryCommitted { .. }
14798 | NodeResponse::DeliveryStageAborted { .. }
14799 | NodeResponse::WorkspaceInspected { .. }
14800 | NodeResponse::HostDirectoriesBrowsed { .. }
14801 | NodeResponse::WorkspaceFileRead { .. }
14802 | NodeResponse::WorkspaceFileWritten { .. }
14803 | NodeResponse::WorkspaceFileCreated { .. }
14804 | NodeResponse::WorkspaceDirectoryCreated { .. }
14805 | NodeResponse::GitHistoryRead { .. }
14806 | NodeResponse::GitDiffRead { .. }
14807 | NodeResponse::Controller { .. }
14808 | NodeResponse::SpawnAccepted { .. }
14809 | NodeResponse::SpawnSpecAccepted { .. }
14810 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14811 | NodeResponse::ManagedWorktreeCleanup { .. }
14812 | NodeResponse::NativeSessionsCataloged { .. }
14813 | NodeResponse::NativeSessionsPaged { .. }
14814 | NodeResponse::NativeSessionPreviewed { .. }
14815 | NodeResponse::SessionRecordPreviewed { .. }
14816 | NodeResponse::HistoryDiscovered { .. }
14817 | NodeResponse::HistoryLoaded { .. }
14818 | NodeResponse::ContextPackForSessionRecordExported { .. }
14819 | NodeResponse::ContextPackExported { .. }
14820 | NodeResponse::ContextPackForgotten { .. }
14821 | NodeResponse::DurableContextPackResolved { .. }
14822 | NodeResponse::ContextPackBytesRead { .. }
14823 | NodeResponse::SessionRecordUpdated { .. }
14824 | NodeResponse::ProviderSessionIndexed { .. }
14825 | NodeResponse::NativeSessionIndexed { .. }
14826 | NodeResponse::SessionRecordResumed { .. }
14827 | NodeResponse::SessionRecordForgotten { .. }
14828 | NodeResponse::WorkspaceUnregistered { .. }
14829 | NodeResponse::WorktreeRemoved { .. }
14830 | NodeResponse::Accepted
14831 | NodeResponse::ShuttingDown => {}
14832 }
14833}
14834
14835fn project_event_legacy_provider_ids(
14836 shared: &NodeShared,
14837 mut envelope: NodeEventEnvelope,
14838) -> Option<NodeEventEnvelope> {
14839 let include = match &mut envelope.event {
14840 NodeEvent::HarnessMcpReadCall { .. } => true,
14841 NodeEvent::Control { address, .. }
14842 | NodeEvent::AgentStream { address, .. }
14843 | NodeEvent::TerminalFrame { address, .. } => shared
14844 .validate_address(address)
14845 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14846 NodeEvent::WorkspaceAdded { workspace } => {
14847 workspace
14848 .sessions
14849 .retain(|session| provider_id_is_legacy(&session.agent_id));
14850 true
14851 }
14852 NodeEvent::SessionRecordUpserted { record } => {
14853 provider_id_is_legacy(&record.provider)
14854 }
14855 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14856 NodeEvent::SessionRecordRemoved { .. } => shared
14857 .history
14858 .lock()
14859 .unwrap_or_else(|poisoned| poisoned.into_inner())
14860 .removed_record_providers
14861 .get(&envelope.sequence)
14862 .is_some_and(provider_id_is_legacy),
14863 NodeEvent::ControllerChanged { .. }
14864 | NodeEvent::ManagedWorktreeUpserted { .. }
14865 | NodeEvent::ManagedWorktreeRemoved { .. }
14866 | NodeEvent::WorkspaceRemoved { .. }
14867 | NodeEvent::ResyncRequired { .. } => true,
14868 };
14869 include.then_some(envelope)
14870}
14871
14872fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14873 let Ok(response) = reply.result.as_mut() else {
14874 return;
14875 };
14876 match response {
14877 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14878 snapshot.provider_runtime_statuses.clear();
14879 }
14880 NodeResponse::Armed { .. }
14881 | NodeResponse::Spawned { .. }
14882 | NodeResponse::Activated { .. }
14883 | NodeResponse::Aborted { .. }
14884 | NodeResponse::ReplyChunkAccepted { .. }
14885 | NodeResponse::CallRejected { .. }
14886 | NodeResponse::DeliveryStageBegun { .. }
14887 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14888 | NodeResponse::DeliveryCommitted { .. }
14889 | NodeResponse::DeliveryStageAborted { .. }
14890 | NodeResponse::WorkspaceInspected { .. }
14891 | NodeResponse::HostDirectoriesBrowsed { .. }
14892 | NodeResponse::WorkspaceFileRead { .. }
14893 | NodeResponse::WorkspaceFileWritten { .. }
14894 | NodeResponse::WorkspaceFileCreated { .. }
14895 | NodeResponse::WorkspaceDirectoryCreated { .. }
14896 | NodeResponse::GitHistoryRead { .. }
14897 | NodeResponse::GitDiffRead { .. }
14898 | NodeResponse::Controller { .. }
14899 | NodeResponse::SpawnAccepted { .. }
14900 | NodeResponse::SpawnSpecAccepted { .. }
14901 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14902 | NodeResponse::ManagedWorktreeCleanup { .. }
14903 | NodeResponse::NativeSessionsCataloged { .. }
14904 | NodeResponse::NativeSessionsPaged { .. }
14905 | NodeResponse::NativeSessionPreviewed { .. }
14906 | NodeResponse::SessionRecordPreviewed { .. }
14907 | NodeResponse::HistoryDiscovered { .. }
14908 | NodeResponse::HistoryLoaded { .. }
14909 | NodeResponse::ContextPackForSessionRecordExported { .. }
14910 | NodeResponse::ContextPackExported { .. }
14911 | NodeResponse::ContextPackForgotten { .. }
14912 | NodeResponse::DurableContextPackResolved { .. }
14913 | NodeResponse::ContextPackBytesRead { .. }
14914 | NodeResponse::SessionRecordUpdated { .. }
14915 | NodeResponse::ProviderSessionIndexed { .. }
14916 | NodeResponse::NativeSessionIndexed { .. }
14917 | NodeResponse::SessionRecordResumed { .. }
14918 | NodeResponse::SessionRecordForgotten { .. }
14919 | NodeResponse::WorkspaceRegistered { .. }
14920 | NodeResponse::StandaloneWorkspaceCreated { .. }
14921 | NodeResponse::WorkspaceUnregistered { .. }
14922 | NodeResponse::WorktreeCreated { .. }
14923 | NodeResponse::WorktreeRemoved { .. }
14924 | NodeResponse::Accepted
14925 | NodeResponse::ShuttingDown => {}
14926 }
14927}
14928
14929struct ControllerReleaseGuard {
14930 shared: Arc<NodeShared>,
14931 connection_id: u64,
14932}
14933
14934impl Drop for ControllerReleaseGuard {
14935 fn drop(&mut self) {
14936 self.shared.release_controller(self.connection_id);
14937 }
14938}
14939
14940struct AbortTaskOnDrop(AbortHandle);
14941
14942impl Drop for AbortTaskOnDrop {
14943 fn drop(&mut self) {
14944 self.0.abort();
14945 }
14946}
14947
14948fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14953 std::env::var(name)
14954 .ok()
14955 .and_then(|value| value.parse::<u64>().ok())
14956 .filter(|value| *value > 0)
14957 .unwrap_or(default)
14958 .min(max)
14959}
14960
14961fn workspace_inspection_time_budget_ms() -> u64 {
14962 env_config_bounded(
14963 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14964 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14965 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14966 )
14967}
14968
14969fn workspace_inspection_entry_cap() -> usize {
14970 env_config_bounded(
14971 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14972 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14973 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14974 ) as usize
14975}
14976
14977struct WorkspaceWalkBudget {
14983 deadline: Instant,
14984 entry_cap: usize,
14985 entries_visited: u64,
14986 time_budget_exceeded: bool,
14987 entry_cap_exceeded: bool,
14988}
14989
14990impl WorkspaceWalkBudget {
14991 fn new(deadline: Instant, entry_cap: usize) -> Self {
14992 Self {
14993 deadline,
14994 entry_cap,
14995 entries_visited: 0,
14996 time_budget_exceeded: false,
14997 entry_cap_exceeded: false,
14998 }
14999 }
15000
15001 fn is_exhausted(&self) -> bool {
15002 self.time_budget_exceeded || self.entry_cap_exceeded
15003 }
15004
15005 fn record_visit(&mut self) -> bool {
15008 self.entries_visited += 1;
15009 if self.entries_visited as usize >= self.entry_cap {
15010 self.entry_cap_exceeded = true;
15011 }
15012 if Instant::now() >= self.deadline {
15013 self.time_budget_exceeded = true;
15014 }
15015 self.is_exhausted()
15016 }
15017}
15018
15019fn collect_workspace_entries(
15020 root: &Path,
15021 deadline: Instant,
15022 entry_cap: usize,
15023) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15024 let mut entries = Vec::new();
15025 let mut truncated = false;
15026 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15027 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15028 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15032 (entries, truncated, budget)
15033}
15034
15035fn workspace_inspection_truncation(
15039 walk_budget: &WorkspaceWalkBudget,
15040 git_time_budget_exceeded: bool,
15041 elapsed: Duration,
15042) -> Option<WorkspaceInspectionTruncationV1> {
15043 if !walk_budget.time_budget_exceeded
15044 && !walk_budget.entry_cap_exceeded
15045 && !git_time_budget_exceeded
15046 {
15047 return None;
15048 }
15049 Some(WorkspaceInspectionTruncationV1 {
15050 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15051 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15052 git_time_budget_exceeded,
15053 entries_visited: walk_budget.entries_visited,
15054 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15055 })
15056}
15057
15058fn walk_workspace_directory(
15059 directory: &Path,
15060 relative_directory: Option<&RepositoryPath>,
15061 depth: usize,
15062 entries: &mut Vec<WorkspaceEntry>,
15063 truncated: &mut bool,
15064 budget: &mut WorkspaceWalkBudget,
15065) {
15066 if budget.is_exhausted() {
15067 *truncated = true;
15068 return;
15069 }
15070 let read_dir = match std::fs::read_dir(directory) {
15071 Ok(read_dir) => read_dir,
15072 Err(_) => {
15073 *truncated = true;
15074 return;
15075 }
15076 };
15077 let mut children = Vec::new();
15078 for child in read_dir {
15079 match child {
15080 Ok(child) => children.push(child),
15081 Err(_) => *truncated = true,
15082 }
15083 }
15084 children.sort_by_key(|child| child.file_name());
15085 for child in children {
15086 if budget.record_visit() {
15087 *truncated = true;
15088 return;
15089 }
15090 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15091 *truncated = true;
15092 return;
15093 }
15094 let file_type = match child.file_type() {
15095 Ok(file_type) => file_type,
15096 Err(_) => {
15097 *truncated = true;
15098 continue;
15099 }
15100 };
15101 if file_type.is_symlink() {
15102 continue;
15103 }
15104 let name = match child.file_name().into_string() {
15105 Ok(name) => name,
15106 Err(_) => {
15107 *truncated = true;
15108 continue;
15109 }
15110 };
15111 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15112 continue;
15113 }
15114 let relative_path = match relative_directory {
15115 None => windows_repository_path(name),
15116 Some(parent) => parent
15117 .as_utf8()
15118 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15119 };
15120 let Some(relative_path) = relative_path else {
15121 *truncated = true;
15122 continue;
15123 };
15124 let kind = if file_type.is_dir() {
15125 WorkspaceEntryKind::Directory
15126 } else if file_type.is_file() {
15127 WorkspaceEntryKind::File
15128 } else {
15129 continue;
15130 };
15131 entries.push(WorkspaceEntry {
15132 relative_path: relative_path.clone(),
15133 kind,
15134 });
15135 if file_type.is_dir() {
15136 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15137 walk_workspace_directory(
15138 &child.path(),
15139 Some(&relative_path),
15140 depth + 1,
15141 entries,
15142 truncated,
15143 budget,
15144 );
15145 } else {
15146 *truncated = true;
15147 }
15148 }
15149 }
15150}
15151
15152fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15153 if value.contains('\\') {
15154 return None;
15155 }
15156 RepositoryPath::utf8(value).ok()
15157}
15158
15159fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15160 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15161}
15162
15163fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15172 if name.eq_ignore_ascii_case(".git")
15173 || name.eq_ignore_ascii_case("target")
15174 || name.eq_ignore_ascii_case("node_modules")
15175 {
15176 return true;
15177 }
15178 path.join("CACHEDIR.TAG").is_file()
15179}
15180
15181fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15182 matches!(
15183 status,
15184 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15185 )
15186}
15187
15188#[derive(Clone, Debug, Eq, PartialEq)]
15189enum ContextPackRepositoryHead {
15190 NotRepository,
15191 Unborn,
15192 Commit(String),
15193}
15194
15195#[derive(Clone, Debug, Eq, PartialEq)]
15196struct ContextPackRepositoryObservation {
15197 head: ContextPackRepositoryHead,
15198 git: GitSnapshot,
15199 files: Vec<ContextPackRepositoryFileSource>,
15200}
15201
15202fn stable_context_pack_repository(
15203 first: ContextPackRepositoryObservation,
15204 second: ContextPackRepositoryObservation,
15205) -> Result<ContextPackRepository, NodeFailure> {
15206 if first != second {
15207 return Err(failure(
15208 NodeFailureCode::ContextPackMaterializationFailed,
15209 "context repository changed during bounded capture",
15210 ));
15211 }
15212 Ok(ContextPackRepository::from_sources(
15213 &second.git,
15214 second.files,
15215 ))
15216}
15217
15218fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15225 if Instant::now() < deadline {
15226 return false;
15227 }
15228 append_git_diagnostic(
15229 snapshot,
15230 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15231 );
15232 true
15233}
15234
15235async fn run_git_bounded_with_deadline(
15244 root: &str,
15245 arguments: &[&str],
15246 output_limit: usize,
15247 deadline: Instant,
15248) -> io::Result<GitCommandOutput> {
15249 let remaining = deadline.checked_duration_since(Instant::now());
15250 let timeout_ms = remaining
15251 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15252 .unwrap_or(0)
15253 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15254 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15255}
15256
15257async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15264 let mut snapshot = GitSnapshot {
15265 is_repository: false,
15266 branch: None,
15267 status: Vec::new(),
15268 recent_commits: Vec::new(),
15269 worktrees: Vec::new(),
15270 managed_worktree: None,
15271 truncated: false,
15272 diagnostic: None,
15273 };
15274 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15275 return (snapshot, true);
15276 }
15277 let repository = match run_git_bounded_with_deadline(
15278 root,
15279 &["rev-parse", "--is-inside-work-tree"],
15280 4 * 1_024,
15281 deadline,
15282 )
15283 .await
15284 {
15285 Ok(output) => output,
15286 Err(error) => {
15287 append_git_diagnostic(
15288 &mut snapshot,
15289 &format!("git inspection unavailable: {error}"),
15290 );
15291 return (snapshot, false);
15292 }
15293 };
15294 snapshot.truncated |= repository.truncated;
15295 if repository.timed_out {
15296 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15297 return (snapshot, false);
15298 }
15299 if !repository.success
15300 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15301 {
15302 let stderr = String::from_utf8_lossy(&repository.stderr);
15303 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15304 append_git_diagnostic(&mut snapshot, stderr.trim());
15305 }
15306 return (snapshot, false);
15307 }
15308 snapshot.is_repository = true;
15309
15310 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15311 return (snapshot, true);
15312 }
15313 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15314 Ok(output) => {
15315 snapshot.truncated |= output.truncated;
15316 if output.timed_out {
15317 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15318 } else if output.success {
15319 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15320 if !branch.is_empty() {
15321 snapshot.branch = Some(truncate_git_field(
15322 &branch,
15323 MAX_REPOSITORY_PATH_BYTES,
15324 &mut snapshot.truncated,
15325 ));
15326 }
15327 }
15328 }
15329 Err(error) => append_git_diagnostic(
15330 &mut snapshot,
15331 &format!("git branch query failed: {error}"),
15332 ),
15333 }
15334 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15335 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15336 snapshot.truncated |= output.truncated;
15337 if output.success && !output.timed_out {
15338 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15339 if !head.is_empty() {
15340 snapshot.branch = Some(format!("detached:{head}"));
15341 }
15342 }
15343 }
15344 }
15345
15346 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15347 return (snapshot, true);
15348 }
15349 match run_git_bounded_with_deadline(
15350 root,
15351 &[
15352 "status",
15353 "--porcelain=v1",
15354 "-z",
15355 "--renames",
15356 "--untracked-files=normal",
15357 "--",
15358 ".",
15359 ],
15360 GIT_OUTPUT_MAX_BYTES,
15361 deadline,
15362 )
15363 .await
15364 {
15365 Ok(output) => {
15366 snapshot.truncated |= output.truncated;
15367 if output.timed_out {
15368 append_git_diagnostic(&mut snapshot, "git status timed out");
15369 } else if output.success {
15370 parse_git_status(&output.stdout, &mut snapshot);
15371 } else {
15372 append_git_diagnostic(
15373 &mut snapshot,
15374 String::from_utf8_lossy(&output.stderr).trim(),
15375 );
15376 }
15377 }
15378 Err(error) => append_git_diagnostic(
15379 &mut snapshot,
15380 &format!("git status failed: {error}"),
15381 ),
15382 }
15383
15384 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15385 return (snapshot, true);
15386 }
15387 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15388 match run_git_bounded_with_deadline(
15389 root,
15390 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15391 16 * 1_024,
15392 deadline,
15393 )
15394 .await
15395 {
15396 Ok(output) => {
15397 snapshot.truncated |= output.truncated;
15398 if output.timed_out {
15399 append_git_diagnostic(&mut snapshot, "git log timed out");
15400 } else if output.success {
15401 parse_git_commits(&output.stdout, &mut snapshot);
15402 } else {
15403 let stderr = String::from_utf8_lossy(&output.stderr);
15404 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15405 append_git_diagnostic(&mut snapshot, stderr.trim());
15406 }
15407 }
15408 }
15409 Err(error) => append_git_diagnostic(
15410 &mut snapshot,
15411 &format!("git log failed: {error}"),
15412 ),
15413 }
15414
15415 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15416 return (snapshot, true);
15417 }
15418 match list_git_worktrees_with_deadline(root, deadline).await {
15419 Ok(worktrees) => {
15420 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15421 }
15422 Err(error) => append_git_diagnostic(
15423 &mut snapshot,
15424 &format!("git worktree list failed: {}", error.message),
15425 ),
15426 }
15427 (snapshot, false)
15428}
15429
15430fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15431 let mut remaining = output;
15432 while !remaining.is_empty() {
15433 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15434 snapshot.truncated = true;
15435 break;
15436 }
15437 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15438 snapshot.truncated = true;
15439 break;
15440 };
15441 let record = &remaining[..record_end];
15442 remaining = &remaining[record_end + 1..];
15443 let has_previous_path = record
15444 .get(..2)
15445 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15446 let previous_path_bytes = if has_previous_path {
15447 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15448 snapshot.truncated = true;
15449 break;
15450 };
15451 let previous = &remaining[..previous_end];
15452 remaining = &remaining[previous_end + 1..];
15453 Some(previous)
15454 } else {
15455 None
15456 };
15457 if record.len() < 4
15458 || record[2] != b' '
15459 || !is_git_status_code(record[0])
15460 || !is_git_status_code(record[1])
15461 {
15462 snapshot.truncated = true;
15463 continue;
15464 }
15465 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15466 snapshot.truncated = true;
15467 continue;
15468 };
15469 let previous_path = match previous_path_bytes {
15470 Some(previous) => {
15471 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15472 snapshot.truncated = true;
15473 continue;
15474 };
15475 Some(previous)
15476 }
15477 None => None,
15478 };
15479 snapshot.status.push(GitStatusEntry {
15480 index_status: (record[0] as char).to_string(),
15481 worktree_status: (record[1] as char).to_string(),
15482 path,
15483 previous_path,
15484 });
15485 }
15486}
15487
15488fn is_git_status_code(byte: u8) -> bool {
15489 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15490}
15491
15492fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15493 for line in String::from_utf8_lossy(output).lines() {
15494 let Some((id, summary)) = line.split_once('\u{1f}') else {
15495 continue;
15496 };
15497 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15498 snapshot.truncated = true;
15499 break;
15500 }
15501 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15502 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15503 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15504 }
15505}
15506
15507fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15508 if value.len() <= max_bytes {
15509 return value.to_owned();
15510 }
15511 *truncated = true;
15512 let mut boundary = max_bytes;
15513 while boundary > 0 && !value.is_char_boundary(boundary) {
15514 boundary -= 1;
15515 }
15516 value[..boundary].to_owned()
15517}
15518
15519fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15520 if message.is_empty() {
15521 return;
15522 }
15523 let normalized = message
15524 .chars()
15525 .map(|character| if character.is_control() { ' ' } else { character })
15526 .collect::<String>();
15527 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15528 if !diagnostic.is_empty() {
15529 diagnostic.push_str("; ");
15530 }
15531 diagnostic.push_str(normalized.trim());
15532 snapshot.diagnostic = Some(truncate_git_field(
15533 &diagnostic,
15534 GIT_DIAGNOSTIC_MAX_BYTES,
15535 &mut snapshot.truncated,
15536 ));
15537}
15538
15539async fn run_git_bounded(
15540 root: &str,
15541 arguments: &[&str],
15542 output_limit: usize,
15543) -> io::Result<GitCommandOutput> {
15544 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15545}
15546
15547async fn read_git_history_bounded(
15548 root: &str,
15549 path: Option<&RepositoryPath>,
15550 before: Option<&GitObjectId>,
15551 limit: u16,
15552) -> Result<GitHistoryPage, NodeFailure> {
15553 let path = path
15554 .map(|path| {
15555 path.as_utf8().ok_or_else(|| {
15556 failure(
15557 NodeFailureCode::InvalidRepositoryPath,
15558 "git history path must use UTF-8",
15559 )
15560 })
15561 })
15562 .transpose()?
15563 .unwrap_or(".");
15564 let count = (usize::from(limit) + 1).to_string();
15565 let start = before
15566 .map(|revision| format!("{}^", revision.as_str()))
15567 .unwrap_or_else(|| "HEAD".to_owned());
15568 let arguments = [
15569 "--no-pager",
15570 "log",
15571 "--no-decorate",
15572 "--date=iso-strict",
15573 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15574 "-n",
15575 count.as_str(),
15576 start.as_str(),
15577 "--",
15578 path,
15579 ];
15580 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15581 .await
15582 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15583 if output.timed_out {
15584 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15585 }
15586 if !output.success {
15587 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15588 }
15589 let mut commits = parse_git_history(&output.stdout);
15590 let has_more = commits.len() > usize::from(limit) || output.truncated;
15591 commits.truncate(usize::from(limit));
15592 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15593 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15594}
15595
15596fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15597 String::from_utf8_lossy(output)
15598 .split('\u{1e}')
15599 .filter_map(|record| {
15600 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15601 if fields.len() != 9 {
15602 return None;
15603 }
15604 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15605 let parents = if fields[1].is_empty() {
15606 Vec::new()
15607 } else {
15608 fields[1]
15609 .split(' ')
15610 .map(|value| GitObjectId::new(value.to_owned()))
15611 .collect::<Result<Vec<_>, _>>()
15612 .ok()?
15613 };
15614 Some(GitCommitDetails {
15615 id,
15616 parents,
15617 subject: bounded_git_text(fields[2], 512),
15618 author_name: bounded_git_text(fields[3], 256),
15619 author_email: bounded_git_text(fields[4], 320),
15620 authored_at: bounded_git_text(fields[5], 64),
15621 committer_name: bounded_git_text(fields[6], 256),
15622 committer_email: bounded_git_text(fields[7], 320),
15623 committed_at: bounded_git_text(fields[8], 64),
15624 signature_status: GitSignatureStatus::NoSignature,
15625 signer: None,
15626 })
15627 })
15628 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15629 .collect()
15630}
15631
15632fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15633 let normalized = value
15634 .chars()
15635 .map(|character| if character.is_control() { ' ' } else { character })
15636 .collect::<String>();
15637 if normalized.len() <= max_bytes {
15638 return normalized;
15639 }
15640 let mut boundary = max_bytes;
15641 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15642 boundary -= 1;
15643 }
15644 normalized[..boundary].to_owned()
15645}
15646
15647async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15648 let path = request.path.as_ref().map(|path| {
15649 path.as_utf8()
15650 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15651 }).transpose()?;
15652 let mut arguments = match &request.mode {
15653 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15654 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15655 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15656 };
15657 if let GitDiffMode::Commit { revision } = &request.mode {
15658 arguments.push(revision.as_str());
15659 }
15660 arguments.push("--");
15661 if let Some(path) = path {
15662 arguments.push(path);
15663 }
15664 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15665 .await
15666 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15667 if output.timed_out {
15668 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15669 }
15670 if !output.success {
15671 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15672 }
15673 Ok(GitDiff {
15674 mode: request.mode,
15675 path: request.path,
15676 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15677 truncated: output.truncated,
15678 })
15679}
15680
15681async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15682 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15683 ResponseEnvelope { request_id: envelope.request_id, result }
15684}
15685
15686async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15687 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15688 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15689 }
15690 let read_only = matches!(
15691 &request,
15692 NodeRequest::Snapshot
15693 | NodeRequest::Resync { .. }
15694 | NodeRequest::BrowseHostDirectories { .. }
15695 | NodeRequest::InspectWorkspace { .. }
15696 | NodeRequest::ReadWorkspaceFile { .. }
15697 | NodeRequest::ReadGitHistory { .. }
15698 | NodeRequest::ReadGitDiff { .. }
15699 | NodeRequest::CatalogNativeSessions { .. }
15700 | NodeRequest::PageNativeSessions { .. }
15701 | NodeRequest::PreviewNativeSession { .. }
15702 | NodeRequest::PreviewSessionRecord { .. }
15703 );
15704 let _mutation_guard = if read_only {
15705 None
15706 } else {
15707 let guard = shared.mutation_gate.lock().await;
15708 if shared.shutdown.load(Ordering::Acquire) {
15709 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15710 }
15711 Some(guard)
15712 };
15713 match request {
15714 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15715 event_sequence: shared.current_sequence(),
15716 controller: shared.controller_state(),
15717 snapshot: shared.snapshot(),
15718 }),
15719 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15720 NodeRequest::ArmHarnessMcpReservation {
15721 reservation_id,
15722 activation_digest,
15723 spawn_spec,
15724 launch,
15725 expires_at_unix_ms,
15726 } => {
15727 shared.require_controller(connection_id, role)?;
15728 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15729 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15730 })?;
15731 let expires_at_unix_ms = registry
15732 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15733 .await
15734 .map_err(harness_mcp_failure)?;
15735 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15736 }
15737 NodeRequest::SpawnSpecWithHarnessMcp {
15738 reservation_id,
15739 activation_digest,
15740 spec,
15741 deadline_unix_ms,
15742 } => {
15743 shared.require_controller(connection_id, role)?;
15744 let receipt = shared
15745 .spawn_from_spec_with_harness_mcp(
15746 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15747 )
15748 .await?;
15749 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15750 }
15751 NodeRequest::ActivateHarnessMcpReservation {
15752 reservation_id,
15753 activation_digest,
15754 record_id,
15755 session,
15756 } => {
15757 shared.require_controller(connection_id, role)?;
15758 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15759 current.instance_id == session.session.instance_id
15760 && current.generation == session.session.generation
15761 }).and_then(|current| current.process_id).ok_or_else(|| {
15762 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15763 })?;
15764 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15765 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15766 })?.activate(
15767 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15768 ).map_err(harness_mcp_failure)?;
15769 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15770 }
15771 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15772 shared.require_controller(connection_id, role)?;
15773 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15774 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15775 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15776 if let Some(instance_id) = instance_id {
15777 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15778 }
15779 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15780 }
15781 NodeRequest::PutHarnessMcpReplyChunk {
15782 reservation_id, activation_digest, record_id, session, call_id,
15783 offset, final_chunk, chunk_hex,
15784 } => {
15785 shared.require_controller(connection_id, role)?;
15786 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15787 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15788 })?.put_reply_chunk(
15789 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15790 offset, final_chunk, &chunk_hex,
15791 ).map_err(harness_mcp_failure)?;
15792 Ok(NodeResponse::ReplyChunkAccepted {
15793 reservation_id, activation_digest, record_id, session, call_id,
15794 next_offset, completed,
15795 })
15796 }
15797 NodeRequest::RejectHarnessMcpCall {
15798 reservation_id, activation_digest, record_id, session, call_id, reason,
15799 } => {
15800 shared.require_controller(connection_id, role)?;
15801 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15802 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15803 })?.reject_call(
15804 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15805 ).map_err(harness_mcp_failure)?;
15806 Ok(NodeResponse::CallRejected {
15807 reservation_id, activation_digest, record_id, session, call_id,
15808 })
15809 }
15810 NodeRequest::BeginDeliveryStage { manifest } => {
15811 shared.require_controller(connection_id, role)?;
15812 shared.begin_delivery_stage(manifest)
15813 }
15814 NodeRequest::PutDeliveryBlobChunk {
15815 stage_id,
15816 blob_digest,
15817 offset,
15818 chunk_hex,
15819 } => {
15820 shared.require_controller(connection_id, role)?;
15821 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15822 }
15823 NodeRequest::CommitDeliveryStage { stage_id } => {
15824 shared.require_controller(connection_id, role)?;
15825 let receipt = shared.commit_delivery_stage(stage_id)?;
15826 Ok(NodeResponse::DeliveryCommitted { receipt })
15827 }
15828 NodeRequest::AbortDeliveryStage { stage_id } => {
15829 shared.require_controller(connection_id, role)?;
15830 shared.abort_delivery_stage(&stage_id)?;
15831 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15832 }
15833 NodeRequest::BrowseHostDirectories { directory, after } => {
15834 let listing = shared.browse_host_directories(directory, after).await?;
15835 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15836 }
15837 NodeRequest::InspectWorkspace { workspace_id } => {
15838 let inspection = shared.inspect_workspace(workspace_id).await?;
15839 Ok(NodeResponse::WorkspaceInspected { inspection })
15840 }
15841 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15842 let file = shared.read_workspace_file(workspace_id, path).await?;
15843 Ok(NodeResponse::WorkspaceFileRead { file })
15844 }
15845 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15846 shared.require_controller(connection_id, role)?;
15847 let file = shared
15848 .write_workspace_file(workspace_id, path, expected_revision, text)
15849 .await?;
15850 Ok(NodeResponse::WorkspaceFileWritten { file })
15851 }
15852 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15853 shared.require_controller(connection_id, role)?;
15854 let file = shared.create_workspace_file(workspace_id, path).await?;
15855 Ok(NodeResponse::WorkspaceFileCreated { file })
15856 }
15857 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15858 shared.require_controller(connection_id, role)?;
15859 let entry = shared
15860 .create_workspace_directory(workspace_id.clone(), path)
15861 .await?;
15862 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15863 }
15864 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15865 if role != ClientRole::Operator {
15866 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15867 }
15868 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15869 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15870 }
15871 let page = shared
15872 .read_git_history(workspace_id.clone(), path, before, limit)
15873 .await?;
15874 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15875 }
15876 NodeRequest::ReadGitDiff { workspace_id, request } => {
15877 if role != ClientRole::Operator {
15878 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15879 }
15880 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15881 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15882 }
15883 NodeRequest::CatalogNativeSessions { route, limit } => {
15884 if role != ClientRole::Operator {
15885 return Err(failure(
15886 NodeFailureCode::ObserverReadOnly,
15887 "native session catalog requires an operator connection",
15888 ));
15889 }
15890 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15891 return Err(failure(
15892 NodeFailureCode::InvalidRequest,
15893 "native session catalog limit is invalid",
15894 ));
15895 }
15896 let (entries, summary) = shared
15897 .catalog_native_sessions(route.clone(), limit)
15898 .await?;
15899 Ok(NodeResponse::NativeSessionsCataloged {
15900 route,
15901 entries,
15902 summary: Some(summary),
15903 })
15904 }
15905 NodeRequest::PageNativeSessions {
15906 route,
15907 window,
15908 catalog_revision,
15909 recent_cutoff_unix_ms,
15910 after_selection_id,
15911 limit,
15912 } => {
15913 if role != ClientRole::Operator {
15914 return Err(failure(
15915 NodeFailureCode::ObserverReadOnly,
15916 "native session catalog paging requires an operator connection",
15917 ));
15918 }
15919 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15920 || after_selection_id
15921 .as_deref()
15922 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15923 {
15924 return Err(failure(
15925 NodeFailureCode::InvalidRequest,
15926 "native session catalog page request is invalid",
15927 ));
15928 }
15929 let page = shared
15930 .page_native_sessions(
15931 route.clone(),
15932 window,
15933 catalog_revision,
15934 recent_cutoff_unix_ms,
15935 after_selection_id,
15936 limit,
15937 )
15938 .await?;
15939 Ok(NodeResponse::NativeSessionsPaged {
15940 route,
15941 page,
15942 })
15943 }
15944 NodeRequest::PreviewNativeSession {
15945 selection,
15946 message_limit,
15947 } => {
15948 if role != ClientRole::Operator {
15949 return Err(failure(
15950 NodeFailureCode::ObserverReadOnly,
15951 "native session preview requires an operator connection",
15952 ));
15953 }
15954 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15955 .contains(&message_limit)
15956 || selection.validate().is_err()
15957 {
15958 return Err(failure(
15959 NodeFailureCode::InvalidRequest,
15960 "native session preview request is invalid",
15961 ));
15962 }
15963 let preview = shared
15964 .preview_native_session(selection.clone(), message_limit)
15965 .await?;
15966 Ok(NodeResponse::NativeSessionPreviewed {
15967 selection,
15968 preview,
15969 })
15970 }
15971 NodeRequest::IndexNativeSession {
15972 selection,
15973 display_name,
15974 } => {
15975 shared.require_controller(connection_id, role)?;
15976 let record = shared
15977 .index_native_session(selection.clone(), display_name)
15978 .await?;
15979 Ok(NodeResponse::NativeSessionIndexed { selection, record })
15980 }
15981 NodeRequest::PreviewSessionRecord {
15982 record_id,
15983 message_limit,
15984 } => {
15985 if role != ClientRole::Operator {
15986 return Err(failure(
15987 NodeFailureCode::ObserverReadOnly,
15988 "native session preview requires an operator connection",
15989 ));
15990 }
15991 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15992 .contains(&message_limit)
15993 {
15994 return Err(failure(
15995 NodeFailureCode::InvalidRequest,
15996 "native session preview request is invalid",
15997 ));
15998 }
15999 let record = shared.record(&record_id)?;
16000 let working_directory = shared.workspace_root(&record.workspace_id)?;
16001 if !platform::roots_equal(
16002 &working_directory,
16003 windows_path_text(&record.canonical_root),
16004 ) {
16005 return Err(failure(
16006 NodeFailureCode::SessionRecordConflict,
16007 "managed session workspace root changed; refusing to preview another directory",
16008 ));
16009 }
16010 let identity = record.provider_session.clone().ok_or_else(|| {
16011 failure(
16012 NodeFailureCode::BackendOperationFailed,
16013 "native session preview is unavailable for this session record",
16014 )
16015 })?;
16016 if identity.key != ProviderSessionKey::SessionId {
16017 return Err(failure(
16018 NodeFailureCode::BackendOperationFailed,
16019 "native session preview is unavailable for this session identity",
16020 ));
16021 }
16022 let preview = shared
16023 .preview_native_session_by_session_id(
16024 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16025 )
16026 .await?;
16027 if preview.session_id != identity.id {
16028 return Err(failure(
16029 NodeFailureCode::SessionRecordConflict,
16030 "native session preview identity changed while loading",
16031 ));
16032 }
16033 shared.revalidate_session_record_preview(&record, &identity)?;
16034 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16035 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16036 record_id: record_id.clone(),
16037 summary: SessionHistorySummaryV1::from(&preview),
16038 });
16039 Ok(NodeResponse::SessionRecordPreviewed {
16040 record_id,
16041 preview,
16042 })
16043 }
16044 NodeRequest::AcquireController { lease_ms } => {
16045 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16046 Ok(NodeResponse::Controller { controller: Some(controller) })
16047 }
16048 NodeRequest::ReleaseController => {
16049 shared.release_controller(connection_id);
16050 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16051 }
16052 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16053 shared.require_controller(connection_id, role)?;
16054 let root = require_windows_path(root)?;
16055 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16056 let workspace = shared.register_workspace(workspace_id, root).await?;
16057 Ok(NodeResponse::WorkspaceRegistered { workspace })
16058 }
16059 NodeRequest::CreateStandaloneWorkspace {
16060 workspace_id,
16061 root,
16062 initial_branch,
16063 } => {
16064 shared.require_controller(connection_id, role)?;
16065 let root = require_windows_path(root)?;
16066 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16067 let workspace = shared
16068 .create_standalone_workspace(workspace_id, root, initial_branch)
16069 .await?;
16070 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16071 }
16072 NodeRequest::UnregisterWorkspace { workspace_id } => {
16073 shared.require_controller(connection_id, role)?;
16074 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16075 shared.unregister_workspace(&workspace_id)?;
16076 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16077 }
16078 NodeRequest::CreateWorktree {
16079 source_workspace_id,
16080 workspace_id,
16081 target_root,
16082 branch,
16083 base,
16084 } => {
16085 shared.require_controller(connection_id, role)?;
16086 let target_root = require_windows_path(target_root)?;
16087 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16088 let (worktree, workspace) = shared
16089 .create_worktree(
16090 source_workspace_id,
16091 workspace_id,
16092 target_root,
16093 branch,
16094 base,
16095 )
16096 .await?;
16097 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16098 }
16099 NodeRequest::RemoveWorktree {
16100 source_workspace_id,
16101 target_root,
16102 } => {
16103 shared.require_controller(connection_id, role)?;
16104 let native_target_root = require_windows_path(target_root.clone())?;
16105 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16106 let workspace_id = shared
16107 .remove_worktree(source_workspace_id, native_target_root)
16108 .await?;
16109 Ok(NodeResponse::WorktreeRemoved {
16110 target_root,
16111 workspace_id,
16112 })
16113 }
16114 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16115 shared.require_controller(connection_id, role)?;
16116 if !terminal_size.is_valid() {
16117 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16118 }
16119 if let Some(prompt) = initial_prompt.as_deref() {
16120 validate_node_text("spawn initial prompt", prompt)?;
16121 }
16122 match shared
16123 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16124 .await
16125 {
16126 Ok(session) => Ok(NodeResponse::SpawnAccepted { session }),
16127 Err(err) => {
16128 tracing::warn!(
16129 %provider,
16130 %workspace_id,
16131 ?mode,
16132 code = ?err.code,
16133 message = %err.message,
16134 "session spawn refused"
16135 );
16136 Err(err)
16137 }
16138 }
16139 }
16140 NodeRequest::SpawnSpec { spec } => {
16141 shared.require_controller(connection_id, role)?;
16142 let receipt = shared.spawn_from_spec(spec).await?;
16143 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16144 }
16145 NodeRequest::SpawnManagedWorktree { request } => {
16146 shared.require_controller(connection_id, role)?;
16147 let receipt = shared.spawn_managed_worktree(request).await?;
16148 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16149 }
16150 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16151 shared.require_controller(connection_id, role)?;
16152 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16153 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16154 }
16155 NodeRequest::CleanupManagedWorktree { lease_id } => {
16156 shared.require_controller(connection_id, role)?;
16157 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16158 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16159 }
16160 NodeRequest::DiscoverHistory { session, limit } => {
16161 controlled_session(shared, connection_id, role, &session)?;
16162 let candidates = shared.discover_history(&session, limit).await?;
16163 Ok(NodeResponse::HistoryDiscovered {
16164 session,
16165 candidates,
16166 })
16167 }
16168 NodeRequest::LoadHistory {
16169 session,
16170 candidate_id,
16171 } => {
16172 controlled_session(shared, connection_id, role, &session)?;
16173 let loaded = shared
16174 .load_history(&session, candidate_id)
16175 .await?;
16176 Ok(NodeResponse::HistoryLoaded {
16177 session,
16178 session_id: loaded.session_id,
16179 message_count: loaded.message_count,
16180 completed_turn_count: loaded.completed_turn_count,
16181 })
16182 }
16183 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16184 shared.require_controller(connection_id, role)?;
16185 let context = shared
16186 .export_context_pack_for_session_record(&record_id, &session, false)
16187 .await?;
16188 Ok(NodeResponse::ContextPackForSessionRecordExported {
16189 record_id,
16190 session,
16191 context,
16192 })
16193 }
16194 NodeRequest::ExportContextPack { session } => {
16195 controlled_session(shared, connection_id, role, &session)?;
16196 let context = shared.export_context_pack(&session).await?;
16197 Ok(NodeResponse::ContextPackExported { context })
16198 }
16199 NodeRequest::ForgetContextPack { context_id } => {
16200 shared.require_controller(connection_id, role)?;
16201 shared.forget_context_pack(&context_id)?;
16202 Ok(NodeResponse::ContextPackForgotten { context_id })
16203 }
16204 NodeRequest::ResolveDurableContextPack { context_id } => {
16205 let context = shared
16209 .context_catalog
16210 .read()
16211 .unwrap_or_else(|poisoned| poisoned.into_inner())
16212 .get(&context_id)
16213 .map(|pack| pack.receipt().clone())
16214 .ok_or_else(|| {
16215 failure(
16216 NodeFailureCode::UnknownContextPack,
16217 "context pack is not present",
16218 )
16219 })?;
16220 Ok(NodeResponse::DurableContextPackResolved { context })
16221 }
16222 NodeRequest::ReadContextPack { digest } => {
16223 let (id, byte_len, bytes) = shared
16228 .context_catalog
16229 .read()
16230 .unwrap_or_else(|poisoned| poisoned.into_inner())
16231 .get_by_digest(&digest)
16232 .map(|pack| {
16233 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16234 })
16235 .ok_or_else(|| {
16236 failure(
16237 NodeFailureCode::UnknownContextPack,
16238 &format!("context pack {digest} not on this node"),
16239 )
16240 })?;
16241 tracing::debug!(
16242 digest = digest.as_str(),
16243 byte_len,
16244 "read context pack bytes",
16245 );
16246 Ok(NodeResponse::ContextPackBytesRead {
16247 pack: ContextPackBytesRead {
16248 digest,
16249 id,
16250 byte_len,
16251 bytes,
16252 },
16253 })
16254 }
16255 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16256 let provider = controlled_session(shared, connection_id, role, &session)?;
16257 if let Some(prompt) = initial_prompt.as_deref() {
16258 validate_node_text("resume initial prompt", prompt)?;
16259 }
16260 let runtime_requirement = if initial_prompt.is_some() {
16261 ProviderRuntimeRequirement::ResumeWithPrompt
16262 } else {
16263 ProviderRuntimeRequirement::Resume
16264 };
16265 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16266 let runtime_policy = shared
16267 .admit_provider_runtime(&provider, runtime_requirement)
16268 .await?;
16269 let working_directory = shared.bound_workspace_root(&session)?;
16270 let request = ResumeLaunchRequest {
16271 working_directory,
16272 terminal_size,
16273 initial_prompt,
16274 };
16275 request
16276 .validate()
16277 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16278 let command = shared.prepare_command(ControlCommand::Resume {
16279 instance_id: session.session.instance_id,
16280 target: ResumeTarget::CurrentProvider,
16281 runtime_policy,
16282 request,
16283 });
16284 shared.arm_resume(&session, command.id, runtime_policy)?;
16285 let dispatch = shared.dispatch_envelope(command);
16286 if let Err(error) = dispatch {
16287 shared.clear_armed_resume(&session);
16288 return Err(error);
16289 }
16290 Ok(NodeResponse::Accepted)
16291 }
16292 NodeRequest::RenameSessionRecord {
16293 record_id,
16294 display_name,
16295 } => {
16296 shared.require_controller(connection_id, role)?;
16297 let record = shared.rename_session_record(&record_id, display_name)?;
16298 Ok(NodeResponse::SessionRecordUpdated { record })
16299 }
16300 NodeRequest::SetSessionTask {
16301 record_id,
16302 expected_revision,
16303 target,
16304 } => {
16305 shared.require_controller(connection_id, role)?;
16306 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16307 Ok(NodeResponse::SessionRecordUpdated { record })
16308 }
16309 NodeRequest::IndexProviderSession {
16310 workspace_id,
16311 provider,
16312 identity,
16313 display_name,
16314 } => {
16315 shared.require_controller(connection_id, role)?;
16316 let record = shared.index_provider_session(
16317 workspace_id,
16318 provider,
16319 identity,
16320 display_name,
16321 )?;
16322 Ok(NodeResponse::ProviderSessionIndexed { record })
16323 }
16324 NodeRequest::ResumeSessionRecord {
16325 record_id,
16326 terminal_size,
16327 initial_prompt,
16328 } => {
16329 shared.require_controller(connection_id, role)?;
16330 if let Some(prompt) = initial_prompt.as_deref() {
16331 validate_node_text("managed resume initial prompt", prompt)?;
16332 }
16333 let (record, session) = shared
16334 .resume_session_record(
16335 &record_id,
16336 terminal_size,
16337 initial_prompt,
16338 )
16339 .await?;
16340 Ok(NodeResponse::SessionRecordResumed { record, session })
16341 }
16342 NodeRequest::ForgetSessionRecord { record_id } => {
16343 shared.require_controller(connection_id, role)?;
16344 shared.forget_session_record(&record_id).await?;
16345 Ok(NodeResponse::SessionRecordForgotten { record_id })
16346 }
16347 NodeRequest::Prompt { session, text } => {
16348 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16349 validate_node_text("prompt", &text)?;
16350 shared.require_session_runtime_policy(
16351 &session,
16352 ProviderRuntimeRequirement::SemanticPrompt,
16353 )?;
16354 let framing = prompt_framing(&agent_id);
16355 shared
16356 .dispatch_input_bounded(
16357 &session,
16358 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16359 )
16360 .await?;
16361 Ok(NodeResponse::Accepted)
16362 }
16363 NodeRequest::Paste { session, text } => {
16364 controlled_session(shared, connection_id, role, &session)?;
16365 validate_node_text("paste", &text)?;
16366 shared.require_session_runtime_policy(
16367 &session,
16368 ProviderRuntimeRequirement::SemanticPrompt,
16369 )?;
16370 shared
16371 .dispatch_input_bounded(
16372 &session,
16373 InputAction::InsertDraft(PromptPayload {
16374 text,
16375 framing: PromptFraming::BracketedPaste,
16376 }),
16377 )
16378 .await?;
16379 Ok(NodeResponse::Accepted)
16380 }
16381 NodeRequest::Input { session, text } => {
16382 controlled_session(shared, connection_id, role, &session)?;
16383 validate_node_text("terminal input", &text)?;
16384 shared
16385 .dispatch_input_bounded(
16386 &session,
16387 InputAction::TerminalText(TerminalText { text }),
16388 )
16389 .await?;
16390 Ok(NodeResponse::Accepted)
16391 }
16392 NodeRequest::TerminalBytes { session, bytes } => {
16393 controlled_session(shared, connection_id, role, &session)?;
16394 let action = terminal_bytes_action(bytes)?;
16395 shared
16396 .dispatch_input_bounded(&session, action)
16397 .await?;
16398 Ok(NodeResponse::Accepted)
16399 }
16400 NodeRequest::TerminalControl { session, control } => {
16401 controlled_session(shared, connection_id, role, &session)?;
16402 shared
16403 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16404 .await?;
16405 Ok(NodeResponse::Accepted)
16406 }
16407 NodeRequest::Resize { session, size } => {
16408 controlled_session(shared, connection_id, role, &session)?;
16409 if !size.is_valid() {
16410 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16411 }
16412 shared.dispatch_resize_bounded(&session, size).await?;
16413 Ok(NodeResponse::Accepted)
16414 }
16415 NodeRequest::Interrupt { session } => {
16416 controlled_session(shared, connection_id, role, &session)?;
16417 shared
16418 .dispatch_input_bounded(
16419 &session,
16420 InputAction::TerminalControl(TerminalControl::Interrupt),
16421 )
16422 .await?;
16423 Ok(NodeResponse::Accepted)
16424 }
16425 NodeRequest::Stop { session, force } => {
16426 controlled_session(shared, connection_id, role, &session)?;
16427 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16428 Ok(NodeResponse::Accepted)
16429 }
16430 NodeRequest::Remove { session } => {
16431 controlled_session(shared, connection_id, role, &session)?;
16432 shared.remove_session(&session).await?;
16433 Ok(NodeResponse::Accepted)
16434 }
16435 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16436 shared.require_controller(connection_id, role)?;
16437 let snapshot = shared.require_acp_session(&session)?;
16438 let interaction = snapshot
16439 .provider
16440 .interactions
16441 .iter()
16442 .find(|interaction| {
16443 interaction_correlation(
16444 session.session.instance_id,
16445 session.session.generation,
16446 interaction.id.0,
16447 ) == correlation_id
16448 })
16449 .ok_or_else(|| failure(
16450 NodeFailureCode::InvalidRequest,
16451 "unknown interaction correlation",
16452 ))?;
16453 if interaction.status != ProviderInteractionStatus::Pending {
16454 return Err(failure(
16455 NodeFailureCode::BackendOperationFailed,
16456 "interaction has already been resolved",
16457 ));
16458 }
16459 response
16460 .validate_for(interaction.interaction_kind)
16461 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16462 let interaction_id = interaction.id;
16463 let response_kind = response.kind();
16464 let response_outcome = response.outcome();
16465 shared.dispatch(ControlCommand::ResolveInteraction {
16466 instance_id: session.session.instance_id,
16467 generation: session.session.generation,
16468 interaction_id,
16469 response,
16470 })?;
16471 tracing::info!(
16477 node_id = %shared.node_id,
16478 session = ?session.session,
16479 correlation_id = %correlation_id,
16480 interaction_id = interaction_id.0,
16481 response_kind = ?response_kind,
16482 outcome = ?response_outcome,
16483 "interaction resolved",
16484 );
16485 Ok(NodeResponse::Accepted)
16486 }
16487 NodeRequest::SetSessionMode { session, mode_id } => {
16488 shared.require_controller(connection_id, role)?;
16489 shared.require_acp_session(&session)?;
16490 shared.dispatch(ControlCommand::SetSessionMode {
16491 instance_id: session.session.instance_id,
16492 mode_id,
16493 })?;
16494 Ok(NodeResponse::Accepted)
16495 }
16496 NodeRequest::SetSessionConfigOption {
16497 session,
16498 option_id,
16499 value_json,
16500 } => {
16501 shared.require_controller(connection_id, role)?;
16502 shared.require_acp_session(&session)?;
16503 shared.dispatch(ControlCommand::SetSessionConfigOption {
16504 instance_id: session.session.instance_id,
16505 option_id,
16506 value_json,
16507 })?;
16508 Ok(NodeResponse::Accepted)
16509 }
16510 NodeRequest::SetSessionModel { session, model_id } => {
16511 shared.require_controller(connection_id, role)?;
16512 shared.require_acp_session(&session)?;
16513 shared.dispatch(ControlCommand::SetSessionModel {
16514 instance_id: session.session.instance_id,
16515 model_id,
16516 })?;
16517 Ok(NodeResponse::Accepted)
16518 }
16519 NodeRequest::Shutdown => {
16520 shared.require_controller(connection_id, role)?;
16521 shared.begin_shutdown_locked().await?;
16522 Ok(NodeResponse::ShuttingDown)
16523 }
16524 }
16525}
16526
16527fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16528 shared.require_controller(connection_id, role)?;
16529 shared.validate_address(session)
16530}
16531
16532fn select_history_candidate(
16538 candidates: &[HistoryCandidateSummary],
16539 provider_session_id: Option<&str>,
16540) -> Result<String, NodeFailure> {
16541 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16542 Some(id) => candidate.session_id_hint == id,
16543 None => true,
16544 });
16545 let candidate = matches.next().ok_or_else(|| {
16546 failure(
16547 NodeFailureCode::SessionRecordConflict,
16548 if provider_session_id.is_some() {
16549 "no history candidate matches the managed provider session"
16550 } else {
16551 "no history candidate exists for the managed session"
16552 },
16553 )
16554 })?;
16555 if matches.next().is_some() {
16556 return Err(failure(
16557 NodeFailureCode::SessionRecordConflict,
16558 if provider_session_id.is_some() {
16559 "history identity is ambiguous for the managed provider session"
16560 } else {
16561 "history is ambiguous without a verified provider session identity"
16562 },
16563 ));
16564 }
16565 Ok(candidate.id.clone())
16566}
16567
16568fn resolved_spawn_profile_summary(
16569 profile: &SpawnProfileDefaults,
16570 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16571) -> Option<SpawnProfileSummary> {
16572 let environment_profile = match profile.environment_profile_id.as_ref() {
16573 None => None,
16574 Some(profile_id) => {
16575 let binding = environment_profiles.get(profile_id)?;
16576 if binding.provider != profile.provider
16577 || binding.native_profile_id(profile.mode).is_none()
16578 {
16579 return None;
16580 }
16581 Some(ResolvedEnvironmentProfileReceipt {
16582 profile_id: binding.id.clone(),
16583 profile_revision: binding.revision.clone(),
16584 network_allowlist: None,
16585 browser_profile_id: None,
16586 })
16587 }
16588 };
16589 Some(SpawnProfileSummary {
16590 id: profile.profile_id.clone(),
16591 revision: profile.revision.clone(),
16592 environment_profile,
16593 })
16594}
16595
16596fn session_record_export_target_matches(
16615 expected: &ManagedSessionRecord,
16616 current: &ManagedSessionRecord,
16617 session: &SessionAddress,
16618 allow_clean_detachment: bool,
16619) -> bool {
16620 if current == expected && current.active_session.as_ref() == Some(session) {
16621 return current.state == ManagedSessionState::Live;
16622 }
16623 allow_clean_detachment
16624 && current.state == ManagedSessionState::Dormant
16625 && current.active_session.is_none()
16626 && current.record_id == expected.record_id
16627 && current.display_name == expected.display_name
16628 && current.provider == expected.provider
16629 && current.mode == expected.mode
16630 && current.workspace_id == expected.workspace_id
16631 && current.canonical_root == expected.canonical_root
16632 && current.provider_session == expected.provider_session
16633 && current.environment_profile == expected.environment_profile
16634 && current.bundle == expected.bundle
16635 && current.context_id == expected.context_id
16636 && current.context == expected.context
16637 && current.exported_context == expected.exported_context
16638 && current.task_binding == expected.task_binding
16639 && current.created_at_unix_ms == expected.created_at_unix_ms
16640 && current.last_error == expected.last_error
16641}
16642
16643fn session_record_context_export_binding_is_exact(
16644 expected: &ManagedSessionRecord,
16645 current: &ManagedSessionRecord,
16646 identity: Option<&ProviderSessionIdentity>,
16647 session: &SessionAddress,
16648 binding: &SessionBinding,
16649 provider: &AgentId,
16650 current_root: &str,
16651 allow_clean_detachment: bool,
16652) -> bool {
16653 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16654 && current.provider_session.as_ref() == identity
16655 && current.workspace_id == session.workspace_id
16656 && provider == ¤t.provider
16657 && binding.workspace_id == session.workspace_id
16658 && binding.generation == session.session.generation
16659 && binding.record_id.as_ref() == Some(¤t.record_id)
16660 && platform::roots_equal(
16661 current_root,
16662 windows_path_text(¤t.canonical_root),
16663 )
16664}
16665
16666fn session_record_context_export_source_is_usable(
16667 record: &ManagedSessionRecord,
16668 status: &SessionStatus,
16669 allow_clean_detachment: bool,
16670) -> bool {
16671 (record.state == ManagedSessionState::Live
16672 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16673 && context_pack_source_status_is_usable(status)
16674}
16675
16676fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16677 PromptFraming::BracketedPaste
16678}
16679
16680fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16681 if text.len() > MAX_NODE_TEXT_BYTES {
16682 return Err(failure(
16683 NodeFailureCode::InvalidRequest,
16684 &format!(
16685 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16686 text.len(),
16687 ),
16688 ));
16689 }
16690 Ok(())
16691}
16692
16693fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16694 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16695 return Err(failure(
16696 NodeFailureCode::InvalidRequest,
16697 &format!(
16698 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16699 bytes.len(),
16700 ),
16701 ));
16702 }
16703 Ok(())
16704}
16705
16706fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16707 validate_terminal_bytes(&bytes)?;
16708 Ok(InputAction::TerminalBytes(bytes))
16709}
16710
16711fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16712 if root.is_empty()
16713 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16714 || root.chars().any(char::is_control)
16715 {
16716 return Err(failure(
16717 NodeFailureCode::InvalidWorkspaceRoot,
16718 &format!(
16719 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16720 ),
16721 ));
16722 }
16723 Ok(())
16724}
16725
16726fn canonical_standalone_workspace_candidate(
16727 workspace_id: &WorkspaceId,
16728 root: &str,
16729) -> Result<String, NodeServerError> {
16730 let path = Path::new(root);
16731 match std::fs::symlink_metadata(path) {
16732 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16733 .map(|workspace| workspace.canonical_root),
16734 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16735 if !path.is_absolute() {
16736 return Err(NodeServerError::InvalidWorkspaceRoot {
16737 workspace_id: workspace_id.clone(),
16738 path: root.to_owned(),
16739 message: "path is not absolute".to_owned(),
16740 });
16741 }
16742 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16743 workspace_id: workspace_id.clone(),
16744 path: root.to_owned(),
16745 message: "path has no standalone directory name".to_owned(),
16746 })?;
16747 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16748 workspace_id: workspace_id.clone(),
16749 path: root.to_owned(),
16750 message: "path has no parent directory".to_owned(),
16751 })?;
16752 let canonical = std::fs::canonicalize(parent)
16753 .map(|parent| parent.join(name))
16754 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16755 workspace_id: workspace_id.clone(),
16756 path: root.to_owned(),
16757 message: error.to_string(),
16758 })?;
16759 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16760 NodeServerError::InvalidWorkspaceRoot {
16761 workspace_id: workspace_id.clone(),
16762 path: path.to_string_lossy().into_owned(),
16763 message: "canonical path is not valid Unicode".to_owned(),
16764 }
16765 })?;
16766 let canonical_root = platform::normalize_canonical_root(canonical_root);
16767 validate_workspace_root(workspace_id, &canonical_root)?;
16768 Ok(canonical_root)
16769 }
16770 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16771 workspace_id: workspace_id.clone(),
16772 path: root.to_owned(),
16773 message: error.to_string(),
16774 }),
16775 }
16776}
16777
16778fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16779 let Some(revision) = revision else {
16780 return Ok(());
16781 };
16782 if revision.is_empty()
16783 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16784 || revision.chars().any(char::is_control)
16785 {
16786 return Err(failure(
16787 NodeFailureCode::InvalidRequest,
16788 &format!(
16789 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16790 ),
16791 ));
16792 }
16793 Ok(())
16794}
16795
16796fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16797 let code = match error.kind {
16798 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16799 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16800 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16801 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16802 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16803 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16804 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16805 };
16806 failure(code, &error.message)
16807}
16808
16809fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16810 let code = match error.kind {
16811 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16812 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16813 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16814 NodeFailureCode::BackendOperationFailed
16815 }
16816 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16817 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16818 }
16819 };
16820 failure(code, error.message)
16821}
16822
16823fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16824 let (code, message) = match error.kind {
16825 GitWorktreeErrorKind::Invalid => (
16826 NodeFailureCode::InvalidRequest,
16827 "managed Git input is invalid",
16828 ),
16829 GitWorktreeErrorKind::NotRepository => (
16830 NodeFailureCode::NotGitRepository,
16831 "managed source is not a Git repository",
16832 ),
16833 GitWorktreeErrorKind::Conflict => (
16834 NodeFailureCode::WorktreeConflict,
16835 "managed Git identity conflicts with existing repository state",
16836 ),
16837 GitWorktreeErrorKind::Protected => (
16838 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16839 "managed worktree ownership validation failed",
16840 ),
16841 GitWorktreeErrorKind::Dirty => (
16842 NodeFailureCode::WorktreeDirty,
16843 "managed worktree contains uncommitted changes",
16844 ),
16845 GitWorktreeErrorKind::Locked => (
16846 NodeFailureCode::WorktreeLocked,
16847 "managed worktree is locked",
16848 ),
16849 GitWorktreeErrorKind::Failed => (
16850 NodeFailureCode::BackendOperationFailed,
16851 "managed Git operation failed",
16852 ),
16853 };
16854 NodeFailure { code, message: message.to_owned() }
16855}
16856
16857fn managed_spawn_receipt(
16858 resolved: &ResolvedSpawnSpec,
16859 incarnation_id: NodeIncarnationId,
16860 session: SessionAddress,
16861 lease: ManagedWorktreeLeaseSnapshot,
16862 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16863 bundle: Option<ResolvedBundleReceipt>,
16864 context: Option<ResolvedContextPackReceipt>,
16865) -> ManagedWorktreeSpawnReceipt {
16866 let mut managed = resolved.clone();
16867 managed.target.worktree_id = Some(lease.workspace_id.clone());
16868 ManagedWorktreeSpawnReceipt {
16869 spawn: managed.receipt_with_materialization(
16870 incarnation_id,
16871 session,
16872 environment_profile,
16873 bundle,
16874 context,
16875 ),
16876 lease,
16877 }
16878}
16879
16880fn validate_selected_worktree(
16881 source_root: &str,
16882 selected_root: &str,
16883 worktrees: &[NativeGitWorktreeSnapshot],
16884) -> Result<(), NodeFailure> {
16885 if !worktrees
16886 .iter()
16887 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16888 {
16889 return Err(failure(
16890 NodeFailureCode::WorktreeProtected,
16891 "source workspace is not an authoritative non-bare Git worktree root",
16892 ));
16893 }
16894 if !worktrees.iter().any(|worktree| {
16895 worktree_paths_equal(&worktree.path, selected_root)
16896 && !worktree.is_main
16897 && !worktree.is_bare
16898 }) {
16899 return Err(failure(
16900 NodeFailureCode::WorktreeProtected,
16901 "selected workspace is not an eligible Git worktree in the source repository",
16902 ));
16903 }
16904 Ok(())
16905}
16906
16907fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16908 let code = match error.kind() {
16909 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16910 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16911 NodeFailureCode::RepositoryPathUnsafe
16912 }
16913 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16914 WorkspaceFileReadErrorKind::NotRegularFile => {
16915 NodeFailureCode::RepositoryFileNotRegular
16916 }
16917 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16918 NodeFailureCode::RepositoryFileReadFailed
16919 }
16920 WorkspaceFileReadErrorKind::RevisionConflict => {
16921 NodeFailureCode::RepositoryFileRevisionConflict
16922 }
16923 WorkspaceFileReadErrorKind::AlreadyExists
16924 | WorkspaceFileReadErrorKind::ParentNotFound
16925 | WorkspaceFileReadErrorKind::ParentNotDirectory
16926 | WorkspaceFileReadErrorKind::Canceled => {
16927 NodeFailureCode::RepositoryFileReadFailed
16928 }
16929 };
16930 failure(code, "repository file read failed")
16931}
16932
16933fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16934 let code = match error.kind() {
16935 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16936 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16937 NodeFailureCode::RepositoryPathUnsafe
16938 }
16939 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16940 WorkspaceFileReadErrorKind::NotRegularFile => {
16941 NodeFailureCode::RepositoryFileNotRegular
16942 }
16943 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16944 NodeFailureCode::RepositoryFileWriteFailed
16945 }
16946 WorkspaceFileReadErrorKind::RevisionConflict => {
16947 NodeFailureCode::RepositoryFileRevisionConflict
16948 }
16949 WorkspaceFileReadErrorKind::AlreadyExists
16950 | WorkspaceFileReadErrorKind::ParentNotFound
16951 | WorkspaceFileReadErrorKind::ParentNotDirectory
16952 | WorkspaceFileReadErrorKind::Canceled => {
16953 NodeFailureCode::RepositoryFileWriteFailed
16954 }
16955 };
16956 failure(code, "repository file write failed")
16957}
16958
16959async fn settle_workspace_entry_create<T>(
16960 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16961 commit_state: Arc<AtomicU8>,
16962 deadline: Duration,
16963 timeout_message: &'static str,
16964 task_failure_message: &'static str,
16965) -> Result<T, NodeFailure> {
16966 let joined = match timeout(deadline, &mut task).await {
16967 Ok(joined) => joined,
16968 Err(_) => {
16969 if commit_state
16970 .compare_exchange(
16971 WORKSPACE_ENTRY_CREATE_PENDING,
16972 WORKSPACE_ENTRY_CREATE_CANCELED,
16973 Ordering::AcqRel,
16974 Ordering::Acquire,
16975 )
16976 .is_ok()
16977 {
16978 return Err(failure(
16979 NodeFailureCode::RepositoryEntryCreateTimedOut,
16980 timeout_message,
16981 ));
16982 }
16983 debug_assert_eq!(
16984 commit_state.load(Ordering::Acquire),
16985 WORKSPACE_ENTRY_CREATE_COMMITTING,
16986 );
16987 task.await
16988 }
16989 };
16990 joined
16991 .map_err(|_| {
16992 failure(
16993 NodeFailureCode::RepositoryEntryCreateFailed,
16994 task_failure_message,
16995 )
16996 })?
16997 .map_err(workspace_entry_create_failure)
16998}
16999
17000fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17001 let code = match error.kind() {
17002 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17003 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17004 NodeFailureCode::RepositoryPathUnsafe
17005 }
17006 WorkspaceFileReadErrorKind::AlreadyExists => {
17007 NodeFailureCode::RepositoryEntryAlreadyExists
17008 }
17009 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17010 NodeFailureCode::RepositoryParentNotFound
17011 }
17012 WorkspaceFileReadErrorKind::ParentNotDirectory
17013 | WorkspaceFileReadErrorKind::NotRegularFile => {
17014 NodeFailureCode::RepositoryParentNotDirectory
17015 }
17016 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17017 NodeFailureCode::RepositoryEntryCreateFailed
17018 }
17019 WorkspaceFileReadErrorKind::RevisionConflict => {
17020 NodeFailureCode::RepositoryEntryCreateFailed
17021 }
17022 WorkspaceFileReadErrorKind::Canceled => {
17023 NodeFailureCode::RepositoryEntryCreateTimedOut
17024 }
17025 };
17026 failure(code, "repository entry create failed")
17027}
17028
17029fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17030 let value = digest(&SHA256, bytes)
17031 .as_ref()
17032 .iter()
17033 .map(|byte| format!("{byte:02x}"))
17034 .collect();
17035 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17036}
17037
17038fn managed_spawn_request_digest_v2(
17039 request: &ManagedWorktreeSpawnRequestV2,
17040) -> Result<String, NodeFailure> {
17041 let encoded = serde_json::to_vec(request).map_err(|_| {
17042 failure(
17043 NodeFailureCode::InvalidRequest,
17044 "managed worktree V2 request could not be canonicalized",
17045 )
17046 })?;
17047 let mut value = String::with_capacity(71);
17048 value.push_str("sha256:");
17049 for byte in digest(&SHA256, &encoded).as_ref() {
17050 use std::fmt::Write as _;
17051 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17052 }
17053 Ok(value)
17054}
17055
17056fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17057 let code = match error {
17058 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17059 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17060 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17061 NativeSessionPreviewError::UnsupportedProvider
17062 | NativeSessionPreviewError::PreviewUnavailable
17063 | NativeSessionPreviewError::SessionNotFound
17064 | NativeSessionPreviewError::AmbiguousSession => {
17065 NodeFailureCode::BackendOperationFailed
17066 }
17067 };
17068 failure(code, "native session preview failed")
17069}
17070
17071fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17072 let code = match error.kind() {
17073 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17074 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17075 };
17076 failure(code, "host directory browse failed")
17077}
17078
17079fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17080 let code = match error {
17081 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17082 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17083 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17084 NodeFailureCode::DeliveryStageConflict
17085 }
17086 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17087 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17088 NodeFailureCode::DeliveryChunkOutOfOrder
17089 }
17090 DeliveryStoreError::BlobDigestMismatch => {
17091 NodeFailureCode::DeliveryBlobDigestMismatch
17092 }
17093 DeliveryStoreError::BundleDigestMismatch => {
17094 NodeFailureCode::DeliveryBundleDigestMismatch
17095 }
17096 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17097 DeliveryStoreError::Unavailable
17098 | DeliveryStoreError::Corrupt
17099 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17100 };
17101 failure(code, "delivery operation failed")
17102}
17103
17104fn validate_workspace_root(
17105 workspace_id: &WorkspaceId,
17106 canonical_root: &str,
17107) -> Result<(), NodeServerError> {
17108 #[cfg(windows)]
17109 if canonical_root.starts_with(r"\\") {
17110 return Err(NodeServerError::InvalidWorkspaceRoot {
17111 workspace_id: workspace_id.clone(),
17112 path: canonical_root.to_owned(),
17113 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17114 });
17115 }
17116 if !platform::workspace_root_supported(canonical_root)
17117 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17118 {
17119 return Err(NodeServerError::InvalidWorkspaceRoot {
17120 workspace_id: workspace_id.clone(),
17121 path: canonical_root.to_owned(),
17122 message: "canonical path must be absolute, bounded, and free of control characters"
17123 .to_owned(),
17124 });
17125 }
17126 Ok(())
17127}
17128
17129fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17130 NodeFailure {
17131 code,
17132 message: node_failure_category(code).to_owned(),
17133 }
17134}
17135
17136fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17137 let code = match error {
17138 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17139 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17140 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17141 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17142 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17143 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17144 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17145 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17146 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17147 };
17148 failure(code, "harness MCP proxy operation failed")
17149}
17150
17151static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17155
17156fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17163 let trace = launch.trace.as_ref()?;
17164 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17165 if trace_dir.is_empty() {
17166 return None;
17167 }
17168 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17169 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17170 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17171}
17172
17173fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17174 let remaining = deadline.saturating_duration_since(Instant::now());
17175 if remaining.is_zero() {
17176 Err(failure(
17177 NodeFailureCode::SpawnDeadlineExceeded,
17178 "spawn deadline elapsed",
17179 ))
17180 } else {
17181 Ok(remaining)
17182 }
17183}
17184
17185fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17186 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17187 match deadline {
17188 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17189 None => Ok(bounded),
17190 }
17191}
17192
17193fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17194 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17195 failure(
17196 NodeFailureCode::SpawnDeadlineExceeded,
17197 "spawn dispatch exceeded the spawn deadline",
17198 )
17199 } else {
17200 error
17201 }
17202}
17203
17204fn spawn_runtime_capabilities(
17205 capabilities: &SpawnRequiredCapabilities,
17206) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17207 capabilities
17208 .iter()
17209 .map(|capability| match capability.as_str() {
17210 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17211 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17212 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17213 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17214 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17215 }
17216 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17217 _ => Err(failure(
17218 NodeFailureCode::UnsupportedSpawnCapability,
17219 "spawn required capability is unknown",
17220 )),
17221 })
17222 .collect()
17223}
17224
17225fn persistence_failure(_error: io::Error) -> NodeFailure {
17226 NodeFailure {
17227 code: NodeFailureCode::BackendOperationFailed,
17228 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17229 }
17230}
17231
17232fn node_failure_category(code: NodeFailureCode) -> &'static str {
17233 match code {
17234 NodeFailureCode::InvalidRequest => "invalid-request",
17235 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17236 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17237 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17238 NodeFailureCode::ReservationConflict => "reservation-conflict",
17239 NodeFailureCode::ReservationExpired => "reservation-expired",
17240 NodeFailureCode::BindingMismatch => "binding-mismatch",
17241 NodeFailureCode::NotActivated => "not-activated",
17242 NodeFailureCode::CallNotFound => "call-not-found",
17243 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17244 NodeFailureCode::ResponseTooLarge => "response-too-large",
17245 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17246 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17247 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17248 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17249 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17250 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17251 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17252 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17253 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17254 NodeFailureCode::Unauthorized => "unauthorized",
17255 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17256 NodeFailureCode::ControllerBusy => "controller-busy",
17257 NodeFailureCode::ControllerRequired => "controller-required",
17258 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17259 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17260 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17261 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17262 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17263 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17264 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17265 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17266 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17267 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17268 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17269 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17270 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17271 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17272 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17273 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17274 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17275 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17276 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17277 NodeFailureCode::GitReadFailed => "git-read-failed",
17278 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17279 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17280 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17281 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17282 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17283 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17284 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17285 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17286 NodeFailureCode::TurnInFlight => "turn-in-flight",
17287 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17288 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17289 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17290 "unsupported-network-allowlist-mapping"
17291 }
17292 NodeFailureCode::BrowserStationProbeUnavailable => {
17293 "browser-station-probe-unavailable"
17294 }
17295 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17296 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17297 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17298 "environment-profile-binding-mismatch"
17299 }
17300 NodeFailureCode::UnknownBundle => "unknown-bundle",
17301 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17302 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17303 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17304 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17305 NodeFailureCode::ContextPackMaterializationFailed => {
17306 "context-pack-materialization-failed"
17307 }
17308 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17309 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17310 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17311 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17312 NodeFailureCode::LastWorkspace => "last-workspace",
17313 NodeFailureCode::NotGitRepository => "not-git-repository",
17314 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17315 NodeFailureCode::WorktreeProtected => "worktree-protected",
17316 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17317 NodeFailureCode::WorktreeLocked => "worktree-locked",
17318 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17319 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17320 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17321 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17322 "managed-worktree-profile-revision-mismatch"
17323 }
17324 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17325 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17326 "standalone-workspace-recovery-required"
17327 }
17328 NodeFailureCode::UnknownSession => "unknown-session",
17329 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17330 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17331 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17332 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17333 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17334 NodeFailureCode::WorkspaceRegistrationRequired => {
17335 "workspace-registration-required"
17336 }
17337 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17338 NodeFailureCode::StaleGeneration => "stale-generation",
17339 NodeFailureCode::BackendBusy => "backend-busy",
17340 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17341 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17342 NodeFailureCode::ShuttingDown => "shutting-down",
17343 }
17344}
17345
17346fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17347 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17348}
17349
17350fn durable_state_load_error(error: io::Error) -> NodeServerError {
17351 let category = match session_registry::state_load_refusal(&error) {
17352 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17353 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17354 }
17355 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17356 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17357 }
17358 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17359 DURABLE_STATE_CONFLICT_ERROR
17360 }
17361 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17362 };
17363 durable_state_server_error(error, category)
17364}
17365
17366fn unix_time_ms() -> u64 {
17367 std::time::SystemTime::now()
17368 .duration_since(std::time::UNIX_EPOCH)
17369 .unwrap_or_default()
17370 .as_millis()
17371 .min(u64::MAX as u128) as u64
17372}
17373
17374fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17375 if record.provider_session.is_some() {
17376 ManagedSessionState::Dormant
17377 } else {
17378 ManagedSessionState::Unavailable
17379 }
17380}
17381
17382fn active_record_state(
17383 identity_admitted: bool,
17384 provider_session_is_known: bool,
17385) -> ManagedSessionState {
17386 if identity_admitted && !provider_session_is_known {
17387 ManagedSessionState::IdentityPending
17388 } else {
17389 ManagedSessionState::Live
17390 }
17391}
17392
17393fn managed_worktree_record_holders(
17394 records: &[ManagedSessionRecord],
17395) -> Vec<(SessionRecordId, WorkspaceId)> {
17396 records
17397 .iter()
17398 .filter(|record| record.provider_session.is_some())
17399 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17400 .collect()
17401}
17402
17403fn managed_spawn_record_ownership(
17404 binding: &SessionBinding,
17405 effective_runtime_policy: ProviderRuntimePolicy,
17406) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17407 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17408 if effective_runtime_policy.provider_session_identity {
17409 (binding.record_id.clone(), None)
17410 } else {
17411 (None, binding.record_id.clone())
17412 }
17413}
17414
17415#[derive(Debug, Error)]
17416pub enum NodeServerError {
17417 #[error("node endpoint must be a bounded absolute local transport path")]
17418 InvalidEndpoint,
17419 #[error("node access token must contain 1..=4096 bytes")]
17420 InvalidAccessToken,
17421 #[error("node HTTP observer must listen on a loopback address: {0}")]
17422 InvalidApiListen(std::net::SocketAddr),
17423 #[error("node browser bridge must listen on a loopback address: {0}")]
17424 InvalidBridgeListen(std::net::SocketAddr),
17425 #[error("node browser bridge token must contain 1..=4096 bytes")]
17426 InvalidBridgeToken,
17427 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17428 BridgeUnderlayRequiresBridgeListen,
17429 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17430 BridgeUnderlayRequiresBridgeToken,
17431 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17432 InvalidBridgeUnderlayToken,
17433 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17434 InvalidBridgeUnderlayKey,
17435 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17436 InvalidCallHome(std::net::SocketAddr),
17437 #[error("node could not reach its call-home relay: {0}")]
17438 CallHomeTransport(#[source] std::io::Error),
17439 #[error("node durable state path must be an absolute file path: {0}")]
17440 InvalidStatePath(String),
17441 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17442 InvalidHarnessMcpHelper,
17443 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17444 InvalidNetworkAllowlistCatalogPath(String),
17445 #[error("network allowlist catalog could not be read: {0}")]
17446 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17447 #[error("network allowlist catalog line {line}: {message}")]
17448 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17449 #[error("network allowlist catalog: {0}")]
17450 InvalidNetworkAllowlistCatalog(String),
17451 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17452 NetworkAllowlistCatalogCapacity { max: usize },
17453 #[error("the local state directory is unavailable; supply an explicit state path")]
17454 LocalStateDirectoryUnavailable,
17455 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17456 LocalRuntimeDirectoryUnavailable,
17457 #[error("node durable state failed: {0}")]
17458 DurableState(io::Error),
17459 #[error("node requires at least one configured workspace")]
17460 NoWorkspaces,
17461 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17462 InvalidWorkspaceRoot {
17463 workspace_id: WorkspaceId,
17464 path: String,
17465 message: String,
17466 },
17467 #[error("duplicate workspace ID: {0}")]
17468 DuplicateWorkspaceId(WorkspaceId),
17469 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17470 DuplicateWorkspaceRoot {
17471 first: WorkspaceId,
17472 second: WorkspaceId,
17473 root: String,
17474 },
17475 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17476 ManagedWorktreeProfileRequired(WorkspaceId),
17477 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17478 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17479 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17480 DuplicateManagedWorktreeProfile {
17481 workspace_id: WorkspaceId,
17482 profile_id: WorktreeProfileId,
17483 },
17484 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17485 ManagedWorktreeProfileCapacity {
17486 workspace_id: WorkspaceId,
17487 max: usize,
17488 },
17489 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17490 InvalidManagedWorktreeProfile {
17491 workspace_id: WorkspaceId,
17492 profile_id: WorktreeProfileId,
17493 message: String,
17494 },
17495 #[error("duplicate node environment profile: {0}")]
17496 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17497 #[error("node environment profile provider is unavailable: {0}")]
17498 EnvironmentProfileProviderUnavailable(AgentId),
17499 #[error("node environment profile capacity is {max}")]
17500 EnvironmentProfileCapacity { max: usize },
17501 #[error("node environment profiles reuse native profile ID '{0}'")]
17502 DuplicateNativeEnvironmentProfile(String),
17503 #[error("node native environment profile is invalid: {0}")]
17504 NativeEnvironmentProfile(String),
17505 #[error("node session-environment materialization root must be absolute")]
17506 InvalidSessionEnvironmentRoot,
17507 #[error("node session-environment materializer is unavailable")]
17508 SessionEnvironmentMaterializerRequired,
17509 #[error("node session-environment materializer failed to initialize")]
17510 SessionEnvironmentMaterializer,
17511 #[error("node bundle catalog is invalid: {0}")]
17512 BundleCatalog(String),
17513 #[error("node delivery store failed to initialize: {0}")]
17514 DeliveryStore(String),
17515 #[error("node context pack store failed to initialize")]
17516 ContextPackStore,
17517 #[error("active agent registry failed: {0}")]
17518 Registry(String),
17519 #[error("node provider contract manifest is invalid: {0}")]
17520 ProviderContractManifest(String),
17521 #[error("named pipe I/O failed: {0}")]
17522 Io(#[from] io::Error),
17523 #[error("node HTTP observer failed: {0}")]
17524 HttpApi(io::Error),
17525 #[error("node browser bridge failed: {0}")]
17526 BridgeApi(io::Error),
17527 #[error(transparent)]
17528 Frame(#[from] FrameError),
17529 #[error("node handshake failed: {0}")]
17530 Handshake(String),
17531 #[error("node authentication frame was not received before the bounded deadline")]
17532 AuthenticationTimedOut,
17533 #[error("node authentication primitive failed: {0}")]
17534 Authentication(String),
17535 #[error("node incarnation identity generation failed: {0}")]
17536 IncarnationIdentity(String),
17537 #[error("node pre-authentication limiter is closed")]
17538 PreauthClosed,
17539 #[error("node authenticated connection limit was reached")]
17540 AuthenticatedConnectionLimit,
17541 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17542 SignalStreamClosed,
17543 #[error("node stop-all dispatch failed: {0:?}")]
17544 ShutdownDispatch(NodeFailure),
17545 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17546 ShutdownTimedOut { active_native_sessions: usize },
17547}
17548
17549impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17550 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17551 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17552 match error {
17553 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17554 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17555 E::InvalidEntry { line, message } => {
17556 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17557 }
17558 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17559 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17560 }
17561 }
17562}
17563
17564#[cfg(test)]
17565mod agent_stream_blocked_tests {
17566 use super::*;
17567 use gate4agent_types::{
17568 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17569 ProviderSource,
17570 };
17571
17572 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17573 ProviderSource {
17574 family,
17575 binding: AdapterBinding::new(
17576 AdapterId::new(adapter).unwrap(),
17577 "fixture/v1",
17578 AdapterVerification::SyntheticFixture,
17579 )
17580 .unwrap(),
17581 }
17582 }
17583
17584 fn provider_control_event_at(
17585 family: AdapterFamily,
17586 adapter: &str,
17587 source_sequence: u64,
17588 provider_event: ProviderEvent,
17589 ) -> ControlEvent {
17590 ControlEvent {
17591 sequence: 41,
17592 command_id: None,
17593 instance_id: AgentInstanceId(7),
17594 generation: SessionGeneration(3),
17595 event: ControlEventKind::ProviderEvent {
17596 sequence: 8,
17597 source: provider_source(family, adapter),
17598 source_sequence,
17599 event: provider_event,
17600 },
17601 }
17602 }
17603
17604 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17605 provider_control_event_at(family, "fixture", 9, provider_event)
17606 }
17607
17608 fn denied_host_request(
17609 by: ProviderHostDecisionAuthority,
17610 reason: Option<String>,
17611 ) -> ControlEvent {
17612 provider_control_event_at(
17613 AdapterFamily::Acp,
17614 "claude-code",
17615 2,
17616 ProviderEvent::HostRequestObserved {
17617 method: "terminal/create".to_owned(),
17618 params_json: String::new(),
17619 decision: ProviderHostRequestDecision::Denied { by },
17620 outcome: ProviderHostRequestOutcome::Executed,
17621 reason,
17622 },
17623 )
17624 }
17625
17626 fn blocked(
17627 event: &ControlEvent,
17628 ) -> (
17629 Option<String>,
17630 String,
17631 BlockAuthorityV1,
17632 Option<String>,
17633 String,
17634 Option<String>,
17635 ) {
17636 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17637 chunk.validate().expect("minted chunk must validate");
17638 let AgentStreamChunkKindV1::Blocked {
17639 correlation_id,
17640 tool_class,
17641 authority,
17642 reason_kind,
17643 reason,
17644 help,
17645 } = chunk.kind
17646 else {
17647 panic!("expected AgentStreamChunkKindV1::Blocked");
17648 };
17649 (correlation_id, tool_class, authority, reason_kind, reason, help)
17650 }
17651
17652 #[test]
17656 fn the_enabled_provider_matrix_is_exact() {
17657 let registry = active_registry().unwrap();
17658 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17659 assert_eq!(
17660 providers
17661 .iter()
17662 .map(|contract| contract.provider.as_str())
17663 .collect::<Vec<_>>(),
17664 vec!["claude", "codex", "grok", "kimi"],
17665 );
17666 assert!(adapters.iter().all(|contract| {
17667 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17668 }));
17669 }
17670
17671 #[test]
17674 fn gate_denied_host_request_mints_a_blocked_chunk() {
17675 let gate_text =
17676 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17677 let event = denied_host_request(
17678 ProviderHostDecisionAuthority::Gate,
17679 Some(gate_text.to_owned()),
17680 );
17681 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17682 assert_eq!(chunk.source_sequence, 2);
17683 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17684 assert_eq!(authority, BlockAuthorityV1::HostGate);
17685 assert_eq!(reason, gate_text);
17686 assert_eq!(correlation_id, None);
17687 assert_eq!(reason_kind, None);
17688 assert_eq!(help, None);
17689 assert_eq!(tool_class, "Write");
17690 }
17691
17692 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.";
17693
17694 #[test]
17700 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17701 let event = provider_control_event(
17702 AdapterFamily::Acp,
17703 ProviderEvent::ToolCompleted {
17704 id: "toolu_9".to_owned(),
17705 output: CLASSIFIER_SAMPLE.to_owned(),
17706 is_error: true,
17707 duration_ms: None,
17708 agent_id: None,
17709 non_execution_kind: Some("permission-rule".to_owned()),
17710 },
17711 );
17712 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17713 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17714 assert_eq!(reason, "Blocked by classifier");
17715 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17716 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17717 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17718 }
17719
17720 #[test]
17725 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17726 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.";
17727 let event = provider_control_event(
17728 AdapterFamily::Acp,
17729 ProviderEvent::SessionEnded {
17730 result: "JSON-RPC error -32603: Internal error".to_owned(),
17731 cost_usd: None,
17732 is_error: true,
17733 stop_reason: Some(ProviderStopReason::ProviderError {
17734 code: -32603,
17735 message: message.to_owned(),
17736 vendor_code: Some("usageLimitExceeded".to_owned()),
17737 }),
17738 },
17739 );
17740 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17741 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17742 assert_eq!(reason, message);
17743 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17744 assert_eq!(tool_class, "Session");
17745 }
17746
17747 #[test]
17750 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17751 let event = provider_control_event(
17752 AdapterFamily::Acp,
17753 ProviderEvent::SessionEnded {
17754 result: "refusal".to_owned(),
17755 cost_usd: None,
17756 is_error: true,
17757 stop_reason: Some(ProviderStopReason::Refusal),
17758 },
17759 );
17760 let (_, _, authority, _, _, _) = blocked(&event);
17761 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17762 }
17763
17764 #[test]
17768 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17769 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17770 let event = provider_control_event(
17771 AdapterFamily::Acp,
17772 ProviderEvent::ToolCompleted {
17773 id: "toolu_1".to_owned(),
17774 output: "command exited with status 1".to_owned(),
17775 is_error: true,
17776 duration_ms: Some(12),
17777 agent_id: None,
17778 non_execution_kind,
17779 },
17780 );
17781 assert!(agent_stream_chunk(&event).is_none());
17782 }
17783 let narrative = provider_control_event(
17784 AdapterFamily::Acp,
17785 ProviderEvent::ToolCompleted {
17786 id: "toolu_2".to_owned(),
17787 output: "the harness blocked the write, approval needed".to_owned(),
17788 is_error: false,
17789 duration_ms: Some(3),
17790 agent_id: None,
17791 non_execution_kind: None,
17792 },
17793 );
17794 assert!(agent_stream_chunk(&narrative).is_none());
17795 }
17796
17797 #[test]
17801 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17802 for (decision, outcome) in [
17803 (
17804 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17805 ProviderHostRequestOutcome::Executed,
17806 ),
17807 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17808 (
17809 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17810 ProviderHostRequestOutcome::Failed {
17811 error: "terminal/create spawn failed: os error 3".to_owned(),
17812 },
17813 ),
17814 ] {
17815 let event = provider_control_event_at(
17816 AdapterFamily::Acp,
17817 "claude-code",
17818 3,
17819 ProviderEvent::HostRequestObserved {
17820 method: "fs/read_text_file".to_owned(),
17821 params_json: String::new(),
17822 decision: decision.clone(),
17823 outcome,
17824 reason: None,
17825 },
17826 );
17827 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17828 }
17829 }
17830
17831 #[test]
17835 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17836 for (by, expected_authority, expected_reason) in [
17837 (
17838 ProviderHostDecisionAuthority::Operator,
17839 BlockAuthorityV1::Operator,
17840 "declined by the operator",
17841 ),
17842 (
17843 ProviderHostDecisionAuthority::DeadlinePolicy,
17844 BlockAuthorityV1::HostDeadline,
17845 "denied by host policy after the operator deadline expired",
17846 ),
17847 ] {
17848 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17849 assert_eq!(authority, expected_authority);
17850 assert_eq!(reason, expected_reason);
17851 }
17852 }
17853
17854 #[test]
17858 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17859 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17860 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17861 ProviderHostDecisionAuthority::Policy,
17862 Some(oversized),
17863 ));
17864 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17865 }
17866}
17867
17868#[cfg(test)]
17869mod standalone_capability_platform_tests {
17870 use super::*;
17871
17872 #[test]
17873 fn standalone_workspace_capability_requires_windows_identity_guards() {
17874 let capabilities = baseline_capabilities().unwrap();
17875 let advertised = capabilities.iter().any(|capability| {
17876 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17877 });
17878 assert_eq!(advertised, cfg!(windows));
17879
17880 let request = NodeRequest::CreateStandaloneWorkspace {
17881 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17882 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17883 initial_branch: None,
17884 };
17885 assert_eq!(
17886 request_uses_unnegotiated_capability(&request, &capabilities),
17887 !cfg!(windows),
17888 );
17889 }
17890}
17891
17892
17893#[cfg(test)]
17894mod station_knobs_resolve_tests {
17895 use super::*;
17896 use gate4agent_catalog::EnvMutation;
17897 use gate4agent_runtime_native::{
17898 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17899 NativeLaunchProfileId,
17900 };
17901 use std::ffi::OsString;
17902 use std::sync::Arc;
17903
17904 struct EmptyEnvironmentResolver;
17905
17906 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17907 fn resolve_child_environment(
17908 &self,
17909 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17910 Ok(vec![EnvMutation {
17911 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17912 value: None,
17913 }])
17914 }
17915 }
17916
17917 fn agent(value: &str) -> AgentId {
17918 AgentId::new(value).unwrap()
17919 }
17920
17921 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17922 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17923 let profile_revision =
17924 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17925 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17926 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17927 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17928 provider: agent("claude"),
17929 mode: SessionMode::Pty,
17930 terminal_size: gate4agent_types::TerminalSize {
17931 rows: 24,
17932 columns: 80,
17933 },
17934 prompt: None,
17935 bundle_id: None,
17936 context_id: None,
17937 environment_profile_id: Some(profile_id.clone()),
17938 };
17939 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17940 let catalog = active_registry().unwrap();
17941 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17942 let control = runtime.native_launch_profile_control();
17943 let node_profile = NodeEnvironmentProfile::new(
17944 profile_id.clone(),
17945 profile_revision,
17946 agent("claude"),
17947 [NativeLaunchProfile::new(
17948 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17949 agent("claude"),
17950 TransportKind::Pty,
17951 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17952 Arc::new(EmptyEnvironmentResolver),
17953 )
17954 .unwrap()],
17955 )
17956 .unwrap();
17957 let (binding, native_profiles, _materialization) = node_profile.into_parts();
17958 for native_profile in native_profiles {
17959 runtime.upsert_native_launch_profile(native_profile).unwrap();
17960 }
17961 let workspace = WorkspaceConfig::new(
17962 WorkspaceId::new("primary").unwrap(),
17963 std::env::current_dir().unwrap(),
17964 )
17965 .unwrap();
17966 let shared = NodeShared::new_with_incarnation(
17967 handle,
17968 "fixture-token".to_owned(),
17969 NodeId::new("node-station-knobs-test").unwrap(),
17970 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17971 vec![workspace],
17972 vec![agent("claude")],
17973 ProviderRuntimeStatuses::default(),
17974 None,
17975 Vec::new(),
17976 Vec::new(),
17977 spawn_profiles,
17978 Some(control),
17979 None,
17980 None,
17981 None,
17982 Vec::new(),
17983 Vec::new(),
17984 Vec::new(),
17985 Vec::new(),
17986 None,
17987 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17988 );
17989 shared
17990 .environment_profiles
17991 .write()
17992 .unwrap_or_else(|poisoned| poisoned.into_inner())
17993 .insert(binding.id.clone(), binding);
17994 (shared, spawn_defaults)
17995 }
17996
17997 #[test]
18000 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18001 let (shared, defaults) = station_knobs_fixture();
18002 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18003 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18004 let spec = crate::protocol::SpawnSpec {
18005 target: crate::protocol::SpawnTarget {
18006 node_id: shared.node_id.clone(),
18007 workspace_id: WorkspaceId::new("primary").unwrap(),
18008 worktree_id: None,
18009 },
18010 profile_id: defaults.profile_id.clone(),
18011 expected_profile_revision: defaults.revision.clone(),
18012 overrides: crate::protocol::SpawnOverrides {
18013 network_allowlist: Some(unknown),
18014 browser_profile_id: Some(browser),
18015 ..crate::protocol::SpawnOverrides::default()
18016 },
18017 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18018 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18019 "station-knobs-unknown-allowlist",
18020 )
18021 .unwrap(),
18022 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18023 };
18024 let resolved = spec.resolve(&defaults).unwrap();
18025 let failure = shared
18026 .resolve_environment_profile(&resolved)
18027 .expect_err("unknown allowlist must refuse");
18028 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18029 }
18030
18031 #[test]
18037 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18038 let (shared, defaults) = station_knobs_fixture();
18039 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18040 #[cfg(not(feature = "dig2-station-probe"))]
18041 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18042 shared
18043 .network_allowlist_catalog
18044 .write()
18045 .unwrap_or_else(|poisoned| poisoned.into_inner())
18046 .insert_id(allowlist.clone())
18047 .unwrap();
18048 let spec = crate::protocol::SpawnSpec {
18049 target: crate::protocol::SpawnTarget {
18050 node_id: shared.node_id.clone(),
18051 workspace_id: WorkspaceId::new("primary").unwrap(),
18052 worktree_id: None,
18053 },
18054 profile_id: defaults.profile_id.clone(),
18055 expected_profile_revision: defaults.revision.clone(),
18056 overrides: crate::protocol::SpawnOverrides {
18057 network_allowlist: Some(allowlist.clone()),
18058 #[cfg(not(feature = "dig2-station-probe"))]
18059 browser_profile_id: Some(browser.clone()),
18060 ..crate::protocol::SpawnOverrides::default()
18061 },
18062 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18063 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18064 .unwrap(),
18065 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18066 };
18067 let resolved = spec.resolve(&defaults).unwrap();
18068 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18069 #[cfg(not(feature = "dig2-station-probe"))]
18070 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18071 let receipt = shared
18072 .resolve_environment_profile(&resolved)
18073 .expect("resolve ok")
18074 .expect("env profile present");
18075 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18076 #[cfg(not(feature = "dig2-station-probe"))]
18077 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18078 #[cfg(feature = "dig2-station-probe")]
18079 assert_eq!(receipt.browser_profile_id, None);
18080 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18081
18082 let omitted = crate::protocol::SpawnSpec {
18083 overrides: crate::protocol::SpawnOverrides::default(),
18084 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18085 .unwrap(),
18086 ..spec
18087 };
18088 let resolved_omit = omitted.resolve(&defaults).unwrap();
18089 let bare = shared
18090 .resolve_environment_profile(&resolved_omit)
18091 .expect("resolve ok")
18092 .expect("env profile present");
18093 assert_eq!(bare.network_allowlist, None);
18094 assert_eq!(bare.browser_profile_id, None);
18095 }
18096
18097 #[cfg(feature = "dig2-station-probe")]
18102 #[test]
18103 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18104 let (shared, defaults) = station_knobs_fixture();
18105 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18106 let spec = crate::protocol::SpawnSpec {
18107 target: crate::protocol::SpawnTarget {
18108 node_id: shared.node_id.clone(),
18109 workspace_id: WorkspaceId::new("primary").unwrap(),
18110 worktree_id: None,
18111 },
18112 profile_id: defaults.profile_id.clone(),
18113 expected_profile_revision: defaults.revision.clone(),
18114 overrides: crate::protocol::SpawnOverrides {
18115 browser_profile_id: Some(browser),
18116 ..crate::protocol::SpawnOverrides::default()
18117 },
18118 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18119 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18120 "station-knobs-probe-refuse",
18121 )
18122 .unwrap(),
18123 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18124 };
18125 let resolved = spec.resolve(&defaults).unwrap();
18126 let failure = shared
18127 .resolve_environment_profile(&resolved)
18128 .expect_err("browser_profile with failing probe must refuse");
18129 #[cfg(windows)]
18130 {
18131 assert!(
18132 matches!(
18133 failure.code,
18134 NodeFailureCode::BrowserStationUnreachable
18135 | NodeFailureCode::BrowserStationProbeUnavailable
18136 ),
18137 "unexpected code {:?}",
18138 failure.code
18139 );
18140 }
18141 #[cfg(not(windows))]
18142 {
18143 assert_eq!(
18144 failure.code,
18145 NodeFailureCode::BrowserStationProbeUnavailable
18146 );
18147 }
18148 }
18149
18150 #[cfg(feature = "dig2-station-probe")]
18152 #[test]
18153 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18154 let (shared, defaults) = station_knobs_fixture();
18155 let spec = crate::protocol::SpawnSpec {
18156 target: crate::protocol::SpawnTarget {
18157 node_id: shared.node_id.clone(),
18158 workspace_id: WorkspaceId::new("primary").unwrap(),
18159 worktree_id: None,
18160 },
18161 profile_id: defaults.profile_id.clone(),
18162 expected_profile_revision: defaults.revision.clone(),
18163 overrides: crate::protocol::SpawnOverrides::default(),
18164 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18165 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18166 "station-knobs-probe-omit",
18167 )
18168 .unwrap(),
18169 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18170 };
18171 let resolved = spec.resolve(&defaults).unwrap();
18172 let receipt = shared
18173 .resolve_environment_profile(&resolved)
18174 .expect("omit browser_profile must not probe")
18175 .expect("env profile present");
18176 assert_eq!(receipt.browser_profile_id, None);
18177 }
18178
18179 #[cfg(feature = "dig2-station-probe")]
18183 #[test]
18184 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18185 let (shared, _defaults) = station_knobs_fixture();
18186 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18187 let workspace_id = WorkspaceId::new("primary").unwrap();
18188 let address_a = SessionAddress {
18189 workspace_id: workspace_id.clone(),
18190 session: SessionKey {
18191 instance_id: AgentInstanceId(101),
18192 generation: SessionGeneration(1),
18193 },
18194 };
18195 let address_b = SessionAddress {
18196 workspace_id: workspace_id.clone(),
18197 session: SessionKey {
18198 instance_id: AgentInstanceId(102),
18199 generation: SessionGeneration(1),
18200 },
18201 };
18202 let guard_a = crate::dig2_station_lease::acquire_lease(
18203 &shared.browser_station_leases,
18204 profile.clone(),
18205 address_a.session.instance_id,
18206 1,
18207 )
18208 .expect("first lease");
18209 let busy = crate::dig2_station_lease::acquire_lease(
18210 &shared.browser_station_leases,
18211 profile.clone(),
18212 address_b.session.instance_id,
18213 2,
18214 )
18215 .expect_err("second holder must refuse");
18216 assert!(matches!(
18217 busy,
18218 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18219 ));
18220 shared.bind_session_with_policy(
18222 &address_a,
18223 ProviderRuntimePolicy::raw_pty(),
18224 None,
18225 );
18226 guard_a.retain();
18227 assert!(shared.remove_binding(&address_a).is_some());
18228 crate::dig2_station_lease::acquire_lease(
18229 &shared.browser_station_leases,
18230 profile,
18231 address_b.session.instance_id,
18232 3,
18233 )
18234 .expect("reacquire after clear")
18235 .retain();
18236 assert!(
18237 crate::dig2_station_lease::release_lease_for_holder(
18238 &shared.browser_station_leases,
18239 address_b.session.instance_id,
18240 )
18241 );
18242 }
18243
18244 #[cfg(not(feature = "dig2-station-probe"))]
18247 #[test]
18248 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18249 let (shared, defaults) = station_knobs_fixture();
18250 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18251 let spec = crate::protocol::SpawnSpec {
18252 target: crate::protocol::SpawnTarget {
18253 node_id: shared.node_id.clone(),
18254 workspace_id: WorkspaceId::new("primary").unwrap(),
18255 worktree_id: None,
18256 },
18257 profile_id: defaults.profile_id.clone(),
18258 expected_profile_revision: defaults.revision.clone(),
18259 overrides: crate::protocol::SpawnOverrides {
18260 browser_profile_id: Some(browser.clone()),
18261 ..crate::protocol::SpawnOverrides::default()
18262 },
18263 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18264 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18265 .unwrap(),
18266 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18267 };
18268 let resolved = spec.resolve(&defaults).unwrap();
18269 let receipt = shared
18270 .resolve_environment_profile(&resolved)
18271 .expect("stub resolve")
18272 .expect("env profile");
18273 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18274 }
18275
18276
18277 #[test]
18280 fn network_allowlist_catalog_loads_from_temp_file() {
18281 let unique = std::time::SystemTime::now()
18282 .duration_since(std::time::UNIX_EPOCH)
18283 .unwrap()
18284 .as_nanos();
18285 let path = std::env::temp_dir().join(format!(
18286 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18287 std::process::id(),
18288 ));
18289 std::fs::write(
18290 &path,
18291 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18292 )
18293 .unwrap();
18294 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18295 .expect("temp catalog file must load");
18296 let _ = std::fs::remove_file(&path);
18297 assert_eq!(loaded.len(), 2);
18298 let (shared, defaults) = station_knobs_fixture();
18299 {
18300 let mut catalog = shared
18301 .network_allowlist_catalog
18302 .write()
18303 .unwrap_or_else(|poisoned| poisoned.into_inner());
18304 *catalog = loaded;
18305 }
18306 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18307 let spec = crate::protocol::SpawnSpec {
18308 target: crate::protocol::SpawnTarget {
18309 node_id: shared.node_id.clone(),
18310 workspace_id: WorkspaceId::new("primary").unwrap(),
18311 worktree_id: None,
18312 },
18313 profile_id: defaults.profile_id.clone(),
18314 expected_profile_revision: defaults.revision.clone(),
18315 overrides: crate::protocol::SpawnOverrides {
18316 network_allowlist: Some(allowlist.clone()),
18317 ..crate::protocol::SpawnOverrides::default()
18318 },
18319 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18320 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18321 "station-knobs-catalog-file",
18322 )
18323 .unwrap(),
18324 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18325 };
18326 let resolved = spec.resolve(&defaults).unwrap();
18327 let receipt = shared
18328 .resolve_environment_profile(&resolved)
18329 .expect("registered via file load")
18330 .expect("env profile present");
18331 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18332 }
18333
18334 #[test]
18337 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18338 let (shared, _defaults) = station_knobs_fixture();
18339 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18340 shared
18341 .network_allowlist_catalog
18342 .write()
18343 .unwrap_or_else(|poisoned| poisoned.into_inner())
18344 .insert_id(allowlist.clone())
18345 .unwrap();
18346 let snapshot = shared.snapshot();
18347 let inventory = snapshot
18348 .launch_inventory
18349 .expect("launch inventory present");
18350 let ids = inventory
18351 .network_allowlists
18352 .expect("non-empty catalog surfaces ids");
18353 assert_eq!(ids, vec![allowlist]);
18354 }
18355
18356 #[test]
18360 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18361 let (shared, defaults) = station_knobs_fixture();
18362 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18363 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18364 id: allowlist.clone(),
18365 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18366 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18367 peer: "127.0.0.1:443".to_owned(),
18368 }],
18369 provider_native: Some(
18370 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18371 codex_network_access: Some(true),
18372 },
18373 ),
18374 };
18375 shared
18376 .network_allowlist_catalog
18377 .write()
18378 .unwrap_or_else(|poisoned| poisoned.into_inner())
18379 .insert(entry)
18380 .unwrap();
18381 let spec = crate::protocol::SpawnSpec {
18382 target: crate::protocol::SpawnTarget {
18383 node_id: shared.node_id.clone(),
18384 workspace_id: WorkspaceId::new("primary").unwrap(),
18385 worktree_id: None,
18386 },
18387 profile_id: defaults.profile_id.clone(),
18388 expected_profile_revision: defaults.revision.clone(),
18389 overrides: crate::protocol::SpawnOverrides {
18390 network_allowlist: Some(allowlist),
18391 ..crate::protocol::SpawnOverrides::default()
18392 },
18393 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18394 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18395 "station-knobs-unsupported-mapping",
18396 )
18397 .unwrap(),
18398 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18399 };
18400 let resolved = spec.resolve(&defaults).unwrap();
18401 assert_eq!(resolved.provider.as_str(), "claude");
18402 let failure = shared
18403 .resolve_environment_profile(&resolved)
18404 .expect_err("claude + codex_network_access must refuse");
18405 assert_eq!(
18406 failure.code,
18407 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18408 );
18409 }
18410
18411 #[test]
18415 fn resolve_codex_network_access_overlay_moderate_only() {
18416 let (shared, defaults) = station_knobs_fixture();
18417 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18418 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18419 id: allowlist.clone(),
18420 permits: Vec::new(),
18421 provider_native: Some(
18422 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18423 codex_network_access: Some(true),
18424 },
18425 ),
18426 };
18427 shared
18428 .network_allowlist_catalog
18429 .write()
18430 .unwrap_or_else(|poisoned| poisoned.into_inner())
18431 .insert(entry)
18432 .unwrap();
18433
18434 let overlay = shared
18435 .resolve_codex_network_access_overlay_args(
18436 &agent("codex"),
18437 SessionMode::Pty,
18438 ApprovalLevel::Moderate,
18439 Some(&allowlist),
18440 )
18441 .expect("Moderate Codex maps network_access");
18442 assert_eq!(
18443 overlay,
18444 vec![
18445 std::ffi::OsString::from("-c"),
18446 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18447 ]
18448 );
18449
18450 let acp = shared
18451 .resolve_codex_network_access_overlay_args(
18452 &agent("codex"),
18453 SessionMode::Acp,
18454 ApprovalLevel::Moderate,
18455 Some(&allowlist),
18456 )
18457 .unwrap();
18458 assert_eq!(acp, overlay);
18459
18460 let inline = shared
18461 .resolve_codex_network_access_overlay_args(
18462 &agent("codex"),
18463 SessionMode::Inline,
18464 ApprovalLevel::Moderate,
18465 Some(&allowlist),
18466 )
18467 .unwrap_err();
18468 assert_eq!(
18469 inline.code,
18470 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18471 );
18472
18473 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18474 let spec = crate::protocol::SpawnSpec {
18475 target: crate::protocol::SpawnTarget {
18476 node_id: shared.node_id.clone(),
18477 workspace_id: WorkspaceId::new("primary").unwrap(),
18478 worktree_id: None,
18479 },
18480 profile_id: defaults.profile_id.clone(),
18481 expected_profile_revision: defaults.revision.clone(),
18482 overrides: crate::protocol::SpawnOverrides {
18483 provider: crate::protocol::SpawnOverride::Set {
18484 value: agent("codex"),
18485 },
18486 approval_level: Some(level),
18487 network_allowlist: Some(allowlist.clone()),
18488 ..crate::protocol::SpawnOverrides::default()
18489 },
18490 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18491 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18492 "codex-net-refuse-{}",
18493 match level {
18494 ApprovalLevel::ReadOnly => "ro",
18495 ApprovalLevel::FullAuto => "fa",
18496 _ => "other",
18497 }
18498 ))
18499 .unwrap(),
18500 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18501 };
18502 let resolved = spec.resolve(&defaults).unwrap();
18503 let failure = shared
18504 .resolve_environment_profile(&resolved)
18505 .expect_err("RO/FullAuto must refuse");
18506 assert_eq!(
18507 failure.code,
18508 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18509 );
18510 }
18511 }
18512
18513 #[test]
18517 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18518 let (shared, _defaults) = station_knobs_fixture();
18519 let allowlist =
18520 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18521 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18522 id: allowlist.clone(),
18523 permits: vec![
18524 crate::network_allowlist_catalog::NetworkPermitSketch {
18525 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18526 peer: "github.com:443".to_owned(),
18527 },
18528 crate::network_allowlist_catalog::NetworkPermitSketch {
18529 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18530 peer: "*.npmjs.org".to_owned(),
18531 },
18532 ],
18533 provider_native: None,
18534 };
18535 shared
18536 .network_allowlist_catalog
18537 .write()
18538 .unwrap_or_else(|poisoned| poisoned.into_inner())
18539 .insert(entry)
18540 .unwrap();
18541
18542 let unique = std::time::SystemTime::now()
18543 .duration_since(std::time::UNIX_EPOCH)
18544 .unwrap()
18545 .as_nanos();
18546 let provider_home = std::env::temp_dir().join(format!(
18547 "gate4agent-claude-provider-home-{}-{unique}",
18548 std::process::id(),
18549 ));
18550 std::fs::create_dir_all(&provider_home).unwrap();
18551
18552 let overlay = shared
18553 .resolve_network_allowlist_overlay_args(
18554 &agent("claude"),
18555 SessionMode::Pty,
18556 ApprovalLevel::Moderate,
18557 Some(&allowlist),
18558 Some(provider_home.as_path()),
18559 )
18560 .expect("Claude PTY settings overlay on unix");
18561 let settings_path = provider_home.join(
18562 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18563 );
18564 assert_eq!(
18565 overlay,
18566 vec![
18567 std::ffi::OsString::from("--settings"),
18568 std::ffi::OsString::from(settings_path.as_os_str()),
18569 ]
18570 );
18571 let body = std::fs::read_to_string(&settings_path).unwrap();
18572 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18573 assert_eq!(value["sandbox"]["enabled"], true);
18574 assert_eq!(
18575 value["sandbox"]["network"]["allowedDomains"],
18576 serde_json::json!(["github.com", "*.npmjs.org"])
18577 );
18578 assert!(settings_path.starts_with(&provider_home));
18579
18580 let acp = shared
18581 .resolve_network_allowlist_overlay_args(
18582 &agent("claude"),
18583 SessionMode::Acp,
18584 ApprovalLevel::Moderate,
18585 Some(&allowlist),
18586 Some(provider_home.as_path()),
18587 )
18588 .expect_err("Claude ACP must refuse settings overlay");
18589 assert_eq!(
18590 acp.code,
18591 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18592 );
18593
18594 let no_home = shared
18595 .resolve_network_allowlist_overlay_args(
18596 &agent("claude"),
18597 SessionMode::Pty,
18598 ApprovalLevel::Moderate,
18599 Some(&allowlist),
18600 None,
18601 )
18602 .expect_err("Claude overlay requires ProviderHome");
18603 assert_eq!(
18604 no_home.code,
18605 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18606 );
18607
18608 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18609 shared
18610 .network_allowlist_catalog
18611 .write()
18612 .unwrap_or_else(|poisoned| poisoned.into_inner())
18613 .insert_id(bare.clone())
18614 .unwrap();
18615 let empty = shared
18616 .resolve_network_allowlist_overlay_args(
18617 &agent("claude"),
18618 SessionMode::Pty,
18619 ApprovalLevel::Moderate,
18620 Some(&bare),
18621 Some(provider_home.as_path()),
18622 )
18623 .unwrap();
18624 assert!(empty.is_empty());
18625
18626 let _ = std::fs::remove_dir_all(&provider_home);
18627 }
18628
18629 #[test]
18631 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18632 let (shared, _defaults) = station_knobs_fixture();
18633 let allowlist =
18634 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18635 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18636 id: allowlist.clone(),
18637 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18638 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18639 peer: "example.com:443".to_owned(),
18640 }],
18641 provider_native: None,
18642 };
18643 shared
18644 .network_allowlist_catalog
18645 .write()
18646 .unwrap_or_else(|poisoned| poisoned.into_inner())
18647 .insert(entry)
18648 .unwrap();
18649 for provider in ["kimi", "grok"] {
18650 let overlay = shared
18651 .resolve_network_allowlist_overlay_args(
18652 &agent(provider),
18653 SessionMode::Pty,
18654 ApprovalLevel::Moderate,
18655 Some(&allowlist),
18656 None,
18657 )
18658 .unwrap();
18659 assert!(
18660 overlay.is_empty(),
18661 "{provider} must not invent network argv from permits"
18662 );
18663 }
18664 }
18665
18666 #[test]
18668 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18669 let unique = std::time::SystemTime::now()
18670 .duration_since(std::time::UNIX_EPOCH)
18671 .unwrap()
18672 .as_nanos();
18673 let path = std::env::temp_dir().join(format!(
18674 "gate4agent-node-allowlist-json-{}-{unique}.json",
18675 std::process::id(),
18676 ));
18677 std::fs::write(
18678 &path,
18679 r#"{
18680 "schema_version": 2,
18681 "entries": [
18682 {
18683 "id": "egress-default",
18684 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18685 },
18686 { "id": "lab-egress" }
18687 ]
18688 }"#,
18689 )
18690 .unwrap();
18691 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18692 .expect("json v2 catalog must load");
18693 let _ = std::fs::remove_file(&path);
18694 assert_eq!(loaded.len(), 2);
18695 let entry = loaded
18696 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18697 .unwrap();
18698 assert_eq!(entry.permits.len(), 1);
18699 let (shared, defaults) = station_knobs_fixture();
18700 {
18701 let mut catalog = shared
18702 .network_allowlist_catalog
18703 .write()
18704 .unwrap_or_else(|poisoned| poisoned.into_inner());
18705 *catalog = loaded;
18706 }
18707 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18708 let spec = crate::protocol::SpawnSpec {
18709 target: crate::protocol::SpawnTarget {
18710 node_id: shared.node_id.clone(),
18711 workspace_id: WorkspaceId::new("primary").unwrap(),
18712 worktree_id: None,
18713 },
18714 profile_id: defaults.profile_id.clone(),
18715 expected_profile_revision: defaults.revision.clone(),
18716 overrides: crate::protocol::SpawnOverrides {
18717 network_allowlist: Some(allowlist.clone()),
18718 ..crate::protocol::SpawnOverrides::default()
18719 },
18720 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18721 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18722 "station-knobs-catalog-json",
18723 )
18724 .unwrap(),
18725 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18726 };
18727 let resolved = spec.resolve(&defaults).unwrap();
18728 let receipt = shared
18729 .resolve_environment_profile(&resolved)
18730 .expect("permits-only entry ok for claude")
18731 .expect("env profile present");
18732 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18733 let inventory = shared
18734 .snapshot()
18735 .launch_inventory
18736 .expect("launch inventory present");
18737 let ids = inventory.network_allowlists.expect("ids surface");
18738 assert_eq!(
18739 ids,
18740 vec![
18741 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18742 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18743 ]
18744 );
18745 }
18746
18747
18748}
18749
18750
18751#[cfg(test)]
18752mod workspace_environment_collision_tests {
18753 use super::*;
18754 use crate::session_environment::{
18755 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18756 };
18757 use gate4agent_catalog::EnvMutation;
18758 use gate4agent_runtime_native::{
18759 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18760 NativeLaunchProfileId,
18761 };
18762 use std::ffi::OsString;
18763 use std::sync::Arc;
18764
18765 struct EmptyEnvironmentResolver;
18766
18767 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18768 fn resolve_child_environment(
18769 &self,
18770 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18771 Ok(vec![EnvMutation {
18772 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18773 value: None,
18774 }])
18775 }
18776 }
18777
18778 fn agent(value: &str) -> AgentId {
18779 AgentId::new(value).unwrap()
18780 }
18781
18782 fn claude_config_dir_collision_fixture() -> (
18784 NodeShared,
18785 ResolvedEnvironmentProfileReceipt,
18786 ResolvedEnvironmentProfileReceipt,
18787 ) {
18788 let catalog = active_registry().unwrap();
18789 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18790 let control = runtime.native_launch_profile_control();
18791 let workspace = WorkspaceConfig::new(
18792 WorkspaceId::new("primary").unwrap(),
18793 std::env::current_dir().unwrap(),
18794 )
18795 .unwrap();
18796 let shared = NodeShared::new_with_incarnation(
18797 handle,
18798 "fixture-token".to_owned(),
18799 NodeId::new("node-env-collision-test").unwrap(),
18800 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18801 vec![workspace],
18802 vec![agent("claude")],
18803 ProviderRuntimeStatuses::default(),
18804 None,
18805 Vec::new(),
18806 Vec::new(),
18807 SpawnProfileRegistry::default(),
18808 Some(control),
18809 None,
18810 None,
18811 None,
18812 Vec::new(),
18813 Vec::new(),
18814 Vec::new(),
18815 Vec::new(),
18816 None,
18817 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18818 );
18819
18820 let mut receipts = Vec::new();
18821 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18822 let profile_id =
18823 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18824 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18825 "claude-config-dir-{suffix}-r1"
18826 ))
18827 .unwrap();
18828 let materialization = NodeSessionMaterializationProfile::new(
18829 Vec::new(),
18830 vec![NodeSessionPathBinding::new(
18831 "CLAUDE_CONFIG_DIR",
18832 NodeSessionPathClass::ProviderHome,
18833 )
18834 .unwrap()],
18835 Vec::new(),
18836 )
18837 .unwrap();
18838 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18839 profile_id.clone(),
18840 profile_revision.clone(),
18841 agent("claude"),
18842 [NativeLaunchProfile::new(
18843 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18844 .unwrap(),
18845 agent("claude"),
18846 TransportKind::Pty,
18847 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18848 Arc::new(EmptyEnvironmentResolver),
18849 )
18850 .unwrap()],
18851 Some(materialization),
18852 )
18853 .unwrap();
18854 let (binding, native_profiles, materialization) = node_profile.into_parts();
18855 for native_profile in native_profiles {
18856 runtime.upsert_native_launch_profile(native_profile).unwrap();
18857 }
18858 shared
18859 .environment_profiles
18860 .write()
18861 .unwrap_or_else(|poisoned| poisoned.into_inner())
18862 .insert(binding.id.clone(), binding);
18863 shared
18864 .environment_materialization_profiles
18865 .write()
18866 .unwrap_or_else(|poisoned| poisoned.into_inner())
18867 .insert(profile_id.clone(), materialization.unwrap());
18868 receipts.push(ResolvedEnvironmentProfileReceipt {
18869 profile_id,
18870 profile_revision,
18871 network_allowlist: None,
18872 browser_profile_id: None,
18873 });
18874 }
18875 let profile_b = receipts.pop().unwrap();
18876 let profile_a = receipts.pop().unwrap();
18877 (shared, profile_a, profile_b)
18878 }
18879
18880 #[test]
18881 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18882 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18883 let primary = WorkspaceId::new("primary").unwrap();
18884 let worktree = WorkspaceId::new("worktree-b").unwrap();
18885 let address = SessionAddress {
18886 workspace_id: primary.clone(),
18887 session: SessionKey {
18888 instance_id: AgentInstanceId(41),
18889 generation: SessionGeneration(1),
18890 },
18891 };
18892 shared.bind_session_with_policy(
18893 &address,
18894 ProviderRuntimePolicy::raw_pty(),
18895 Some(profile_a.clone()),
18896 );
18897
18898 let refused = shared
18899 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18900 .unwrap_err();
18901 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18902
18903 let refused_none = shared
18905 .ensure_workspace_environment_profile_compatible(&primary, None)
18906 .unwrap_err();
18907 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18908
18909 shared
18911 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18912 .unwrap();
18913
18914 shared
18916 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18917 .unwrap();
18918 }
18919
18920 #[tokio::test]
18921 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18922 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18923 let primary = WorkspaceId::new("primary").unwrap();
18924 let address = SessionAddress {
18925 workspace_id: primary.clone(),
18926 session: SessionKey {
18927 instance_id: AgentInstanceId(41),
18928 generation: SessionGeneration(1),
18929 },
18930 };
18931 shared.bind_session_with_policy(
18932 &address,
18933 ProviderRuntimePolicy::raw_pty(),
18934 Some(profile_a),
18935 );
18936
18937 let failure = shared
18938 .spawn_session_with_deadline(
18939 primary,
18940 agent("claude"),
18941 SessionMode::Pty,
18942 gate4agent_types::TerminalSize {
18943 rows: 24,
18944 columns: 80,
18945 },
18946 None,
18947 Some(profile_b),
18948 None,
18949 None,
18950 None,
18951 None,
18952 SpawnRecordPolicy::ProviderIdentityOnly,
18953 Some(Instant::now() + Duration::from_secs(30)),
18954 &[],
18955 None,
18956 ApprovalLevel::default(),
18957 None,
18958 None,
18959 )
18960 .await
18961 .unwrap_err();
18962 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18963 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18965 assert!(shared.handle.snapshot().sessions.is_empty());
18966 }
18967}
18968
18969#[cfg(test)]
18970mod spawn_characterization_tests {
18971 use super::*;
18972 use crate::session_environment::{
18973 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18974 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18975 NodeSessionPathClass,
18976 };
18977 use gate4agent_catalog::EnvMutation;
18978 use gate4agent_runtime_native::{
18979 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18980 NativeLaunchProfile, NativeLaunchProfileId,
18981 };
18982 use std::ffi::OsString;
18983 use std::sync::Arc;
18984
18985 struct EmptyEnvironmentResolver;
18986
18987 struct FixtureSecretResolver {
18988 deny: Arc<AtomicBool>,
18989 }
18990
18991 impl NodeSecretResolver for FixtureSecretResolver {
18992 fn resolve(
18993 &self,
18994 _reference: &NodeSecretReference,
18995 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18996 if self.deny.load(Ordering::Acquire) {
18997 Err(NodeSecretResolveError::Denied)
18998 } else {
18999 NodeSecretValue::text("fixture-secret")
19000 .map_err(|_| NodeSecretResolveError::Unavailable)
19001 }
19002 }
19003 }
19004
19005 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19006 fn resolve_child_environment(
19007 &self,
19008 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19009 Ok(vec![EnvMutation {
19010 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19011 value: None,
19012 }])
19013 }
19014 }
19015
19016 fn agent(value: &str) -> AgentId {
19017 AgentId::new(value).unwrap()
19018 }
19019
19020 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19021 let unique = std::time::SystemTime::now()
19022 .duration_since(std::time::UNIX_EPOCH)
19023 .unwrap()
19024 .as_nanos();
19025 std::env::temp_dir().join(format!(
19026 "gate4agent-node-{label}-{}-{unique}",
19027 std::process::id(),
19028 ))
19029 }
19030
19031 fn materialization_test_shared(
19032 deny_secret: bool,
19033 ) -> (
19034 NodeShared,
19035 NativeRuntime,
19036 ResolvedEnvironmentProfileReceipt,
19037 PathBuf,
19038 Arc<AtomicBool>,
19039 ) {
19040 let workspace = WorkspaceConfig::new(
19041 WorkspaceId::new("primary").unwrap(),
19042 std::env::current_dir().unwrap(),
19043 )
19044 .unwrap();
19045 materialization_test_shared_with_workspace(deny_secret, workspace)
19046 }
19047
19048 fn materialization_test_shared_with_workspace(
19049 deny_secret: bool,
19050 workspace: WorkspaceConfig,
19051 ) -> (
19052 NodeShared,
19053 NativeRuntime,
19054 ResolvedEnvironmentProfileReceipt,
19055 PathBuf,
19056 Arc<AtomicBool>,
19057 ) {
19058 let root = temporary_workspace_root(if deny_secret {
19059 "materialization-denied"
19060 } else {
19061 "materialization-ready"
19062 });
19063 std::fs::create_dir_all(&root).unwrap();
19064 let materialization_root = root.join("session-environments");
19065 let deny = Arc::new(AtomicBool::new(deny_secret));
19066 let resolver = Arc::new(FixtureSecretResolver {
19067 deny: Arc::clone(&deny),
19068 });
19069 let materializer = SessionEnvironmentMaterializer::new(
19070 materialization_root,
19071 resolver,
19072 )
19073 .unwrap();
19074 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19075 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19076 "materialized-claude-r1",
19077 )
19078 .unwrap();
19079 let materialization = NodeSessionMaterializationProfile::new(
19080 vec![NodeSessionEnvironmentMutation::SetSecret {
19081 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19082 reference: NodeSecretReference::new("fixture-token").unwrap(),
19083 }],
19084 vec![NodeSessionPathBinding::new(
19085 "GATE4AGENT_PROVIDER_HOME",
19086 NodeSessionPathClass::ProviderHome,
19087 )
19088 .unwrap()],
19089 vec![NodeSessionFile::secret(
19090 NodeSessionPathClass::Config,
19091 "auth/token",
19092 NodeSecretReference::new("fixture-token").unwrap(),
19093 )
19094 .unwrap()],
19095 )
19096 .unwrap();
19097 let catalog = active_registry().unwrap();
19098 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19099 let control = runtime.native_launch_profile_control();
19100 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19101 profile_id.clone(),
19102 profile_revision.clone(),
19103 agent("claude"),
19104 [NativeLaunchProfile::new(
19105 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19106 agent("claude"),
19107 TransportKind::Pty,
19108 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19109 Arc::new(EmptyEnvironmentResolver),
19110 )
19111 .unwrap()],
19112 Some(materialization),
19113 )
19114 .unwrap();
19115 let (binding, native_profiles, materialization) = node_profile.into_parts();
19116 for native_profile in native_profiles {
19117 runtime.upsert_native_launch_profile(native_profile).unwrap();
19118 }
19119 let shared = NodeShared::new_with_incarnation(
19120 handle,
19121 "fixture-token".to_owned(),
19122 NodeId::new("node-materialization-test").unwrap(),
19123 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19124 vec![workspace],
19125 vec![agent("claude")],
19126 ProviderRuntimeStatuses::default(),
19127 None,
19128 Vec::new(),
19129 Vec::new(),
19130 SpawnProfileRegistry::default(),
19131 Some(control),
19132 None,
19133 Some(materializer),
19134 None,
19135 Vec::new(),
19136 Vec::new(),
19137 Vec::new(),
19138 Vec::new(),
19139 None,
19140 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19141 );
19142 shared
19143 .environment_profiles
19144 .write()
19145 .unwrap_or_else(|poisoned| poisoned.into_inner())
19146 .insert(binding.id.clone(), binding);
19147 shared
19148 .environment_materialization_profiles
19149 .write()
19150 .unwrap_or_else(|poisoned| poisoned.into_inner())
19151 .insert(profile_id.clone(), materialization.unwrap());
19152 (
19153 shared,
19154 runtime,
19155 ResolvedEnvironmentProfileReceipt {
19156 profile_id,
19157 profile_revision,
19158 network_allowlist: None,
19159 browser_profile_id: None,
19160 },
19161 root,
19162 deny,
19163 )
19164 }
19165
19166
19167 async fn characterization_spawn(
19170 shared: &NodeShared,
19171 mode: SessionMode,
19172 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19173 required: &[ProviderRuntimeCapability],
19174 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19175 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19176 shared
19177 .spawn_session_with_deadline(
19178 WorkspaceId::new("primary").unwrap(),
19179 agent("claude"),
19180 mode,
19181 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19182 None,
19183 environment_profile,
19184 None,
19185 None,
19186 None,
19187 Some(ProviderRuntimePolicy::raw_pty()),
19188 SpawnRecordPolicy::ProviderIdentityOnly,
19189 Some(Instant::now() + Duration::from_secs(30)),
19190 required,
19191 harness_mcp,
19192 ApprovalLevel::default(),
19193 None,
19194 None,
19195 )
19196 .await
19197 }
19198
19199 fn assert_no_session_state(shared: &NodeShared) {
19200 assert!(shared.handle.snapshot().sessions.is_empty());
19201 assert!(shared
19202 .session_bindings
19203 .lock()
19204 .unwrap_or_else(|poisoned| poisoned.into_inner())
19205 .is_empty());
19206 assert!(shared
19207 .materializations
19208 .lock()
19209 .unwrap_or_else(|poisoned| poisoned.into_inner())
19210 .is_empty());
19211 }
19212
19213 #[tokio::test]
19214 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19215 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19216 let error = characterization_spawn(
19217 &shared,
19218 SessionMode::Pty,
19219 None,
19220 &[ProviderRuntimeCapability::SemanticReadiness],
19221 None,
19222 )
19223 .await
19224 .unwrap_err();
19225 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19226 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19227 assert_no_session_state(&shared);
19228 drop(shared);
19229 drop(runtime);
19230 std::fs::remove_dir_all(root).unwrap();
19231 }
19232
19233 #[tokio::test]
19234 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19235 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19236 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19237 .await
19238 .unwrap_err();
19239 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19240 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19241 assert_no_session_state(&shared);
19242 drop(shared);
19243 drop(runtime);
19244 std::fs::remove_dir_all(root).unwrap();
19245 }
19246
19247 #[test]
19248 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19249 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19250 let address = SessionAddress {
19251 workspace_id: WorkspaceId::new("primary").unwrap(),
19252 session: SessionKey {
19253 instance_id: AgentInstanceId(77),
19254 generation: SessionGeneration(1),
19255 },
19256 };
19257 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19258 let removed = shared.remove_binding(&address).expect("binding removed");
19259 assert!(removed.environment_profile.is_some());
19260 assert!(shared.remove_binding(&address).is_none());
19261 assert_no_session_state(&shared);
19262 drop(shared);
19263 drop(runtime);
19264 std::fs::remove_dir_all(root).unwrap();
19265 }
19266
19267 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19268 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19269 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19270 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19271 .expect("review current test executable as fixture helper");
19272 let registry = HarnessMcpProxyRegistry::new(helper);
19273 let spec = crate::protocol::SpawnSpec {
19274 target: crate::protocol::SpawnTarget {
19275 node_id: NodeId::new("node-characterization").unwrap(),
19276 workspace_id: WorkspaceId::new("primary").unwrap(),
19277 worktree_id: None,
19278 },
19279 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19280 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19281 overrides: crate::protocol::SpawnOverrides::default(),
19282 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19283 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19284 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19285 };
19286 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19287 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19288 let launch = HarnessMcpLaunchV1 {
19289 server_name: "fixture-harness".to_owned(),
19290 args: vec!["--session-proxy".to_owned()],
19291 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19292 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19293 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19294 trace: None,
19295 scrub_env: Vec::new(),
19296 };
19297 let expires = unix_time_ms().saturating_add(30_000);
19298 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19299 registry
19300 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19301 .unwrap()
19302 }
19303
19304 #[tokio::test]
19305 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19306 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19307 let prepared = characterization_prepared_harness("claude").await;
19308 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19309 .await
19310 .unwrap_err();
19311 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19313 let mismatched = characterization_prepared_harness("codex").await;
19314 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19315 .await
19316 .unwrap_err();
19317 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19318 assert_no_session_state(&shared);
19319 drop(shared);
19320 drop(runtime);
19321 std::fs::remove_dir_all(root).unwrap();
19322 }
19323
19324 #[tokio::test]
19325 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19326 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19327 shared.native_launch_profile_control = None;
19328 let prepared = characterization_prepared_harness("claude").await;
19329 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19330 .await
19331 .unwrap_err();
19332 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19333 assert_no_session_state(&shared);
19334 drop(shared);
19335 drop(runtime);
19336 std::fs::remove_dir_all(root).unwrap();
19337 }
19338}
19339
19340#[cfg(all(test, windows))]
19341mod tests {
19342 use super::*;
19343 use gate4agent_types::ProviderActivity;
19344 use crate::session_environment::{
19345 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19346 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19347 NodeSessionPathClass,
19348 };
19349 use gate4agent_catalog::EnvMutation;
19350 use gate4agent_runtime_native::{
19351 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19352 NativeLaunchProfile, NativeLaunchProfileId,
19353 };
19354 use std::ffi::OsString;
19355 use std::sync::Arc;
19356
19357 struct EmptyEnvironmentResolver;
19358
19359 struct FixtureSecretResolver {
19360 deny: Arc<AtomicBool>,
19361 }
19362
19363 impl NodeSecretResolver for FixtureSecretResolver {
19364 fn resolve(
19365 &self,
19366 _reference: &NodeSecretReference,
19367 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19368 if self.deny.load(Ordering::Acquire) {
19369 Err(NodeSecretResolveError::Denied)
19370 } else {
19371 NodeSecretValue::text("fixture-secret")
19372 .map_err(|_| NodeSecretResolveError::Unavailable)
19373 }
19374 }
19375 }
19376
19377 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19378 fn resolve_child_environment(
19379 &self,
19380 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19381 Ok(vec![EnvMutation {
19382 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19383 value: None,
19384 }])
19385 }
19386 }
19387
19388 fn agent(value: &str) -> AgentId {
19389 AgentId::new(value).unwrap()
19390 }
19391
19392 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19393 let catalog = active_registry().unwrap();
19394 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19395 let workspace = WorkspaceConfig::new(
19396 WorkspaceId::new("primary").unwrap(),
19397 std::env::current_dir().unwrap(),
19398 )
19399 .unwrap()
19400 .with_worktree_service_mode(mode);
19401 NodeShared::new(
19402 handle,
19403 "fixture-token".to_owned(),
19404 NodeId::new("node-terminal-test").unwrap(),
19405 vec![workspace],
19406 vec![agent("claude")],
19407 )
19408 }
19409
19410 fn terminal_test_shared() -> NodeShared {
19411 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19412 }
19413
19414 #[cfg(feature = "fixture")]
19415 #[test]
19416 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19417 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19418 assert_eq!(probe.latest_code(), None);
19419
19420 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19421 assert_eq!(
19422 probe.latest_code(),
19423 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19424 );
19425
19426 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19427 assert_eq!(
19428 probe.latest_code(),
19429 Some(NodeFailureCode::SpawnDeadlineExceeded),
19430 );
19431
19432 probe.record(None);
19433 assert_eq!(probe.latest_code(), None);
19434 }
19435
19436 #[test]
19437 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19438 let source = temporary_workspace_root("launch-inventory-source");
19439 let allocation = temporary_workspace_root("launch-inventory-allocation");
19440 std::fs::create_dir_all(&source).unwrap();
19441 std::fs::create_dir_all(&allocation).unwrap();
19442 let workspace_id = WorkspaceId::new("primary").unwrap();
19443 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19444 .unwrap()
19445 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19446 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19447 workspace = workspace
19448 .with_managed_worktree_profile(
19449 ManagedWorktreeProfile::new(
19450 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19451 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19452 &allocation,
19453 "gate4agent",
19454 "HEAD",
19455 ManagedWorktreeRetention::RemoveWhenReleased,
19456 )
19457 .unwrap(),
19458 )
19459 .unwrap();
19460 }
19461 let overflow = workspace.clone().with_managed_worktree_profile(
19462 ManagedWorktreeProfile::new(
19463 WorktreeProfileId::new("overflow").unwrap(),
19464 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19465 &allocation,
19466 "gate4agent",
19467 "HEAD",
19468 ManagedWorktreeRetention::Retain,
19469 )
19470 .unwrap(),
19471 );
19472 assert!(matches!(
19473 overflow,
19474 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19475 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19476 ..
19477 }),
19478 ));
19479
19480 let catalog = active_registry().unwrap();
19481 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19482 let shared = NodeShared::new(
19483 handle,
19484 "fixture-token".to_owned(),
19485 NodeId::new("node-launch-inventory").unwrap(),
19486 vec![workspace],
19487 vec![agent("claude")],
19488 );
19489 let snapshot = shared.snapshot();
19490 let launch = snapshot.launch_inventory.as_ref().unwrap();
19491 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19492 assert!(launch.bundles.as_ref().unwrap().is_empty());
19493 let profiles = snapshot.workspaces[0]
19494 .managed_worktree_profiles
19495 .as_ref()
19496 .unwrap();
19497 assert_eq!(
19498 snapshot.workspaces[0].worktree_service_mode,
19499 Some(WorktreeServiceMode::Managed),
19500 );
19501 assert_eq!(
19502 profiles.profiles.len(),
19503 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19504 );
19505 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19506 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19507 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19508 }
19509 let legacy = snapshot_for_wire(
19510 &shared,
19511 true,
19512 true,
19513 false,
19514 false,
19515 true,
19516 false,
19517 true,
19518 false,
19519 true,
19520 );
19521 assert!(legacy.launch_inventory.is_none());
19522 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19523
19524 drop(shared);
19525 std::fs::remove_dir_all(source).unwrap();
19526 std::fs::remove_dir_all(allocation).unwrap();
19527 }
19528
19529 #[test]
19530 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19531 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19532 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19533 }));
19534 let source = gate4agent_types::ProviderSource {
19535 family: AdapterFamily::PtySemantic,
19536 binding: AdapterBinding::new(
19537 gate4agent_types::AdapterId::new("claude").unwrap(),
19538 "fixture/v1",
19539 gate4agent_types::AdapterVerification::SyntheticFixture,
19540 )
19541 .unwrap(),
19542 };
19543 let provider = ProviderSnapshot {
19544 sequence: 17,
19545 session: Some(ProviderSessionIdentity {
19546 key: ProviderSessionKey::SessionId,
19547 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19548 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19549 }),
19550 model: Some("PRIVATE_MODEL".to_owned()),
19551 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19552 completed_turns: 3,
19553 usage: gate4agent_types::TokenUsage {
19554 input_tokens: 10,
19555 output_tokens: 20,
19556 cache_read_tokens: 30,
19557 cache_write_tokens: 40,
19558 reasoning_tokens: 50,
19559 context_window: Some(200_000),
19560 },
19561 lead_activity: ProviderActivity::WaitingForInput,
19562 activity: ProviderActivity::WaitingForInput,
19563 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19564 active_tools: (0..9)
19565 .map(|index| gate4agent_types::ActiveProviderTool {
19566 id: format!("private-tool-id-{index}"),
19567 name: if index == 0 {
19568 format!(" Read\u{0000}{}", "x".repeat(80))
19569 } else {
19570 format!("Tool-{index}")
19571 },
19572 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19573 })
19574 .collect(),
19575 interactions: vec![gate4agent_types::ProviderInteraction {
19576 id: gate4agent_types::ProviderInteractionId(17),
19577 source: source.clone(),
19578 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19579 interaction_kind: ProviderInteractionKind::Approval,
19580 tool_name: " Approve\u{0007}Tool ".to_owned(),
19581 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19582 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19583 resume_lead_activity: Some(ProviderActivity::Working),
19584 status: ProviderInteractionStatus::Pending,
19585 }],
19586 subagents: vec![gate4agent_types::ProviderSubagent {
19587 source: source.clone(),
19588 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19589 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19590 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19591 }],
19592 sources: vec![gate4agent_types::ProviderSourceCursor {
19593 source,
19594 sequence: 17,
19595 gap_count: 2,
19596 stale: true,
19597 }],
19598 last_event: Some(ProviderEvent::Error {
19599 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19600 }),
19601 gap_count: 2,
19602 stale: true,
19603 };
19604 let address = terminal_address(3);
19605 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19606 assert_eq!(entry.address, address);
19607 assert_eq!(entry.progress.provider_sequence, 17);
19608 assert_eq!(entry.progress.active_tool_count, 9);
19609 assert_eq!(
19610 entry.progress.active_tool_labels.len(),
19611 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19612 );
19613 assert_eq!(entry.progress.subagent_count, 1);
19614 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19615 assert!(entry.progress.truncated);
19616 let encoded = serde_json::to_string(&entry).unwrap();
19617 for forbidden in [
19618 "PRIVATE_PROVIDER_SESSION",
19619 "PRIVATE_PROVIDER_PATH",
19620 "PRIVATE_MODEL",
19621 "PRIVATE_TOOL_INVENTORY",
19622 "PRIVATE_CURRENT_PROMPT",
19623 "PRIVATE_TOOL_INPUT_JSON",
19624 "PRIVATE_PROVIDER_REQUEST",
19625 "PRIVATE_INTERACTION_PROMPT",
19626 "PRIVATE_INTERACTION_AGENT",
19627 "PRIVATE_SUBAGENT_ID",
19628 "PRIVATE_SUBAGENT_TYPE",
19629 "PRIVATE_SUBAGENT_DESCRIPTION",
19630 "PRIVATE_PROVIDER_ERROR",
19631 "input_json",
19632 "current_prompt",
19633 "session_id",
19634 ] {
19635 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19636 }
19637
19638 let snapshot = NodeSnapshot {
19639 node_id: NodeId::new("node-progress-test").unwrap(),
19640 enabled_providers: vec![agent("claude")],
19641 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19642 workspaces: Vec::new(),
19643 session_records: Vec::new(),
19644 managed_worktrees: Vec::new(),
19645 launch_inventory: None,
19646 agent_progress: vec![entry],
19647 };
19648 let mut legacy_reply = ResponseEnvelope {
19649 request_id: 1,
19650 result: Ok(NodeResponse::Snapshot {
19651 event_sequence: 0,
19652 controller: None,
19653 snapshot: snapshot.clone(),
19654 }),
19655 };
19656 project_response_without_agent_progress(&mut legacy_reply);
19657 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19658 panic!("agent progress projection changed response kind");
19659 };
19660 assert!(legacy.agent_progress.is_empty());
19661 assert_eq!(snapshot.agent_progress.len(), 1);
19662 }
19663
19664 fn terminal_address(generation: u64) -> SessionAddress {
19665 SessionAddress {
19666 workspace_id: WorkspaceId::new("primary").unwrap(),
19667 session: SessionKey {
19668 instance_id: AgentInstanceId(41),
19669 generation: SessionGeneration(generation),
19670 },
19671 }
19672 }
19673
19674 fn spawn_spec_fixture() -> SpawnSpec {
19675 SpawnSpec {
19676 target: crate::protocol::SpawnTarget {
19677 node_id: NodeId::new("node-terminal-test").unwrap(),
19678 workspace_id: WorkspaceId::new("primary").unwrap(),
19679 worktree_id: None,
19680 },
19681 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19682 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19683 .unwrap(),
19684 overrides: crate::protocol::SpawnOverrides::default(),
19685 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19686 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19687 required_capabilities: SpawnRequiredCapabilities::default(),
19688 }
19689 }
19690
19691 fn environment_profile_test_shared(
19692 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19693 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19694 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19695 "local-claude-r1",
19696 )
19697 .unwrap();
19698 let spawn_profiles = SpawnProfileRegistry::new([
19699 crate::protocol::SpawnProfileDefaults {
19700 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19701 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19702 provider: agent("claude"),
19703 mode: SessionMode::Pty,
19704 terminal_size: gate4agent_types::TerminalSize {
19705 rows: 24,
19706 columns: 80,
19707 },
19708 prompt: None,
19709 bundle_id: None,
19710 context_id: None,
19711 environment_profile_id: Some(profile_id.clone()),
19712 },
19713 ])
19714 .unwrap();
19715 let catalog = active_registry().unwrap();
19716 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19717 let control = runtime.native_launch_profile_control();
19718 let node_profile = NodeEnvironmentProfile::new(
19719 profile_id.clone(),
19720 profile_revision.clone(),
19721 agent("claude"),
19722 [NativeLaunchProfile::new(
19723 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19724 agent("claude"),
19725 TransportKind::Pty,
19726 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19727 Arc::new(EmptyEnvironmentResolver),
19728 )
19729 .unwrap()],
19730 )
19731 .unwrap();
19732 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19733 for native_profile in native_profiles {
19734 runtime.upsert_native_launch_profile(native_profile).unwrap();
19735 }
19736 let workspace = WorkspaceConfig::new(
19737 WorkspaceId::new("primary").unwrap(),
19738 std::env::current_dir().unwrap(),
19739 )
19740 .unwrap();
19741 let shared = NodeShared::new_with_incarnation(
19742 handle,
19743 "fixture-token".to_owned(),
19744 NodeId::new("node-terminal-test").unwrap(),
19745 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19746 vec![workspace],
19747 vec![agent("claude")],
19748 ProviderRuntimeStatuses::default(),
19749 None,
19750 Vec::new(),
19751 Vec::new(),
19752 spawn_profiles,
19753 Some(control),
19754 None,
19755 None,
19756 None,
19757 Vec::new(),
19758 Vec::new(),
19759 Vec::new(),
19760 Vec::new(),
19761 None,
19762 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19763 );
19764 shared
19765 .environment_profiles
19766 .write()
19767 .unwrap_or_else(|poisoned| poisoned.into_inner())
19768 .insert(binding.id.clone(), binding);
19769 (
19770 shared,
19771 runtime,
19772 ResolvedEnvironmentProfileReceipt {
19773 profile_id,
19774 profile_revision,
19775 network_allowlist: None,
19776 browser_profile_id: None,
19777 },
19778 )
19779 }
19780
19781 fn materialization_test_shared(
19782 deny_secret: bool,
19783 ) -> (
19784 NodeShared,
19785 NativeRuntime,
19786 ResolvedEnvironmentProfileReceipt,
19787 PathBuf,
19788 Arc<AtomicBool>,
19789 ) {
19790 let workspace = WorkspaceConfig::new(
19791 WorkspaceId::new("primary").unwrap(),
19792 std::env::current_dir().unwrap(),
19793 )
19794 .unwrap();
19795 materialization_test_shared_with_workspace(deny_secret, workspace)
19796 }
19797
19798 fn materialization_test_shared_with_workspace(
19799 deny_secret: bool,
19800 workspace: WorkspaceConfig,
19801 ) -> (
19802 NodeShared,
19803 NativeRuntime,
19804 ResolvedEnvironmentProfileReceipt,
19805 PathBuf,
19806 Arc<AtomicBool>,
19807 ) {
19808 let root = temporary_workspace_root(if deny_secret {
19809 "materialization-denied"
19810 } else {
19811 "materialization-ready"
19812 });
19813 std::fs::create_dir_all(&root).unwrap();
19814 let materialization_root = root.join("session-environments");
19815 let deny = Arc::new(AtomicBool::new(deny_secret));
19816 let resolver = Arc::new(FixtureSecretResolver {
19817 deny: Arc::clone(&deny),
19818 });
19819 let materializer = SessionEnvironmentMaterializer::new(
19820 materialization_root,
19821 resolver,
19822 )
19823 .unwrap();
19824 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19825 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19826 "materialized-claude-r1",
19827 )
19828 .unwrap();
19829 let materialization = NodeSessionMaterializationProfile::new(
19830 vec![NodeSessionEnvironmentMutation::SetSecret {
19831 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19832 reference: NodeSecretReference::new("fixture-token").unwrap(),
19833 }],
19834 vec![NodeSessionPathBinding::new(
19835 "GATE4AGENT_PROVIDER_HOME",
19836 NodeSessionPathClass::ProviderHome,
19837 )
19838 .unwrap()],
19839 vec![NodeSessionFile::secret(
19840 NodeSessionPathClass::Config,
19841 "auth/token",
19842 NodeSecretReference::new("fixture-token").unwrap(),
19843 )
19844 .unwrap()],
19845 )
19846 .unwrap();
19847 let catalog = active_registry().unwrap();
19848 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19849 let control = runtime.native_launch_profile_control();
19850 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19851 profile_id.clone(),
19852 profile_revision.clone(),
19853 agent("claude"),
19854 [NativeLaunchProfile::new(
19855 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19856 agent("claude"),
19857 TransportKind::Pty,
19858 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19859 Arc::new(EmptyEnvironmentResolver),
19860 )
19861 .unwrap()],
19862 Some(materialization),
19863 )
19864 .unwrap();
19865 let (binding, native_profiles, materialization) = node_profile.into_parts();
19866 for native_profile in native_profiles {
19867 runtime.upsert_native_launch_profile(native_profile).unwrap();
19868 }
19869 let shared = NodeShared::new_with_incarnation(
19870 handle,
19871 "fixture-token".to_owned(),
19872 NodeId::new("node-materialization-test").unwrap(),
19873 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19874 vec![workspace],
19875 vec![agent("claude")],
19876 ProviderRuntimeStatuses::default(),
19877 None,
19878 Vec::new(),
19879 Vec::new(),
19880 SpawnProfileRegistry::default(),
19881 Some(control),
19882 None,
19883 Some(materializer),
19884 None,
19885 Vec::new(),
19886 Vec::new(),
19887 Vec::new(),
19888 Vec::new(),
19889 None,
19890 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19891 );
19892 shared
19893 .environment_profiles
19894 .write()
19895 .unwrap_or_else(|poisoned| poisoned.into_inner())
19896 .insert(binding.id.clone(), binding);
19897 shared
19898 .environment_materialization_profiles
19899 .write()
19900 .unwrap_or_else(|poisoned| poisoned.into_inner())
19901 .insert(profile_id.clone(), materialization.unwrap());
19902 (
19903 shared,
19904 runtime,
19905 ResolvedEnvironmentProfileReceipt {
19906 profile_id,
19907 profile_revision,
19908 network_allowlist: None,
19909 browser_profile_id: None,
19910 },
19911 root,
19912 deny,
19913 )
19914 }
19915
19916 #[cfg(feature = "fixture")]
19917 #[derive(Clone, Copy)]
19918 enum CodexHomeProfile {
19919 Missing,
19920 WrongClass,
19921 Exact,
19922 }
19923
19924 #[cfg(feature = "fixture")]
19925 fn codex_bundle_test_shared(
19926 home_profile: CodexHomeProfile,
19927 ) -> (
19928 NodeShared,
19929 NativeRuntime,
19930 ResolvedEnvironmentProfileReceipt,
19931 NodeBundle,
19932 PathBuf,
19933 ) {
19934 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19935 const CLAUDE_MANIFEST: &[u8] =
19936 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19937 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19938 const DIGEST: &str =
19939 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19940
19941 let (shared, mut runtime, _claude_receipt, root, _deny) =
19942 materialization_test_shared(false);
19943 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19944 CodexHomeProfile::Missing => "codex-home-missing",
19945 CodexHomeProfile::WrongClass => "codex-home-wrong",
19946 CodexHomeProfile::Exact => "codex-home-exact",
19947 })
19948 .unwrap();
19949 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19950 "codex-home-r1",
19951 )
19952 .unwrap();
19953 let materialization = match home_profile {
19954 CodexHomeProfile::Missing => None,
19955 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19956 Some(NodeSessionMaterializationProfile::new(
19957 Vec::new(),
19958 vec![NodeSessionPathBinding::new(
19959 "CODEX_HOME",
19960 if matches!(home_profile, CodexHomeProfile::Exact) {
19961 NodeSessionPathClass::ProviderHome
19962 } else {
19963 NodeSessionPathClass::Config
19964 },
19965 )
19966 .unwrap()],
19967 Vec::new(),
19968 )
19969 .unwrap())
19970 }
19971 };
19972 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19973 profile_id.clone(),
19974 profile_revision.clone(),
19975 agent("codex"),
19976 [NativeLaunchProfile::new(
19977 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19978 agent("codex"),
19979 TransportKind::Pty,
19980 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19981 Arc::new(EmptyEnvironmentResolver),
19982 )
19983 .unwrap()],
19984 materialization,
19985 )
19986 .unwrap();
19987 let (binding, native_profiles, materialization) = node_profile.into_parts();
19988 for native_profile in native_profiles {
19989 runtime.upsert_native_launch_profile(native_profile).unwrap();
19990 }
19991 shared
19992 .environment_profiles
19993 .write()
19994 .unwrap_or_else(|poisoned| poisoned.into_inner())
19995 .insert(binding.id.clone(), binding);
19996 if let Some(materialization) = materialization {
19997 shared
19998 .environment_materialization_profiles
19999 .write()
20000 .unwrap_or_else(|poisoned| poisoned.into_inner())
20001 .insert(profile_id.clone(), materialization);
20002 }
20003
20004 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20005 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20006 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20007 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20008 std::fs::write(
20009 bundle_source.join(".claude-plugin/plugin.json"),
20010 CLAUDE_MANIFEST,
20011 )
20012 .unwrap();
20013 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20014 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20015 let bundle = NodeBundle::new(
20016 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20017 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20018 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20019 &bundle_source,
20020 )
20021 .unwrap();
20022 *shared
20023 .bundle_catalog
20024 .write()
20025 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20026 BundleCatalog::new([bundle.clone()]).unwrap();
20027 (
20028 shared,
20029 runtime,
20030 ResolvedEnvironmentProfileReceipt {
20031 profile_id,
20032 profile_revision,
20033 network_allowlist: None,
20034 browser_profile_id: None,
20035 },
20036 bundle,
20037 root,
20038 )
20039 }
20040
20041 #[cfg(feature = "fixture")]
20042 #[test]
20043 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20044 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20045 let (shared, runtime, receipt, bundle, root) =
20046 codex_bundle_test_shared(home_profile);
20047 let error = shared
20048 .bundle_layout(
20049 &agent("codex"),
20050 SessionMode::Pty,
20051 Some(&receipt),
20052 &bundle,
20053 )
20054 .unwrap_err();
20055 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20056 assert!(shared
20057 .materializations
20058 .lock()
20059 .unwrap_or_else(|poisoned| poisoned.into_inner())
20060 .is_empty());
20061 let materialization_root = root.join("session-environments");
20062 assert!(!std::fs::read_dir(&materialization_root)
20063 .unwrap()
20064 .filter_map(Result::ok)
20065 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20066 drop(shared);
20067 drop(runtime);
20068 let _ = std::fs::remove_dir_all(root);
20069 }
20070 }
20071
20072 #[cfg(feature = "fixture")]
20073 #[test]
20074 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20075 let (shared, runtime, receipt, bundle, root) =
20076 codex_bundle_test_shared(CodexHomeProfile::Exact);
20077 let address = SessionAddress {
20078 workspace_id: WorkspaceId::new("primary").unwrap(),
20079 session: SessionKey {
20080 instance_id: AgentInstanceId(310),
20081 generation: SessionGeneration(1),
20082 },
20083 };
20084 let (overlay, guard) = shared
20085 .prepare_session_materialization(
20086 &address,
20087 &agent("codex"),
20088 SessionMode::Pty,
20089 Some(&receipt),
20090 Some(&bundle.receipt()),
20091 None,
20092 None,
20093 )
20094 .unwrap()
20095 .unwrap();
20096 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20097 let ownership = shared
20098 .materializations
20099 .lock()
20100 .unwrap_or_else(|poisoned| poisoned.into_inner())
20101 .get(guard.id().unwrap())
20102 .unwrap()
20103 .clone();
20104 let (profile, _, _) = shared
20105 .materialization_profile(
20106 &agent("codex"),
20107 SessionMode::Pty,
20108 Some(&receipt),
20109 Some(&bundle.receipt()),
20110 None,
20111 )
20112 .unwrap()
20113 .unwrap();
20114 let environment = shared
20115 .session_environment_materializer
20116 .as_ref()
20117 .unwrap()
20118 .resolve_environment(&ownership, &profile)
20119 .unwrap();
20120 assert!(environment.iter().any(|mutation| {
20121 mutation.key == OsString::from("CODEX_HOME")
20122 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20123 }));
20124 assert_eq!(
20125 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20126 .unwrap(),
20127 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20128 );
20129 assert!(std::fs::read_dir(ownership.bundle_root())
20130 .unwrap()
20131 .next()
20132 .is_none());
20133 drop(guard);
20134 drop(shared);
20135 drop(runtime);
20136 let _ = std::fs::remove_dir_all(root);
20137 }
20138
20139 #[cfg(feature = "fixture")]
20140 #[test]
20141 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20142 let (shared, runtime, receipt, bundle, root) =
20143 codex_bundle_test_shared(CodexHomeProfile::Exact);
20144 let bundle_receipt = bundle.receipt();
20145 let address = SessionAddress {
20146 workspace_id: WorkspaceId::new("primary").unwrap(),
20147 session: SessionKey {
20148 instance_id: AgentInstanceId(311),
20149 generation: SessionGeneration(1),
20150 },
20151 };
20152 let (_overlay, guard) = shared
20153 .prepare_session_materialization(
20154 &address,
20155 &agent("codex"),
20156 SessionMode::Pty,
20157 Some(&receipt),
20158 Some(&bundle_receipt),
20159 None,
20160 None,
20161 )
20162 .unwrap()
20163 .unwrap();
20164 let materialization_id = guard.id().unwrap().clone();
20165 let skill_path = shared
20166 .materializations
20167 .lock()
20168 .unwrap_or_else(|poisoned| poisoned.into_inner())
20169 .get(&materialization_id)
20170 .unwrap()
20171 .provider_home()
20172 .join("skills/review-code/SKILL.md");
20173 let record_id = shared
20174 .bind_spawn_session_with_materialization(
20175 &address,
20176 agent("codex"),
20177 SessionMode::Pty,
20178 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20179 SpawnRecordPolicy::ProviderIdentityOnly,
20180 Some(receipt.clone()),
20181 Some(bundle_receipt.clone()),
20182 None,
20183 Some(materialization_id.clone()),
20184 )
20185 .unwrap()
20186 .unwrap();
20187 guard.retain();
20188 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20189
20190 let error = match shared.resolve_record_materialization(
20191 &record_id,
20192 &agent("codex"),
20193 SessionMode::Pty,
20194 Some(&receipt),
20195 Some(&bundle_receipt),
20196 None,
20197 ) {
20198 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20199 Err(error) => error,
20200 };
20201 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20202 assert_eq!(
20203 shared
20204 .materializations
20205 .lock()
20206 .unwrap_or_else(|poisoned| poisoned.into_inner())
20207 .get(&materialization_id)
20208 .unwrap()
20209 .state(),
20210 MaterializationState::RecoveryRequired,
20211 );
20212 drop(shared);
20213 drop(runtime);
20214 let _ = std::fs::remove_dir_all(root);
20215 }
20216
20217 fn bind_record_owned_materialization_fixture(
20218 shared: &NodeShared,
20219 receipt: &ResolvedEnvironmentProfileReceipt,
20220 instance_id: u64,
20221 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20222 let address = SessionAddress {
20223 workspace_id: WorkspaceId::new("primary").unwrap(),
20224 session: SessionKey {
20225 instance_id: AgentInstanceId(instance_id),
20226 generation: SessionGeneration(1),
20227 },
20228 };
20229 let (overlay, materialization_guard) = shared
20230 .prepare_session_materialization(
20231 &address,
20232 &agent("claude"),
20233 SessionMode::Pty,
20234 Some(receipt),
20235 None,
20236 None,
20237 None,
20238 )
20239 .unwrap()
20240 .unwrap();
20241 let materialization_id = materialization_guard.id().unwrap().clone();
20242 let materialization_root = shared
20243 .materializations
20244 .lock()
20245 .unwrap_or_else(|poisoned| poisoned.into_inner())
20246 .get(&materialization_id)
20247 .unwrap()
20248 .root()
20249 .to_path_buf();
20250 let selection = shared
20251 .select_environment_profile(
20252 address.session.instance_id,
20253 &agent("claude"),
20254 SessionMode::Pty,
20255 Some(receipt),
20256 )
20257 .unwrap()
20258 .unwrap();
20259 assert!(shared
20260 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20261 .unwrap()
20262 .is_none());
20263 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20264 let record_id = shared
20265 .bind_spawn_session_with_materialization(
20266 &address,
20267 agent("claude"),
20268 SessionMode::Pty,
20269 policy,
20270 SpawnRecordPolicy::ProviderIdentityOnly,
20271 Some(receipt.clone()),
20272 None,
20273 None,
20274 Some(materialization_id.clone()),
20275 )
20276 .unwrap()
20277 .unwrap();
20278 selection.retain();
20279 materialization_guard.retain();
20280 (address, record_id, materialization_id, materialization_root)
20281 }
20282
20283 #[test]
20284 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20285 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20286 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20287 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20288 }));
20289 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20290 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20291 let mut spec = spawn_spec_fixture();
20292 spec.expected_profile_revision =
20293 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20294 assert!(matches!(
20295 &spec.overrides.environment_profile_id,
20296 crate::protocol::SpawnOverride::Inherit,
20297 ));
20298 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20299 .requires_child_environment_profile_capability());
20300 assert!(request_requires_child_environment_profile(
20301 &shared,
20302 &NodeRequest::SpawnSpec { spec: spec.clone() },
20303 ));
20304
20305 let address = terminal_address(1);
20306 shared.bind_session_with_policy(
20307 &address,
20308 ProviderRuntimePolicy::raw_pty(),
20309 Some(environment_profile.clone()),
20310 );
20311 assert!(request_requires_child_environment_profile(
20312 &shared,
20313 &NodeRequest::Input {
20314 session: address.clone(),
20315 text: "x".to_owned(),
20316 },
20317 ));
20318
20319 let mut record = record("claude", "profile-record");
20320 record.active_session = Some(address.clone());
20321 record.environment_profile = Some(environment_profile.clone());
20322 let record_id = record.record_id.clone();
20323 shared.insert_record(record.clone()).unwrap();
20324 assert!(request_requires_child_environment_profile(
20325 &shared,
20326 &NodeRequest::ResumeSessionRecord {
20327 record_id,
20328 terminal_size: gate4agent_types::TerminalSize {
20329 rows: 24,
20330 columns: 80,
20331 },
20332 initial_prompt: None,
20333 },
20334 ));
20335
20336 let snapshot = snapshot_for_wire(
20337 &shared, false, true, true, true, false, false, false, false, true,
20338 );
20339 assert_eq!(snapshot.session_records.len(), 1);
20340 assert!(snapshot.session_records[0].environment_profile.is_none());
20341 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20342 sequence: 1,
20343 event: NodeEvent::SessionRecordUpserted { record },
20344 });
20345 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20346 panic!("record projection changed the event kind");
20347 };
20348 assert!(record.environment_profile.is_none());
20349
20350 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20351 let mut reply = ResponseEnvelope {
20352 request_id: 1,
20353 result: Ok(NodeResponse::SpawnSpecAccepted {
20354 receipt: resolved.receipt_with_environment(
20355 shared.incarnation_id,
20356 address,
20357 Some(environment_profile),
20358 ),
20359 }),
20360 };
20361 project_response_without_child_environment_profile(&mut reply);
20362 assert_eq!(
20363 reply.result.unwrap_err().code,
20364 NodeFailureCode::UnsupportedCapability,
20365 );
20366 }
20367
20368 #[test]
20369 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20370 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20371 let snapshot = shared.snapshot();
20372 let profiles = snapshot
20373 .launch_inventory
20374 .as_ref()
20375 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20376 .unwrap();
20377 assert_eq!(profiles.len(), 1);
20378 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20379 assert!(snapshot.requires_child_environment_profile_capability());
20380
20381 let mut legacy = snapshot.clone();
20382 clear_snapshot_child_environment_profiles(&mut legacy);
20383 assert!(legacy
20384 .launch_inventory
20385 .as_ref()
20386 .unwrap()
20387 .spawn_profiles
20388 .as_ref()
20389 .unwrap()[0]
20390 .environment_profile
20391 .is_none());
20392 assert!(!legacy.requires_child_environment_profile_capability());
20393
20394 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20395 let bindings = shared
20396 .environment_profiles
20397 .read()
20398 .unwrap_or_else(|poisoned| poisoned.into_inner())
20399 .clone();
20400 let mut provider_mismatch = bindings.clone();
20401 provider_mismatch
20402 .values_mut()
20403 .next()
20404 .unwrap()
20405 .provider = agent("codex");
20406 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20407 let mut mode_mismatch = profile;
20408 mode_mismatch.mode = SessionMode::Inline;
20409 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20410
20411 shared
20412 .environment_profiles
20413 .write()
20414 .unwrap_or_else(|poisoned| poisoned.into_inner())
20415 .clear();
20416 assert!(shared
20417 .snapshot()
20418 .launch_inventory
20419 .unwrap()
20420 .spawn_profiles
20421 .unwrap()
20422 .is_empty());
20423 }
20424
20425 #[test]
20426 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20427 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20428 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20429 }));
20430 let shared = terminal_test_shared();
20431 let bundle = ResolvedBundleReceipt {
20432 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20433 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20434 digest: crate::protocol::SpawnBundleDigest::new(format!(
20435 "sha256:{}",
20436 "0".repeat(64),
20437 ))
20438 .unwrap(),
20439 };
20440 let mut bundled = record("claude", "bundle-record");
20441 bundled.bundle = Some(bundle.clone());
20442 let record_id = bundled.record_id.clone();
20443 shared.insert_record(bundled.clone()).unwrap();
20444 assert!(request_requires_session_bundle(
20445 &shared,
20446 &NodeRequest::ForgetSessionRecord {
20447 record_id: record_id.clone(),
20448 },
20449 ));
20450
20451 let snapshot = snapshot_for_wire(
20452 &shared, false, true, true, true, true, false, false, false, true,
20453 );
20454 assert!(snapshot.session_records[0].bundle.is_none());
20455 let launch = snapshot.launch_inventory.as_ref().unwrap();
20456 assert!(launch.spawn_profiles.is_some());
20457 assert!(launch.bundles.is_none());
20458 let event = project_event_without_session_bundle(NodeEventEnvelope {
20459 sequence: 1,
20460 event: NodeEvent::SessionRecordUpserted { record: bundled },
20461 });
20462 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20463 panic!("record projection changed the event kind");
20464 };
20465 assert!(record.bundle.is_none());
20466
20467 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20468 let mut reply = ResponseEnvelope {
20469 request_id: 1,
20470 result: Ok(NodeResponse::SpawnSpecAccepted {
20471 receipt: resolved.receipt_with_materialization(
20472 shared.incarnation_id,
20473 terminal_address(17),
20474 None,
20475 Some(bundle),
20476 None,
20477 ),
20478 }),
20479 };
20480 project_response_without_session_bundle(&mut reply);
20481 assert_eq!(
20482 reply.result.unwrap_err().code,
20483 NodeFailureCode::UnsupportedCapability,
20484 );
20485 }
20486
20487 #[test]
20488 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20489 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20490 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20491 }));
20492 let context_id = SpawnContextId::new("context-review").unwrap();
20493 let context = ResolvedContextPackReceipt {
20494 id: context_id.clone(),
20495 digest: crate::protocol::SpawnContextDigest::new(format!(
20496 "sha256:{}",
20497 "c".repeat(64),
20498 ))
20499 .unwrap(),
20500 lineage: ContextPackLineageReceipt {
20501 source_node_id: NodeId::new("node-source").unwrap(),
20502 source_session: terminal_address(1),
20503 source_provider: agent("kimi"),
20504 },
20505 source_message_count: 2,
20506 retained_message_count: 2,
20507 byte_len: 256,
20508 truncated: false,
20509 };
20510 assert!(context.is_valid());
20511 let mut bound_record = record("codex", "context-record");
20512 bound_record.context_id = Some(context_id);
20513 bound_record.context = Some(context.clone());
20514 let session = gate4agent_types::SessionSnapshot {
20515 instance_id: AgentInstanceId(91),
20516 agent_id: agent("kimi"),
20517 transport: TransportKind::Pty,
20518 generation: SessionGeneration(3),
20519 status: SessionStatus::Running,
20520 pending_operation: None,
20521 pending_input: None,
20522 process_id: Some(9001),
20523 terminal_size: None,
20524 terminal_frame: None,
20525 terminal_stale: None,
20526 session_options: None,
20527 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20528 history: gate4agent_types::HistorySnapshot {
20529 pending: None,
20530 candidates: vec![HistoryCandidateSummary {
20531 id: "candidate-opaque".to_owned(),
20532 session_id_hint: "session-hint".to_owned(),
20533 modified_at_unix_ms: Some(1),
20534 }],
20535 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20536 loaded: Some(HistorySessionRecord {
20537 session_id: "private-vendor-session".to_owned(),
20538 title: Some("private title".to_owned()),
20539 cwd: Some(r"C:\private\history-root".to_owned()),
20540 model: Some("private-model".to_owned()),
20541 message_count: 2,
20542 completed_turn_count: None,
20543 total_tokens: 19,
20544 messages: vec![
20545 gate4agent_types::HistoryMessageRecord {
20546 role: gate4agent_types::HistoryMessageRole::User,
20547 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20548 },
20549 gate4agent_types::HistoryMessageRecord {
20550 role: gate4agent_types::HistoryMessageRole::Assistant,
20551 text: "private answer".to_owned(),
20552 },
20553 ],
20554 }),
20555 last_error: None,
20556 },
20557 resume: gate4agent_types::ResumeSnapshot::default(),
20558 foreground: gate4agent_types::ForegroundSnapshot::default(),
20559 provider: gate4agent_types::ProviderSnapshot::default(),
20560 screen_state: gate4agent_types::PtyScreenState::default(),
20561 };
20562 let original = NodeSnapshot {
20563 node_id: NodeId::new("node-terminal-test").unwrap(),
20564 enabled_providers: vec![agent("kimi"), agent("codex")],
20565 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20566 workspaces: vec![WorkspaceSnapshot {
20567 workspace_id: WorkspaceId::new("primary").unwrap(),
20568 canonical_root: opaque_windows_path(
20569 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20570 ),
20571 sessions: vec![session],
20572 worktree_service_mode: None,
20573 managed_worktree_profiles: None,
20574 }],
20575 session_records: vec![bound_record],
20576 managed_worktrees: Vec::new(),
20577 launch_inventory: None,
20578 agent_progress: Vec::new(),
20579 };
20580
20581 let mut negotiated = original.clone();
20582 project_snapshot_history_for_wire(&mut negotiated, true);
20583 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20584 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20585 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20586 assert!(!serde_json::to_string(&negotiated)
20587 .unwrap()
20588 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20589
20590 let mut legacy = original;
20591 project_snapshot_history_for_wire(&mut legacy, false);
20592 clear_snapshot_context_packs(&mut legacy);
20593 assert_eq!(
20594 legacy.workspaces[0].sessions[0].history,
20595 gate4agent_types::HistorySnapshot::default(),
20596 );
20597 assert!(legacy.session_records[0].context_id.is_none());
20598 assert!(legacy.session_records[0].context.is_none());
20599 let encoded = serde_json::to_string(&legacy).unwrap();
20600 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20601 assert!(!encoded.contains("context-review"));
20602
20603 let mut reply = ResponseEnvelope {
20604 request_id: 7,
20605 result: Ok(NodeResponse::ContextPackExported { context }),
20606 };
20607 project_response_without_context_pack(&mut reply);
20608 assert_eq!(
20609 reply.result.unwrap_err().code,
20610 NodeFailureCode::UnsupportedCapability,
20611 );
20612 }
20613
20614 #[test]
20615 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20616 let shared = terminal_test_shared();
20617 let history = HistorySessionRecord {
20618 session_id: "fixture-history".to_owned(),
20619 title: None,
20620 cwd: None,
20621 model: None,
20622 message_count: 1,
20623 completed_turn_count: None,
20624 total_tokens: 0,
20625 messages: vec![gate4agent_types::HistoryMessageRecord {
20626 role: gate4agent_types::HistoryMessageRole::User,
20627 text: "bounded handoff".to_owned(),
20628 }],
20629 };
20630 let pack = NodeContextPack::export(
20631 ContextPackLineageReceipt {
20632 source_node_id: shared.node_id.clone(),
20633 source_session: terminal_address(1),
20634 source_provider: agent("claude"),
20635 },
20636 &history,
20637 )
20638 .unwrap();
20639 let context = shared
20640 .context_catalog
20641 .write()
20642 .unwrap_or_else(|poisoned| poisoned.into_inner())
20643 .insert(pack)
20644 .unwrap();
20645 let mut retained = record("codex", "context-retained-record");
20646 retained.context_id = Some(context.id.clone());
20647 retained.context = Some(context.clone());
20648 let record_id = retained.record_id.clone();
20649 shared.insert_record(retained).unwrap();
20650 assert_eq!(
20651 shared.forget_context_pack(&context.id).unwrap_err().code,
20652 NodeFailureCode::ContextPackBusy,
20653 );
20654 assert!(shared
20655 .context_catalog
20656 .read()
20657 .unwrap_or_else(|poisoned| poisoned.into_inner())
20658 .get(&context.id)
20659 .is_some());
20660 shared
20661 .session_records
20662 .lock()
20663 .unwrap_or_else(|poisoned| poisoned.into_inner())
20664 .records
20665 .remove(&record_id);
20666
20667 let mut spec = spawn_spec_fixture();
20668 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20669 value: context.id.clone(),
20670 };
20671 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20672 let receipt = resolved.receipt_with_materialization(
20673 shared.incarnation_id,
20674 terminal_address(2),
20675 None,
20676 None,
20677 Some(context.clone()),
20678 );
20679 assert!(receipt.context_binding_is_valid());
20680 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20681
20682 shared.forget_context_pack(&context.id).unwrap();
20683 assert!(shared
20684 .context_catalog
20685 .read()
20686 .unwrap_or_else(|poisoned| poisoned.into_inner())
20687 .get(&context.id)
20688 .is_none());
20689 assert!(shared
20690 .replay_spawn_spec(&spec, Instant::now())
20691 .unwrap()
20692 .is_none());
20693 }
20694
20695 #[test]
20696 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20697 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20698 let address = terminal_address(1);
20699 let selection = shared
20700 .select_environment_profile(
20701 address.session.instance_id,
20702 &agent("claude"),
20703 SessionMode::Pty,
20704 Some(&environment_profile),
20705 )
20706 .unwrap()
20707 .unwrap();
20708 shared.bind_session_with_policy(
20709 &address,
20710 ProviderRuntimePolicy::raw_pty(),
20711 Some(environment_profile.clone()),
20712 );
20713 selection.retain();
20714 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20715 assert!(matches!(
20716 runtime.remove_native_launch_profile(&native_id),
20717 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20718 ));
20719 assert!(shared.remove_binding(&address).is_some());
20720 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20721
20722 let mismatched = ResolvedEnvironmentProfileReceipt {
20723 profile_id: environment_profile.profile_id,
20724 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20725 "local-claude-r2",
20726 )
20727 .unwrap(),
20728 network_allowlist: None,
20729 browser_profile_id: None,
20730 };
20731 let error = match shared.select_environment_profile(
20732 AgentInstanceId(42),
20733 &agent("claude"),
20734 SessionMode::Pty,
20735 Some(&mismatched),
20736 ) {
20737 Ok(_) => panic!("mismatched environment profile revision was selected"),
20738 Err(error) => error,
20739 };
20740 assert_eq!(
20741 error.code,
20742 NodeFailureCode::EnvironmentProfileBindingMismatch,
20743 );
20744 assert!(!shared
20745 .session_bindings
20746 .lock()
20747 .unwrap_or_else(|poisoned| poisoned.into_inner())
20748 .contains_key(&AgentInstanceId(42)));
20749 }
20750
20751 #[test]
20752 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20753 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20754 let address = terminal_address(1);
20755 let selection = shared
20756 .select_environment_profile(
20757 address.session.instance_id,
20758 &agent("claude"),
20759 SessionMode::Pty,
20760 Some(&environment_profile),
20761 )
20762 .unwrap()
20763 .unwrap();
20764 let overlay = NativeInstanceLaunchOverlay::new(
20765 agent("claude"),
20766 TransportKind::Pty,
20767 vec![EnvMutation {
20768 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20769 value: Some(OsString::from("fixture-secret")),
20770 }],
20771 vec![
20772 OsString::from("--plugin-dir"),
20773 std::env::current_dir().unwrap().into_os_string(),
20774 ],
20775 )
20776 .unwrap();
20777 let instance_overlay = shared
20778 .install_prepared_launch_overlay(
20779 address.session.instance_id,
20780 PreparedNativeLaunchOverlay::Instance(overlay),
20781 )
20782 .unwrap()
20783 .unwrap();
20784 let bundle = ResolvedBundleReceipt {
20785 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20786 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20787 digest: crate::protocol::SpawnBundleDigest::new(format!(
20788 "sha256:{}",
20789 "0".repeat(64),
20790 ))
20791 .unwrap(),
20792 };
20793 shared.bind_session_with_materialization(
20794 &address,
20795 ProviderRuntimePolicy::raw_pty(),
20796 Some(environment_profile),
20797 Some(bundle),
20798 None,
20799 None,
20800 );
20801 selection.retain();
20802 instance_overlay.retain();
20803
20804 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20805 assert!(matches!(
20806 runtime.remove_native_launch_profile(&native_id),
20807 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20808 ));
20809 assert!(shared.remove_binding(&address).is_some());
20810 assert!(!shared
20811 .native_launch_profile_control
20812 .as_ref()
20813 .unwrap()
20814 .clear_native_instance_launch_overlay(address.session.instance_id));
20815 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20816 }
20817
20818 #[test]
20819 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20820 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20821 let address = SessionAddress {
20822 workspace_id: WorkspaceId::new("primary").unwrap(),
20823 session: SessionKey {
20824 instance_id: AgentInstanceId(201),
20825 generation: SessionGeneration(1),
20826 },
20827 };
20828 let error = match shared.prepare_session_materialization(
20829 &address,
20830 &agent("claude"),
20831 SessionMode::Pty,
20832 Some(&receipt),
20833 None,
20834 None,
20835 None,
20836 ) {
20837 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20838 Err(error) => error,
20839 };
20840 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20841 assert!(shared.handle.snapshot().sessions.is_empty());
20842 assert!(shared
20843 .session_bindings
20844 .lock()
20845 .unwrap_or_else(|poisoned| poisoned.into_inner())
20846 .is_empty());
20847 assert!(shared
20848 .materializations
20849 .lock()
20850 .unwrap_or_else(|poisoned| poisoned.into_inner())
20851 .is_empty());
20852 drop(shared);
20853 drop(runtime);
20854 std::fs::remove_dir_all(root).unwrap();
20855 }
20856
20857 #[test]
20858 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20859 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20860 let address = SessionAddress {
20861 workspace_id: WorkspaceId::new("primary").unwrap(),
20862 session: SessionKey {
20863 instance_id: AgentInstanceId(202),
20864 generation: SessionGeneration(1),
20865 },
20866 };
20867 let (overlay, materialization_guard) = shared
20868 .prepare_session_materialization(
20869 &address,
20870 &agent("claude"),
20871 SessionMode::Pty,
20872 Some(&receipt),
20873 None,
20874 None,
20875 None,
20876 )
20877 .unwrap()
20878 .unwrap();
20879 let materialization_id = materialization_guard.id().unwrap().clone();
20880 let materialization_root = shared
20881 .materializations
20882 .lock()
20883 .unwrap_or_else(|poisoned| poisoned.into_inner())
20884 .get(&materialization_id)
20885 .unwrap()
20886 .root()
20887 .to_path_buf();
20888 let selection = shared
20889 .select_environment_profile(
20890 address.session.instance_id,
20891 &agent("claude"),
20892 SessionMode::Pty,
20893 Some(&receipt),
20894 )
20895 .unwrap()
20896 .unwrap();
20897 assert!(shared
20898 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20899 .unwrap()
20900 .is_none());
20901 shared.bind_session_with_materialization(
20902 &address,
20903 ProviderRuntimePolicy::raw_pty(),
20904 Some(receipt),
20905 None,
20906 None,
20907 Some(materialization_id),
20908 );
20909 selection.retain();
20910 materialization_guard.retain();
20911
20912 assert!(materialization_root.is_dir());
20913 assert!(shared
20914 .session_bindings
20915 .lock()
20916 .unwrap_or_else(|poisoned| poisoned.into_inner())
20917 .contains_key(&address.session.instance_id));
20918
20919 let removed = shared.remove_binding(&address).unwrap();
20920 shared
20921 .cleanup_session_owned_materialization(&address, &removed)
20922 .unwrap();
20923 assert!(!materialization_root.exists());
20924 assert!(shared
20925 .materializations
20926 .lock()
20927 .unwrap_or_else(|poisoned| poisoned.into_inner())
20928 .is_empty());
20929 drop(shared);
20930 drop(runtime);
20931 std::fs::remove_dir_all(root).unwrap();
20932 }
20933
20934 #[tokio::test]
20935 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20936 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20937 let address = SessionAddress {
20938 workspace_id: WorkspaceId::new("primary").unwrap(),
20939 session: SessionKey {
20940 instance_id: AgentInstanceId(203),
20941 generation: SessionGeneration(1),
20942 },
20943 };
20944 let (overlay, materialization_guard) = shared
20945 .prepare_session_materialization(
20946 &address,
20947 &agent("claude"),
20948 SessionMode::Pty,
20949 Some(&receipt),
20950 None,
20951 None,
20952 None,
20953 )
20954 .unwrap()
20955 .unwrap();
20956 let materialization_id = materialization_guard.id().unwrap().clone();
20957 let materialization_root = shared
20958 .materializations
20959 .lock()
20960 .unwrap_or_else(|poisoned| poisoned.into_inner())
20961 .get(&materialization_id)
20962 .unwrap()
20963 .root()
20964 .to_path_buf();
20965 let selection = shared
20966 .select_environment_profile(
20967 address.session.instance_id,
20968 &agent("claude"),
20969 SessionMode::Pty,
20970 Some(&receipt),
20971 )
20972 .unwrap()
20973 .unwrap();
20974 assert!(shared
20975 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20976 .unwrap()
20977 .is_none());
20978 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20979 let record_id = shared
20980 .bind_spawn_session_with_materialization(
20981 &address,
20982 agent("claude"),
20983 SessionMode::Pty,
20984 policy,
20985 SpawnRecordPolicy::ProviderIdentityOnly,
20986 Some(receipt.clone()),
20987 None,
20988 None,
20989 Some(materialization_id.clone()),
20990 )
20991 .unwrap()
20992 .unwrap();
20993 selection.retain();
20994 materialization_guard.retain();
20995 assert_eq!(
20996 shared
20997 .materializations
20998 .lock()
20999 .unwrap_or_else(|poisoned| poisoned.into_inner())
21000 .get(&materialization_id)
21001 .unwrap()
21002 .owner(),
21003 &MaterializationOwner::Record {
21004 record_id: record_id.clone(),
21005 },
21006 );
21007
21008 let removed = shared.remove_binding(&address).unwrap();
21009 shared
21010 .cleanup_session_owned_materialization(&address, &removed)
21011 .unwrap();
21012 assert!(materialization_root.is_dir());
21013 assert!(shared
21014 .resolve_record_materialization(
21015 &record_id,
21016 &agent("claude"),
21017 SessionMode::Pty,
21018 Some(&receipt),
21019 None,
21020 None,
21021 )
21022 .unwrap()
21023 .is_some());
21024 {
21025 let mut records = shared
21026 .session_records
21027 .lock()
21028 .unwrap_or_else(|poisoned| poisoned.into_inner());
21029 let record = records.records.get_mut(&record_id).unwrap();
21030 record.active_session = None;
21031 record.state = ManagedSessionState::Unavailable;
21032 }
21033 shared.forget_session_record(&record_id).await.unwrap();
21034 assert!(!materialization_root.exists());
21035 assert!(shared
21036 .materializations
21037 .lock()
21038 .unwrap_or_else(|poisoned| poisoned.into_inner())
21039 .is_empty());
21040 drop(shared);
21041 drop(runtime);
21042 std::fs::remove_dir_all(root).unwrap();
21043 }
21044
21045 #[test]
21046 fn record_materialization_revalidation_failure_persists_recovery_required() {
21047 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21048 let state_path = root.join("node-state-v6.json");
21049 shared.state_path = Some(state_path.clone());
21050 let (_address, _record_id, materialization_id, materialization_root) =
21051 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21052 std::fs::write(
21053 materialization_root.join(".gate4agent-materialization-owner"),
21054 b"tampered",
21055 )
21056 .unwrap();
21057
21058 let error = match shared.resolve_record_materialization(
21059 &_record_id,
21060 &agent("claude"),
21061 SessionMode::Pty,
21062 Some(&receipt),
21063 None,
21064 None,
21065 ) {
21066 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21067 Err(error) => error,
21068 };
21069 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21070 assert_eq!(
21071 shared
21072 .materializations
21073 .lock()
21074 .unwrap_or_else(|poisoned| poisoned.into_inner())
21075 .get(&materialization_id)
21076 .unwrap()
21077 .state(),
21078 MaterializationState::RecoveryRequired,
21079 );
21080 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21081 assert_eq!(loaded.materializations.len(), 1);
21082 assert_eq!(
21083 loaded.materializations[0].state(),
21084 MaterializationState::RecoveryRequired,
21085 );
21086 drop(shared);
21087 drop(runtime);
21088 std::fs::remove_dir_all(root).unwrap();
21089 }
21090
21091 #[test]
21092 fn transient_record_materialization_resolution_failure_remains_ready() {
21093 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21094 let (_address, record_id, materialization_id, _materialization_root) =
21095 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21096 deny.store(true, Ordering::Release);
21097
21098 let error = match shared.resolve_record_materialization(
21099 &record_id,
21100 &agent("claude"),
21101 SessionMode::Pty,
21102 Some(&receipt),
21103 None,
21104 None,
21105 ) {
21106 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21107 Err(error) => error,
21108 };
21109 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21110 assert_eq!(
21111 shared
21112 .materializations
21113 .lock()
21114 .unwrap_or_else(|poisoned| poisoned.into_inner())
21115 .get(&materialization_id)
21116 .unwrap()
21117 .state(),
21118 MaterializationState::Ready,
21119 );
21120 drop(shared);
21121 drop(runtime);
21122 std::fs::remove_dir_all(root).unwrap();
21123 }
21124
21125 #[test]
21126 fn startup_missing_record_materialization_is_not_resumable() {
21127 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21128 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21129 shared
21130 .insert_record(ManagedSessionRecord {
21131 record_id: record_id.clone(),
21132 display_name: "missing materialization".to_owned(),
21133 provider: agent("claude"),
21134 mode: SessionMode::Pty,
21135 state: ManagedSessionState::Dormant,
21136 workspace_id: WorkspaceId::new("primary").unwrap(),
21137 canonical_root: opaque_windows_path(
21138 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21139 ),
21140 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21141 key: gate4agent_types::ProviderSessionKey::SessionId,
21142 id: "provider-session-missing-materialization".to_owned(),
21143 transcript_path: None,
21144 }),
21145 active_session: None,
21146 environment_profile: Some(receipt),
21147 bundle: None,
21148 context_id: None,
21149 context: None,
21150 exported_context: None,
21151 task_binding: None,
21152 created_at_unix_ms: 1,
21153 updated_at_unix_ms: 1,
21154 last_error: None,
21155 })
21156 .unwrap();
21157
21158 shared.reconcile_materializations();
21159 let record = shared.record(&record_id).unwrap();
21160 assert_eq!(record.state, ManagedSessionState::Unavailable);
21161 assert!(record.provider_session.is_none());
21162 assert_eq!(
21163 record.last_error.as_deref(),
21164 Some("environment-profile-unavailable"),
21165 );
21166 drop(shared);
21167 drop(runtime);
21168 std::fs::remove_dir_all(root).unwrap();
21169 }
21170
21171 #[tokio::test]
21172 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21173 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21174 let lease = install_managed_lease(&shared);
21175 let address = SessionAddress {
21176 workspace_id: WorkspaceId::new("primary").unwrap(),
21177 session: SessionKey {
21178 instance_id: AgentInstanceId(204),
21179 generation: SessionGeneration(1),
21180 },
21181 };
21182 let (overlay, materialization_guard) = shared
21183 .prepare_session_materialization(
21184 &address,
21185 &agent("claude"),
21186 SessionMode::Pty,
21187 Some(&receipt),
21188 None,
21189 None,
21190 Some(lease.lease_id.clone()),
21191 )
21192 .unwrap()
21193 .unwrap();
21194 let materialization_id = materialization_guard.id().unwrap().clone();
21195 let materialization_root = shared
21196 .materializations
21197 .lock()
21198 .unwrap_or_else(|poisoned| poisoned.into_inner())
21199 .get(&materialization_id)
21200 .unwrap()
21201 .root()
21202 .to_path_buf();
21203 let selection = shared
21204 .select_environment_profile(
21205 address.session.instance_id,
21206 &agent("claude"),
21207 SessionMode::Pty,
21208 Some(&receipt),
21209 )
21210 .unwrap()
21211 .unwrap();
21212 assert!(shared
21213 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21214 .unwrap()
21215 .is_none());
21216 shared.bind_session_with_materialization(
21217 &address,
21218 ProviderRuntimePolicy::raw_pty(),
21219 Some(receipt),
21220 None,
21221 None,
21222 Some(materialization_id),
21223 );
21224 selection.retain();
21225 materialization_guard.retain();
21226
21227 let blocked = shared
21228 .cleanup_managed_worktree(&lease.lease_id, false)
21229 .await
21230 .unwrap_err();
21231 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21232 assert!(materialization_root.is_dir());
21233 let removed = shared.remove_binding(&address).unwrap();
21234 shared
21235 .cleanup_session_owned_materialization(&address, &removed)
21236 .unwrap();
21237 assert!(!materialization_root.exists());
21238 drop(shared);
21239 drop(runtime);
21240 std::fs::remove_dir_all(root).unwrap();
21241 }
21242
21243 #[tokio::test]
21244 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21245 let git_root = temporary_workspace_root("raw-managed-inventory");
21246 let source = git_root.join("source");
21247 let allocation = git_root.join("allocation");
21248 std::fs::create_dir_all(&source).unwrap();
21249 std::fs::create_dir_all(&allocation).unwrap();
21250 run_test_git(&source, &["init"]);
21251 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21252 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21253 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21254 run_test_git(&source, &["add", "seed.txt"]);
21255 run_test_git(&source, &["commit", "-m", "seed"]);
21256 let profile = ManagedWorktreeProfile::new(
21257 WorktreeProfileId::new("default").unwrap(),
21258 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21259 &allocation,
21260 "gate4agent",
21261 "HEAD",
21262 ManagedWorktreeRetention::RemoveWhenReleased,
21263 )
21264 .unwrap();
21265 let workspace = WorkspaceConfig::new(
21266 WorkspaceId::new("primary").unwrap(),
21267 &source,
21268 )
21269 .unwrap()
21270 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21271 .with_managed_worktree_profile(profile.clone())
21272 .unwrap();
21273 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21274 materialization_test_shared_with_workspace(false, workspace);
21275 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21276 let source_text = source.to_string_lossy().into_owned();
21277 let target_text = target.to_string_lossy().into_owned();
21278 let created = create_git_worktree(
21279 &source_text,
21280 &target_text,
21281 "gate4agent/mw-raw-inventory",
21282 Some("HEAD"),
21283 )
21284 .await
21285 .unwrap();
21286 let lease = ManagedWorktreeLeaseRecord {
21287 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21288 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21289 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21290 profile_id: profile.profile_id().clone(),
21291 profile_revision: profile.revision().clone(),
21292 target_root: created.path.clone(),
21293 branch: "gate4agent/mw-raw-inventory".to_owned(),
21294 base_commit: created.head.clone(),
21295 expected_head: Some(created.head.clone()),
21296 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21297 state: ManagedWorktreeLeaseState::Ready,
21298 session_holders: Vec::new(),
21299 record_holders: Vec::new(),
21300 cleanup_failure: None,
21301 created_at_unix_ms: 1,
21302 updated_at_unix_ms: 1,
21303 };
21304 shared
21305 .workspaces
21306 .write()
21307 .unwrap_or_else(|poisoned| poisoned.into_inner())
21308 .insert(lease.workspace_id.clone(), created.path.clone());
21309 *shared
21310 .managed_worktrees
21311 .lock()
21312 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21313 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21314 let address = SessionAddress {
21315 workspace_id: lease.workspace_id.clone(),
21316 session: SessionKey {
21317 instance_id: AgentInstanceId(205),
21318 generation: SessionGeneration(1),
21319 },
21320 };
21321 let (overlay, materialization_guard) = shared
21322 .prepare_session_materialization(
21323 &address,
21324 &agent("claude"),
21325 SessionMode::Pty,
21326 Some(&receipt),
21327 None,
21328 None,
21329 Some(lease.lease_id.clone()),
21330 )
21331 .unwrap()
21332 .unwrap();
21333 let materialization_id = materialization_guard.id().unwrap().clone();
21334 let materialization_root = shared
21335 .materializations
21336 .lock()
21337 .unwrap_or_else(|poisoned| poisoned.into_inner())
21338 .get(&materialization_id)
21339 .unwrap()
21340 .root()
21341 .to_path_buf();
21342 let selection = shared
21343 .select_environment_profile(
21344 address.session.instance_id,
21345 &agent("claude"),
21346 SessionMode::Pty,
21347 Some(&receipt),
21348 )
21349 .unwrap()
21350 .unwrap();
21351 assert!(shared
21352 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21353 .unwrap()
21354 .is_none());
21355 let record_id = shared
21356 .bind_spawn_session_with_materialization(
21357 &address,
21358 agent("claude"),
21359 SessionMode::Pty,
21360 ProviderRuntimePolicy::raw_pty(),
21361 SpawnRecordPolicy::Always,
21362 Some(receipt.clone()),
21363 None,
21364 None,
21365 Some(materialization_id.clone()),
21366 )
21367 .unwrap()
21368 .unwrap();
21369 selection.retain();
21370 materialization_guard.retain();
21371 let record = shared.record(&record_id).unwrap();
21372 assert_eq!(record.state, ManagedSessionState::Live);
21373 assert!(record.provider_session.is_none());
21374 assert!(record.environment_profile.is_none());
21375 assert!(record.bundle.is_none());
21376 assert!(record.context_id.is_none());
21377 assert!(record.context.is_none());
21378 assert_eq!(record.active_session.as_ref(), Some(&address));
21379 assert_eq!(
21380 shared
21381 .materializations
21382 .lock()
21383 .unwrap_or_else(|poisoned| poisoned.into_inner())
21384 .get(&materialization_id)
21385 .unwrap()
21386 .owner(),
21387 &MaterializationOwner::Session {
21388 incarnation_id: shared.incarnation_id,
21389 instance_id: address.session.instance_id,
21390 generation: address.session.generation,
21391 },
21392 );
21393 {
21394 let mut bindings = shared
21395 .session_bindings
21396 .lock()
21397 .unwrap_or_else(|poisoned| poisoned.into_inner());
21398 bindings
21399 .get_mut(&address.session.instance_id)
21400 .unwrap()
21401 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21402 }
21403 let lease_snapshot = shared
21404 .managed_worktrees
21405 .lock()
21406 .unwrap_or_else(|poisoned| poisoned.into_inner())
21407 .bind_session(
21408 &lease.lease_id,
21409 ManagedWorktreeSessionHolder {
21410 incarnation_id: shared.incarnation_id,
21411 instance_id: address.session.instance_id,
21412 generation: address.session.generation,
21413 },
21414 None,
21415 unix_time_ms(),
21416 )
21417 .unwrap();
21418 assert_eq!(lease_snapshot.active_session_count, 1);
21419 assert_eq!(lease_snapshot.managed_record_count, 0);
21420 shared.persist_state().unwrap();
21421 assert!(shared
21422 .managed_worktrees
21423 .lock()
21424 .unwrap_or_else(|poisoned| poisoned.into_inner())
21425 .get(&lease.lease_id)
21426 .unwrap()
21427 .record_holders
21428 .is_empty());
21429 shared
21430 .handle
21431 .dispatch(shared.prepare_command(ControlCommand::Register {
21432 instance_id: address.session.instance_id,
21433 agent_id: agent("claude"),
21434 transport: TransportKind::Pty,
21435 }))
21436 .unwrap();
21437 runtime.tick().await;
21438 shared.reconcile_managed_record(
21439 &record_id,
21440 &address,
21441 &ControlEvent {
21442 sequence: 1,
21443 command_id: None,
21444 instance_id: address.session.instance_id,
21445 generation: address.session.generation,
21446 event: ControlEventKind::Removed,
21447 },
21448 false,
21449 false,
21450 );
21451 let remove = shared.remove_session(&address);
21452 let drive_runtime = async {
21453 runtime.tick().await;
21454 };
21455 let (removed, ()) = tokio::join!(remove, drive_runtime);
21456 removed.unwrap();
21457
21458 assert!(!materialization_root.exists());
21459 assert!(!Path::new(&created.path).exists());
21460 assert_eq!(
21461 shared
21462 .managed_worktrees
21463 .lock()
21464 .unwrap_or_else(|poisoned| poisoned.into_inner())
21465 .get(&lease.lease_id)
21466 .unwrap()
21467 .state,
21468 ManagedWorktreeLeaseState::Removed,
21469 );
21470 let renamed = shared
21471 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21472 .unwrap();
21473 assert_eq!(renamed.record_id, record_id);
21474 assert_eq!(renamed.display_name, "raw managed inventory");
21475 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21476 assert!(renamed.active_session.is_none());
21477
21478 drop(shared);
21479 drop(runtime);
21480 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21481 std::fs::remove_dir_all(git_root).unwrap();
21482 }
21483
21484 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21485 let root = std::env::current_dir().unwrap()
21486 .join("managed-test-target")
21487 .to_string_lossy()
21488 .into_owned();
21489 let lease = ManagedWorktreeLeaseRecord {
21490 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21491 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21492 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21493 profile_id: WorktreeProfileId::new("default").unwrap(),
21494 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21495 target_root: root.clone(),
21496 branch: "gate4agent/mw-test".to_owned(),
21497 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21498 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21499 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21500 state: ManagedWorktreeLeaseState::Ready,
21501 session_holders: Vec::new(),
21502 record_holders: Vec::new(),
21503 cleanup_failure: None,
21504 created_at_unix_ms: 1,
21505 updated_at_unix_ms: 1,
21506 };
21507 shared.workspaces.write()
21508 .unwrap_or_else(|poisoned| poisoned.into_inner())
21509 .insert(lease.workspace_id.clone(), root);
21510 *shared.managed_worktrees.lock()
21511 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21512 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21513 lease
21514 }
21515
21516 #[cfg(feature = "fixture")]
21517 #[test]
21518 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21519 let mut shared = terminal_test_shared();
21520 shared.fixture_semantic_hook_policy = true;
21521 let lease = install_managed_lease(&shared);
21522 let address = SessionAddress {
21523 workspace_id: lease.workspace_id.clone(),
21524 session: SessionKey {
21525 instance_id: AgentInstanceId(206),
21526 generation: SessionGeneration(1),
21527 },
21528 };
21529 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21530 let effective_runtime_policy =
21531 shared.effective_spawn_runtime_policy(pre_admission_policy);
21532 assert_ne!(effective_runtime_policy, pre_admission_policy);
21533 assert!(effective_runtime_policy.provider_session_identity);
21534
21535 let record_id = shared
21536 .bind_spawn_session(
21537 &address,
21538 agent("claude"),
21539 SessionMode::Pty,
21540 effective_runtime_policy,
21541 SpawnRecordPolicy::Always,
21542 None,
21543 )
21544 .unwrap()
21545 .unwrap();
21546 assert_eq!(
21547 shared.record(&record_id).unwrap().state,
21548 ManagedSessionState::IdentityPending,
21549 );
21550 let (record_holder, raw_inventory_record_id) = {
21551 let mut bindings = shared
21552 .session_bindings
21553 .lock()
21554 .unwrap_or_else(|poisoned| poisoned.into_inner());
21555 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21556 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21557 managed_spawn_record_ownership(binding, effective_runtime_policy)
21558 };
21559 assert_eq!(record_holder.as_ref(), Some(&record_id));
21560 assert!(raw_inventory_record_id.is_none());
21561 let snapshot = shared
21562 .managed_worktrees
21563 .lock()
21564 .unwrap_or_else(|poisoned| poisoned.into_inner())
21565 .bind_session(
21566 &lease.lease_id,
21567 ManagedWorktreeSessionHolder {
21568 incarnation_id: shared.incarnation_id,
21569 instance_id: address.session.instance_id,
21570 generation: address.session.generation,
21571 },
21572 record_holder,
21573 unix_time_ms(),
21574 )
21575 .unwrap();
21576 assert_eq!(snapshot.managed_record_count, 1);
21577 assert_eq!(
21578 shared
21579 .managed_worktrees
21580 .lock()
21581 .unwrap_or_else(|poisoned| poisoned.into_inner())
21582 .get(&lease.lease_id)
21583 .unwrap()
21584 .record_holders,
21585 vec![record_id.clone()],
21586 );
21587
21588 let identity = ProviderSessionIdentity {
21589 key: ProviderSessionKey::SessionId,
21590 id: "monitoring-fixture-managed-session".to_owned(),
21591 transcript_path: None,
21592 };
21593 shared.publish_control(ControlEvent {
21594 sequence: 1,
21595 command_id: None,
21596 instance_id: address.session.instance_id,
21597 generation: address.session.generation,
21598 event: ControlEventKind::ProviderEvent {
21599 sequence: 1,
21600 source: gate4agent_types::ProviderSource {
21601 family: AdapterFamily::Hook,
21602 binding: AdapterBinding::new(
21603 gate4agent_types::AdapterId::new("claude-code").unwrap(),
21604 "fixture/v1",
21605 gate4agent_types::AdapterVerification::SyntheticFixture,
21606 )
21607 .unwrap(),
21608 },
21609 source_sequence: 1,
21610 event: ProviderEvent::SessionIdentityObserved {
21611 identity: identity.clone(),
21612 },
21613 },
21614 });
21615
21616 let live = shared.record(&record_id).unwrap();
21617 assert_eq!(live.state, ManagedSessionState::Live);
21618 assert_eq!(live.provider_session.as_ref(), Some(&identity));
21619 assert_eq!(live.active_session.as_ref(), Some(&address));
21620 assert_eq!(
21621 shared
21622 .managed_worktrees
21623 .lock()
21624 .unwrap_or_else(|poisoned| poisoned.into_inner())
21625 .get(&lease.lease_id)
21626 .unwrap()
21627 .record_holders,
21628 vec![record_id],
21629 );
21630 }
21631
21632 fn managed_v2_test_shared(
21633 allocation_root: &Path,
21634 state_path: Option<PathBuf>,
21635 profile_revision: &str,
21636 ) -> NodeShared {
21637 let profile = ManagedWorktreeProfile::new(
21638 WorktreeProfileId::new("default").unwrap(),
21639 WorktreeProfileRevision::new(profile_revision).unwrap(),
21640 allocation_root,
21641 "gate4agent",
21642 "HEAD",
21643 ManagedWorktreeRetention::RemoveWhenReleased,
21644 )
21645 .unwrap();
21646 let workspace = WorkspaceConfig::new(
21647 WorkspaceId::new("primary").unwrap(),
21648 std::env::current_dir().unwrap(),
21649 )
21650 .unwrap()
21651 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21652 .with_managed_worktree_profile(profile)
21653 .unwrap();
21654 let catalog = active_registry().unwrap();
21655 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21656 let mut shared = NodeShared::new(
21657 handle,
21658 "fixture-token".to_owned(),
21659 NodeId::new("node-terminal-test").unwrap(),
21660 vec![workspace],
21661 vec![agent("claude")],
21662 );
21663 shared.state_path = state_path;
21664 shared
21665 }
21666
21667 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21668 let mut spawn_spec = spawn_spec_fixture();
21669 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21670 ManagedWorktreeSpawnRequestV2 {
21671 spawn_spec,
21672 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21673 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21674 }
21675 }
21676
21677 #[test]
21678 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21679 let shared = terminal_test_shared();
21680 let mut lease = install_managed_lease(&shared);
21681 let session = SessionAddress {
21682 workspace_id: lease.workspace_id.clone(),
21683 session: SessionKey {
21684 instance_id: AgentInstanceId(73),
21685 generation: SessionGeneration(4),
21686 },
21687 };
21688 lease.state = ManagedWorktreeLeaseState::InUse;
21689 lease.session_holders.push(ManagedWorktreeSessionHolder {
21690 incarnation_id: shared.incarnation_id,
21691 instance_id: session.session.instance_id,
21692 generation: session.session.generation,
21693 });
21694 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21695 assert!(resolved.target.worktree_id.is_none());
21696
21697 let receipt = managed_spawn_receipt(
21698 &resolved,
21699 shared.incarnation_id,
21700 session.clone(),
21701 lease.snapshot(),
21702 None,
21703 None,
21704 None,
21705 );
21706 assert!(resolved.target.worktree_id.is_none());
21707 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21708 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21709 assert_eq!(receipt.spawn.session, session);
21710 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21711 let encoded = serde_json::to_vec(&receipt).unwrap();
21712 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21713 assert_eq!(decoded, receipt);
21714 }
21715
21716 #[tokio::test]
21717 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21718 let root = temporary_workspace_root("managed-v2-revision");
21719 let allocation = root.join("allocation");
21720 std::fs::create_dir_all(&allocation).unwrap();
21721 let shared = managed_v2_test_shared(
21722 &allocation,
21723 Some(root.join("node-state.json")),
21724 "v1",
21725 );
21726 let mut request = managed_v2_request("managed-v2-revision");
21727 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21728
21729 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21730
21731 assert_eq!(
21732 failure.code,
21733 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21734 );
21735 assert!(shared
21736 .managed_worktrees
21737 .lock()
21738 .unwrap_or_else(|poisoned| poisoned.into_inner())
21739 .records()
21740 .is_empty());
21741 assert!(shared
21742 .managed_spawn_replays
21743 .lock()
21744 .unwrap_or_else(|poisoned| poisoned.into_inner())
21745 .is_empty());
21746 std::fs::remove_dir_all(root).unwrap();
21747 }
21748
21749 #[test]
21750 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21751 let root = temporary_workspace_root("managed-v2-concurrent");
21752 let allocation = root.join("allocation");
21753 std::fs::create_dir_all(&allocation).unwrap();
21754 let shared = Arc::new(managed_v2_test_shared(
21755 &allocation,
21756 Some(root.join("node-state.json")),
21757 "v1",
21758 ));
21759 let request = managed_v2_request("managed-v2-concurrent");
21760 let barrier = Arc::new(std::sync::Barrier::new(3));
21761 let mut joins = Vec::new();
21762 for _ in 0..2 {
21763 let shared = Arc::clone(&shared);
21764 let request = request.clone();
21765 let barrier = Arc::clone(&barrier);
21766 joins.push(std::thread::spawn(move || {
21767 let digest = managed_spawn_request_digest_v2(&request).unwrap();
21768 barrier.wait();
21769 shared.acquire_durable_managed_spawn_v2(&request, digest)
21770 }));
21771 }
21772 barrier.wait();
21773 let results = joins
21774 .into_iter()
21775 .map(|join| join.join().unwrap())
21776 .collect::<Vec<_>>();
21777 assert_eq!(
21778 results
21779 .iter()
21780 .filter(|result| {
21781 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21782 })
21783 .count(),
21784 1,
21785 );
21786 assert_eq!(
21787 results
21788 .iter()
21789 .filter(|result| {
21790 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21791 })
21792 .count(),
21793 1,
21794 );
21795 assert_eq!(
21796 shared
21797 .managed_spawn_replays
21798 .lock()
21799 .unwrap_or_else(|poisoned| poisoned.into_inner())
21800 .len(),
21801 1,
21802 );
21803 assert!(shared
21804 .managed_worktrees
21805 .lock()
21806 .unwrap_or_else(|poisoned| poisoned.into_inner())
21807 .records()
21808 .is_empty());
21809 drop(shared);
21810 std::fs::remove_dir_all(root).unwrap();
21811 }
21812
21813 #[tokio::test]
21814 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21815 let root = temporary_workspace_root("managed-v2-linked-pending");
21816 let allocation = root.join("allocation");
21817 std::fs::create_dir_all(&allocation).unwrap();
21818 let state_path = root.join("node-state.json");
21819 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21820 let lease = install_managed_lease(&shared);
21821 let request = managed_v2_request("managed-v2-linked-pending");
21822 assert!(matches!(
21823 shared
21824 .acquire_durable_managed_spawn_v2(
21825 &request,
21826 managed_spawn_request_digest_v2(&request).unwrap(),
21827 )
21828 .unwrap(),
21829 DurableManagedSpawnReplayDecision::Reserved,
21830 ));
21831 {
21832 let _transaction = shared
21833 .state_transaction
21834 .lock()
21835 .unwrap_or_else(|poisoned| poisoned.into_inner());
21836 shared
21837 .link_durable_managed_spawn_v2_lease_locked(
21838 &request.spawn_spec.idempotency_key,
21839 &lease.lease_id,
21840 )
21841 .unwrap();
21842 shared.persist_state_locked().unwrap();
21843 }
21844 drop(shared);
21845
21846 let mut loaded = session_registry::load(
21847 Some(&state_path),
21848 &NodeId::new("node-terminal-test").unwrap(),
21849 )
21850 .unwrap();
21851 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21852 *reopened
21853 .managed_worktrees
21854 .lock()
21855 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21856 ManagedWorktreeRegistry::from_records(
21857 std::mem::take(&mut loaded.managed_worktrees),
21858 std::mem::take(&mut loaded.managed_worktree_tombstones),
21859 )
21860 .unwrap();
21861 *reopened
21862 .managed_spawn_replays
21863 .lock()
21864 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21865 .managed_spawn_replays
21866 .into_iter()
21867 .map(|record| (record.idempotency_key.clone(), record))
21868 .collect();
21869 let lease_count = reopened
21870 .managed_worktrees
21871 .lock()
21872 .unwrap_or_else(|poisoned| poisoned.into_inner())
21873 .records()
21874 .len();
21875
21876 let failure = reopened
21877 .spawn_managed_worktree_v2(request)
21878 .await
21879 .unwrap_err();
21880
21881 assert_eq!(
21882 failure.code,
21883 NodeFailureCode::ManagedWorktreeRecoveryRequired,
21884 );
21885 assert_eq!(
21886 reopened
21887 .managed_worktrees
21888 .lock()
21889 .unwrap_or_else(|poisoned| poisoned.into_inner())
21890 .records()
21891 .len(),
21892 lease_count,
21893 );
21894 std::fs::remove_dir_all(root).unwrap();
21895 }
21896
21897 #[tokio::test]
21898 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21899 let root = temporary_workspace_root("managed-v2-reopen");
21900 let allocation = root.join("allocation");
21901 std::fs::create_dir_all(&allocation).unwrap();
21902 let state_path = root.join("node-state.json");
21903 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21904 let lease = install_managed_lease(&shared);
21905 let request = managed_v2_request("managed-v2-reopen");
21906 let session = SessionAddress {
21907 workspace_id: lease.workspace_id.clone(),
21908 session: SessionKey {
21909 instance_id: AgentInstanceId(91),
21910 generation: SessionGeneration(1),
21911 },
21912 };
21913 let snapshot = {
21914 let mut registry = shared
21915 .managed_worktrees
21916 .lock()
21917 .unwrap_or_else(|poisoned| poisoned.into_inner());
21918 let current = registry.get_mut(&lease.lease_id).unwrap();
21919 current.state = ManagedWorktreeLeaseState::InUse;
21920 current.session_holders.push(ManagedWorktreeSessionHolder {
21921 incarnation_id: shared.incarnation_id,
21922 instance_id: session.session.instance_id,
21923 generation: session.session.generation,
21924 });
21925 current.snapshot()
21926 };
21927 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21928 let receipt = managed_spawn_receipt(
21929 &resolved,
21930 shared.incarnation_id,
21931 session,
21932 snapshot,
21933 None,
21934 None,
21935 None,
21936 );
21937 assert!(matches!(
21938 shared
21939 .acquire_durable_managed_spawn_v2(
21940 &request,
21941 managed_spawn_request_digest_v2(&request).unwrap(),
21942 )
21943 .unwrap(),
21944 DurableManagedSpawnReplayDecision::Reserved,
21945 ));
21946 {
21947 let _transaction = shared
21948 .state_transaction
21949 .lock()
21950 .unwrap_or_else(|poisoned| poisoned.into_inner());
21951 shared
21952 .link_durable_managed_spawn_v2_lease_locked(
21953 &request.spawn_spec.idempotency_key,
21954 &lease.lease_id,
21955 )
21956 .unwrap();
21957 shared.persist_state_locked().unwrap();
21958 }
21959 shared
21960 .commit_durable_managed_spawn_v2(&request, receipt.clone())
21961 .unwrap();
21962 let legacy_same_key = ManagedWorktreeSpawnRequest {
21963 spawn_spec: request.spawn_spec.clone(),
21964 worktree_profile_id: request.worktree_profile_id.clone(),
21965 };
21966 shared.remember_managed_spawn_attempt(
21967 Some(&request.spawn_spec.idempotency_key),
21968 legacy_same_key.clone(),
21969 Ok(receipt.clone()),
21970 Instant::now(),
21971 );
21972 assert!(shared
21973 .replay_managed_spawn(&legacy_same_key, Instant::now())
21974 .unwrap()
21975 .is_none());
21976 drop(shared);
21977
21978 let mut loaded = session_registry::load(
21979 Some(&state_path),
21980 &NodeId::new("node-terminal-test").unwrap(),
21981 )
21982 .unwrap();
21983 assert_eq!(loaded.managed_spawn_replays.len(), 1);
21984 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21985 *reopened
21986 .managed_worktrees
21987 .lock()
21988 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21989 ManagedWorktreeRegistry::from_records(
21990 std::mem::take(&mut loaded.managed_worktrees),
21991 std::mem::take(&mut loaded.managed_worktree_tombstones),
21992 )
21993 .unwrap();
21994 *reopened
21995 .managed_spawn_replays
21996 .lock()
21997 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21998 .managed_spawn_replays
21999 .into_iter()
22000 .map(|record| (record.idempotency_key.clone(), record))
22001 .collect();
22002 let lease_count_before = reopened
22003 .managed_worktrees
22004 .lock()
22005 .unwrap_or_else(|poisoned| poisoned.into_inner())
22006 .records()
22007 .len();
22008
22009 assert_eq!(
22010 reopened
22011 .spawn_managed_worktree_v2(request.clone())
22012 .await
22013 .unwrap(),
22014 receipt,
22015 );
22016 assert_eq!(
22017 reopened
22018 .managed_worktrees
22019 .lock()
22020 .unwrap_or_else(|poisoned| poisoned.into_inner())
22021 .records()
22022 .len(),
22023 lease_count_before,
22024 );
22025 let mut changed = request;
22026 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22027 let failure = reopened
22028 .spawn_managed_worktree_v2(changed)
22029 .await
22030 .unwrap_err();
22031 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
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 new_key = managed_v2_request("managed-v2-new-after-profile-change");
22042 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22043 let failure = reopened
22044 .spawn_managed_worktree_v2(new_key)
22045 .await
22046 .unwrap_err();
22047 assert_eq!(
22048 failure.code,
22049 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22050 );
22051 assert_eq!(
22052 reopened
22053 .managed_worktrees
22054 .lock()
22055 .unwrap_or_else(|poisoned| poisoned.into_inner())
22056 .records()
22057 .len(),
22058 lease_count_before,
22059 );
22060 assert_eq!(
22061 reopened
22062 .managed_spawn_replays
22063 .lock()
22064 .unwrap_or_else(|poisoned| poisoned.into_inner())
22065 .len(),
22066 1,
22067 );
22068 std::fs::remove_dir_all(root).unwrap();
22069 }
22070
22071 #[test]
22072 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22073 let shared = terminal_test_shared();
22074 let request = ManagedWorktreeSpawnRequest {
22075 spawn_spec: spawn_spec_fixture(),
22076 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22077 };
22078 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22079 shared.remember_managed_spawn(
22080 request.clone(),
22081 Err(expected.clone()),
22082 Instant::now(),
22083 );
22084
22085 assert_eq!(
22086 shared
22087 .replay_managed_spawn(&request, Instant::now())
22088 .unwrap()
22089 .unwrap(),
22090 Err(expected),
22091 );
22092 assert!(shared
22093 .managed_spawn_replays
22094 .lock()
22095 .unwrap_or_else(|poisoned| poisoned.into_inner())
22096 .is_empty());
22097 }
22098
22099 #[tokio::test]
22100 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22101 let shared = terminal_test_shared();
22102 let lease = install_managed_lease(&shared);
22103 let raw = shared.spawn_session(
22104 lease.workspace_id.clone(),
22105 agent("claude"),
22106 SessionMode::Pty,
22107 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22108 None,
22109 ).await.unwrap_err();
22110 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22111 let mut spec = spawn_spec_fixture();
22112 spec.target.workspace_id = lease.workspace_id.clone();
22113 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22114 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22115 let mut selected = spawn_spec_fixture();
22116 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22117 selected.target.worktree_id = Some(lease.workspace_id);
22118 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22119 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22120 assert!(shared.handle.snapshot().sessions.is_empty());
22121 }
22122
22123 #[test]
22124 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22125 let shared = terminal_test_shared();
22126 let lease = install_managed_lease(&shared);
22127 let snapshot = snapshot_for_wire(
22128 &shared, true, true, true, false, true, true, false, false, true,
22129 );
22130 assert!(snapshot.managed_worktrees.is_empty());
22131 assert!(snapshot.workspaces.iter().all(|workspace| {
22132 workspace.managed_worktree_profiles.is_none()
22133 }));
22134 let mut reply = ResponseEnvelope {
22135 request_id: 1,
22136 result: Ok(NodeResponse::Resync {
22137 event_sequence: 1,
22138 oldest_available_sequence: 1,
22139 snapshot: shared.snapshot(),
22140 events: vec![NodeEventEnvelope {
22141 sequence: 1,
22142 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22143 }],
22144 }),
22145 };
22146 project_response_without_managed_worktrees(&mut reply);
22147 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22148 panic!("projection changed the response kind");
22149 };
22150 assert!(snapshot.managed_worktrees.is_empty());
22151 assert!(events.is_empty());
22152 let mut git = git_snapshot_for_parser();
22153 git.branch = Some(lease.branch.clone());
22154 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22155 lease_id: lease.lease_id.clone(),
22156 source_workspace_id: lease.source_workspace_id.clone(),
22157 branch: lease.branch.clone(),
22158 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22159 active_session_count: 1,
22160 managed_record_count: 0,
22161 });
22162 let mut inspection_reply = ResponseEnvelope {
22163 request_id: 2,
22164 result: Ok(NodeResponse::WorkspaceInspected {
22165 inspection: WorkspaceInspection {
22166 workspace_id: lease.workspace_id.clone(),
22167 entries: Vec::new(),
22168 tree_truncated: false,
22169 git,
22170 truncation: None,
22171 },
22172 }),
22173 };
22174 project_response_without_managed_worktrees(&mut inspection_reply);
22175 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22176 panic!("projection changed the inspection response kind");
22177 };
22178 assert!(inspection.git.managed_worktree.is_none());
22179 let failure = managed_git_worktree_failure(GitWorktreeError {
22180 kind: GitWorktreeErrorKind::Failed,
22181 message: r"git failed at C:\private\managed-target".to_owned(),
22182 });
22183 assert!(!failure.message.contains("private"));
22184 assert!(!failure.message.contains("managed-target"));
22185 }
22186
22187 #[tokio::test]
22188 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22189 let root = temporary_workspace_root("managed-restart");
22190 let source = root.join("source");
22191 let allocation = root.join("allocation");
22192 std::fs::create_dir_all(&source).unwrap();
22193 std::fs::create_dir_all(&allocation).unwrap();
22194 run_test_git(&source, &["init"]);
22195 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22196 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22197 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22198 run_test_git(&source, &["add", "seed.txt"]);
22199 run_test_git(&source, &["commit", "-m", "seed"]);
22200 let profile = ManagedWorktreeProfile::new(
22201 WorktreeProfileId::new("default").unwrap(),
22202 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22203 &allocation,
22204 "gate4agent",
22205 "HEAD",
22206 ManagedWorktreeRetention::Retain,
22207 ).unwrap();
22208 let workspace = WorkspaceConfig::new(
22209 WorkspaceId::new("primary").unwrap(),
22210 &source,
22211 ).unwrap()
22212 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22213 .with_managed_worktree_profile(profile.clone()).unwrap();
22214 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22215 let source_text = workspace.canonical_root().to_owned();
22216 let target_text = target.to_string_lossy().into_owned();
22217 let created = create_git_worktree(
22218 &source_text,
22219 &target_text,
22220 "gate4agent/mw-restart",
22221 Some("HEAD"),
22222 ).await.unwrap();
22223 let lease = ManagedWorktreeLeaseRecord {
22224 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22225 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22226 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22227 profile_id: profile.profile_id().clone(),
22228 profile_revision: profile.revision().clone(),
22229 target_root: created.path.clone(),
22230 branch: "gate4agent/mw-restart".to_owned(),
22231 base_commit: created.head.clone(),
22232 expected_head: None,
22233 retention: ManagedWorktreeRetention::Retain,
22234 state: ManagedWorktreeLeaseState::Allocating,
22235 session_holders: Vec::new(),
22236 record_holders: Vec::new(),
22237 cleanup_failure: None,
22238 created_at_unix_ms: 1,
22239 updated_at_unix_ms: 1,
22240 };
22241 let catalog = active_registry().unwrap();
22242 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22243 let shared = NodeShared::new_with_incarnation(
22244 handle,
22245 "fixture-token".to_owned(),
22246 NodeId::new("node-managed-restart").unwrap(),
22247 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22248 vec![workspace],
22249 vec![agent("claude")],
22250 ProviderRuntimeStatuses::default(),
22251 None,
22252 Vec::new(),
22253 Vec::new(),
22254 SpawnProfileRegistry::default(),
22255 None,
22256 None,
22257 None,
22258 None,
22259 Vec::new(),
22260 vec![lease.clone()],
22261 Vec::new(),
22262 Vec::new(),
22263 None,
22264 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22265 );
22266 shared.reconcile_managed_worktrees().await;
22267 let retained = shared.managed_worktrees.lock()
22268 .unwrap_or_else(|poisoned| poisoned.into_inner())
22269 .get(&lease.lease_id).unwrap().clone();
22270 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22271 assert!(shared.workspaces.read()
22272 .unwrap_or_else(|poisoned| poisoned.into_inner())
22273 .contains_key(&lease.workspace_id));
22274 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22275 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22276 assert!(!Path::new(&created.path).exists());
22277 std::fs::remove_dir_all(root).unwrap();
22278 }
22279
22280 fn run_test_git(root: &Path, arguments: &[&str]) {
22281 let output = std::process::Command::new("git")
22282 .arg("-C").arg(root).args(arguments).output().unwrap();
22283 assert!(
22284 output.status.success(),
22285 "git {:?} failed: {}",
22286 arguments,
22287 String::from_utf8_lossy(&output.stderr),
22288 );
22289 }
22290
22291 #[tokio::test]
22292 async fn spawn_spec_validation_fails_before_session_mutation() {
22293 let shared = terminal_test_shared();
22294 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22295 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22296 }));
22297 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22298 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22299 }));
22300 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22301 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22302 }));
22303
22304 let mut target_mismatch = spawn_spec_fixture();
22305 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22306 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22307 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22308
22309 let mut unknown_profile = spawn_spec_fixture();
22310 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22311 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22312 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22313 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22314
22315 let mut revision_mismatch = spawn_spec_fixture();
22316 revision_mismatch.idempotency_key =
22317 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22318 revision_mismatch.expected_profile_revision =
22319 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22320 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22321 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22322
22323 let mut unsupported_materializer = spawn_spec_fixture();
22324 unsupported_materializer.idempotency_key =
22325 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22326 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22327 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22328 };
22329 let failure = shared
22330 .spawn_from_spec(unsupported_materializer)
22331 .await
22332 .unwrap_err();
22333 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22334
22335 let failure = shared
22336 .spawn_session_with_deadline(
22337 WorkspaceId::new("primary").unwrap(),
22338 agent("claude"),
22339 SessionMode::Pty,
22340 gate4agent_types::TerminalSize {
22341 rows: 24,
22342 columns: 80,
22343 },
22344 None,
22345 None,
22346 None,
22347 None,
22348 None,
22349 None,
22350 SpawnRecordPolicy::ProviderIdentityOnly,
22351 Some(Instant::now()),
22352 &[],
22353 None,
22354 ApprovalLevel::default(),
22355 None,
22356 None,
22357 )
22358 .await
22359 .unwrap_err();
22360 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22361 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22362 assert!(shared.handle.snapshot().sessions.is_empty());
22363 }
22364
22365 #[test]
22366 fn worktree_selection_requires_its_separate_negotiated_capability() {
22367 let mut spec = spawn_spec_fixture();
22368 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22369 let request = NodeRequest::SpawnSpec { spec };
22370 let spawn_spec_capability =
22371 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22372 assert!(request_uses_unnegotiated_capability(
22373 &request,
22374 &[spawn_spec_capability.clone()],
22375 ));
22376 let worktree_capability =
22377 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22378 assert!(request_uses_unnegotiated_capability(
22379 &request,
22380 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22381 ));
22382 let profile_revision_capability =
22383 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22384 assert!(request_uses_unnegotiated_capability(
22385 &request,
22386 &[
22387 spawn_spec_capability.clone(),
22388 worktree_capability.clone(),
22389 profile_revision_capability.clone(),
22390 ],
22391 ));
22392 let bundle_materialization_capability =
22399 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_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 bundle_materialization_capability.clone(),
22407 ],
22408 ));
22409 let history_context_pack_capability =
22410 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22411 assert!(!request_uses_unnegotiated_capability(
22412 &request,
22413 &[
22414 spawn_spec_capability,
22415 worktree_capability,
22416 profile_revision_capability,
22417 bundle_materialization_capability,
22418 history_context_pack_capability,
22419 ],
22420 ));
22421 }
22422
22423 #[tokio::test]
22424 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22425 let mut shared = terminal_test_shared();
22426 let spec = spawn_spec_fixture();
22427 let receipt = shared
22428 .resolve_spawn_spec(&spec)
22429 .unwrap()
22430 .receipt(shared.incarnation_id, terminal_address(1));
22431 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22432
22433 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22434 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22435 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22436
22437 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22438
22439 let mut revision_conflicting = spec.clone();
22440 revision_conflicting.expected_profile_revision =
22441 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22442 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22443 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22444
22445 let mut stale_cache_miss = spec.clone();
22446 stale_cache_miss.idempotency_key =
22447 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22448 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22449 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22450
22451 let mut conflicting = spec;
22452 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22453 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22454 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22455 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22456 assert!(shared.handle.snapshot().sessions.is_empty());
22457 }
22458
22459 #[tokio::test]
22460 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22461 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22462 let mut spec = spawn_spec_fixture();
22463 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22464 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22465 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22466
22467 let failure = shared
22468 .create_worktree(
22469 WorkspaceId::new("primary").unwrap(),
22470 WorkspaceId::new("new-worktree").unwrap(),
22471 "not-an-absolute-path".to_owned(),
22472 "invalid branch".to_owned(),
22473 None,
22474 )
22475 .await
22476 .unwrap_err();
22477 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22478
22479 let failure = shared
22480 .remove_worktree(
22481 WorkspaceId::new("primary").unwrap(),
22482 "not-an-absolute-path".to_owned(),
22483 )
22484 .await
22485 .unwrap_err();
22486 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22487 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22488 assert!(shared.handle.snapshot().sessions.is_empty());
22489 }
22490
22491 #[tokio::test]
22492 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22493 let root = temporary_workspace_root("manual-worktree-compensation");
22494 let source = root.join("source");
22495 let target = root.join("target");
22496 let invalid_state_parent = root.join("state-parent-is-a-file");
22497 let invalid_state_path = invalid_state_parent.join("state.json");
22498 std::fs::create_dir_all(&source).unwrap();
22499 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22500 run_test_git(&source, &["init"]);
22501 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22502 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22503 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22504 run_test_git(&source, &["add", "seed.txt"]);
22505 run_test_git(&source, &["commit", "-m", "seed"]);
22506
22507 let catalog = active_registry().unwrap();
22508 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22509 let workspace = WorkspaceConfig::new(
22510 WorkspaceId::new("primary").unwrap(),
22511 &source,
22512 ).unwrap();
22513 let mut shared = NodeShared::new(
22514 handle,
22515 "fixture-token".to_owned(),
22516 NodeId::new("node-worktree-compensation").unwrap(),
22517 vec![workspace],
22518 vec![agent("claude")],
22519 );
22520 shared.state_path = Some(invalid_state_path);
22521
22522 let error = shared.create_worktree(
22523 WorkspaceId::new("primary").unwrap(),
22524 WorkspaceId::new("compensated").unwrap(),
22525 target.to_string_lossy().into_owned(),
22526 "gate4agent/compensated".to_owned(),
22527 Some("HEAD".to_owned()),
22528 ).await.unwrap_err();
22529
22530 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22531 assert!(!target.exists());
22532 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22533 .iter()
22534 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22535 std::fs::remove_dir_all(root).unwrap();
22536 }
22537
22538 #[test]
22539 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22540 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22541 NativeGitWorktreeSnapshot {
22542 path: path.to_owned(),
22543 head: "0123456789abcdef".to_owned(),
22544 branch: Some("refs/heads/test".to_owned()),
22545 is_bare,
22546 is_main,
22547 locked: false,
22548 lock_reason: None,
22549 prunable: false,
22550 prunable_reason: None,
22551 workspace_id: None,
22552 }
22553 }
22554
22555 let source = r"C:\repo";
22556 let selected = r"C:\repo-worktree";
22557 let listed = vec![
22558 worktree(source, true, false),
22559 worktree(selected, false, false),
22560 ];
22561 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22562
22563 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22564 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22565
22566 let bare = vec![
22567 worktree(source, true, false),
22568 worktree(selected, false, true),
22569 ];
22570 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22571 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22572
22573 let foreign_failure =
22574 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22575 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22576 }
22577
22578 fn terminal_frame(sequence: u64) -> TerminalFrame {
22579 TerminalFrame {
22580 sequence,
22581 size: gate4agent_types::TerminalSize {
22582 rows: 24,
22583 columns: 80,
22584 },
22585 cursor_row: 2,
22586 cursor_column: 3,
22587 contents: format!("frame-{sequence}"),
22588 formatted: vec![1, 2, 3],
22589 scrollback_formatted: Vec::new(),
22590 alternate_screen: false,
22591 mouse_protocol_enabled: false,
22592 mouse_protocol_encoding: Default::default(),
22593 produced_at_unix_ms: 0,
22594 screen_state: gate4agent_types::PtyScreenState::default(),
22595 bracketed_paste: None,
22596 }
22597 }
22598
22599 #[test]
22600 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22601 let shared = terminal_test_shared();
22602 let mut receiver = shared.terminal_event_tx.subscribe();
22603 let original = terminal_address(1);
22604
22605 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22606 let first = receiver.try_recv().unwrap();
22607 assert_eq!(first.len(), 1);
22608 assert!(matches!(
22609 &first[0].event,
22610 NodeEvent::TerminalFrame { address, frame }
22611 if address == &original && frame.sequence == 7
22612 ));
22613
22614 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22615 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22616 assert!(matches!(
22617 receiver.try_recv(),
22618 Err(broadcast::error::TryRecvError::Empty)
22619 ));
22620
22621 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22622 let advanced = receiver.try_recv().unwrap();
22623 assert_eq!(advanced[0].sequence, 2);
22624
22625 let resumed = terminal_address(2);
22626 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22627 let reset = receiver.try_recv().unwrap();
22628 assert_eq!(reset[0].sequence, 3);
22629 assert!(matches!(
22630 &reset[0].event,
22631 NodeEvent::TerminalFrame { address, frame }
22632 if address == &resumed && frame.sequence == 1
22633 ));
22634 shared.clear_terminal_frame_watermark(&resumed);
22635 assert!(!shared
22636 .terminal_frame_watermarks
22637 .lock()
22638 .unwrap_or_else(|poisoned| poisoned.into_inner())
22639 .contains_key(&resumed.session.instance_id));
22640 }
22641
22642 #[test]
22643 fn terminal_frame_batches_replace_without_entering_node_event_history() {
22644 let shared = terminal_test_shared();
22645 let mut receiver = shared.terminal_event_tx.subscribe();
22646 let address = terminal_address(1);
22647 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22648
22649 for sequence in 1..=count {
22650 shared.publish_terminal_frame_candidates(vec![(
22651 address.clone(),
22652 terminal_frame(sequence),
22653 )]);
22654 }
22655
22656 let history = shared
22657 .history
22658 .lock()
22659 .unwrap_or_else(|poisoned| poisoned.into_inner());
22660 assert!(history.events.is_empty());
22661 assert_eq!(history.last_sequence, count);
22662 assert_eq!(history.replay_floor_sequence, 1);
22663 drop(history);
22664 let NodeResponse::Resync {
22665 event_sequence,
22666 oldest_available_sequence,
22667 events,
22668 ..
22669 } = shared.resync(0) else {
22670 unreachable!("resync helper returned another response")
22671 };
22672 assert_eq!(event_sequence, count);
22673 assert_eq!(oldest_available_sequence, 1);
22674 assert!(events.is_empty());
22675 assert!(matches!(
22676 resync_required_event(&shared).event,
22677 NodeEvent::ResyncRequired {
22678 oldest_available_sequence: 1,
22679 }
22680 ));
22681 assert!(matches!(
22682 receiver.try_recv(),
22683 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22684 ));
22685 let latest = receiver.try_recv().unwrap();
22686 assert_eq!(latest.len(), 1);
22687 assert_eq!(latest[0].sequence, count);
22688 }
22689
22690 #[test]
22691 fn real_durable_eviction_advances_authoritative_replay_floor() {
22692 let shared = terminal_test_shared();
22693 for _ in 0..=NODE_EVENT_HISTORY_MAX {
22694 shared.publish(NodeEvent::ControllerChanged { controller: None });
22695 }
22696
22697 let NodeResponse::Resync {
22698 event_sequence,
22699 oldest_available_sequence,
22700 events,
22701 ..
22702 } = shared.resync(0) else {
22703 unreachable!("resync helper returned another response")
22704 };
22705 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22706 assert_eq!(oldest_available_sequence, 2);
22707 assert_eq!(events.first().map(|event| event.sequence), Some(2));
22708 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22709 assert!(matches!(
22710 resync_required_event(&shared).event,
22711 NodeEvent::ResyncRequired {
22712 oldest_available_sequence: 2,
22713 }
22714 ));
22715 }
22716
22717 #[test]
22718 fn legacy_terminal_frame_connection_has_no_live_subscription() {
22719 let shared = terminal_test_shared();
22720 let without_capability = terminal_event_subscription(&shared, false);
22721 let with_capability = terminal_event_subscription(&shared, true);
22722 assert!(without_capability.is_none());
22723 assert!(with_capability.is_some());
22724 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22725 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22726 }));
22727 }
22728
22729 #[test]
22730 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22731 let mut pending = Vec::new();
22732 let mut event_sequence = 0_u64;
22733 for frame_sequence in 1..=4 {
22734 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22735 event_sequence += 1;
22736 let mut address = terminal_address(1);
22737 address.session.instance_id = AgentInstanceId(instance);
22738 queue_connection_event(
22739 &mut pending,
22740 NodeEventEnvelope {
22741 sequence: event_sequence,
22742 event: NodeEvent::TerminalFrame {
22743 address,
22744 frame: terminal_frame(frame_sequence),
22745 },
22746 },
22747 0,
22748 );
22749 }
22750 }
22751 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22752 pending.sort_unstable_by_key(|event| event.sequence);
22753
22754 let mut uninterrupted = pending.clone();
22755 let first_burst = connection_event_burst_len(uninterrupted.len());
22756 uninterrupted.drain(..first_burst);
22757 let second_burst = connection_event_burst_len(uninterrupted.len());
22758 uninterrupted.drain(..second_burst);
22759 assert!(uninterrupted.is_empty());
22760
22761 let (frame_tx, mut frame_rx) =
22762 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22763 frame_tx
22764 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22765 request_id: 77,
22766 request: NodeRequest::Snapshot,
22767 })))
22768 .unwrap();
22769 let burst_len = connection_event_burst_len(pending.len());
22770 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22771 pending.drain(..burst_len);
22772 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22773 assert!(matches!(
22774 frame_rx.try_recv(),
22775 Ok(Ok(ClientFrame::Request(RequestEnvelope {
22776 request_id: 77,
22777 request: NodeRequest::Snapshot,
22778 })))
22779 ));
22780 }
22781
22782 #[test]
22783 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22784 let mut pending = Vec::new();
22785 for sequence in [7_u64, 8] {
22786 let mut address = terminal_address(1);
22787 address.session.instance_id = AgentInstanceId(sequence);
22788 queue_connection_event(
22789 &mut pending,
22790 NodeEventEnvelope {
22791 sequence,
22792 event: NodeEvent::TerminalFrame {
22793 address,
22794 frame: terminal_frame(sequence),
22795 },
22796 },
22797 0,
22798 );
22799 }
22800 let mut discard_through = 0;
22801 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22802 assert!(pending.is_empty());
22803
22804 let replayed = NodeEventEnvelope {
22805 sequence: 8,
22806 event: NodeEvent::TerminalFrame {
22807 address: terminal_address(1),
22808 frame: terminal_frame(8),
22809 },
22810 };
22811 let advanced = NodeEventEnvelope {
22812 sequence: 9,
22813 event: NodeEvent::TerminalFrame {
22814 address: terminal_address(2),
22815 frame: terminal_frame(1),
22816 },
22817 };
22818 queue_connection_event_batch(
22819 &mut pending,
22820 &[replayed, advanced.clone()],
22821 discard_through,
22822 );
22823 assert_eq!(pending, vec![advanced]);
22824 }
22825
22826 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22827 ManagedSessionRecord {
22828 record_id: SessionRecordId::new(record_id).unwrap(),
22829 display_name: format!("{provider} record"),
22830 provider: agent(provider),
22831 mode: SessionMode::Pty,
22832 state: ManagedSessionState::Unavailable,
22833 workspace_id: WorkspaceId::new("primary").unwrap(),
22834 canonical_root: opaque_windows_path(
22835 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22836 ),
22837 provider_session: None,
22838 active_session: None,
22839 environment_profile: None,
22840 bundle: None,
22841 context_id: None,
22842 context: None,
22843 exported_context: None,
22844 task_binding: None,
22845 created_at_unix_ms: 1,
22846 updated_at_unix_ms: 1,
22847 last_error: None,
22848 }
22849 }
22850
22851 fn retention_test_record(
22852 workspace_id: &str,
22853 record_id: &str,
22854 state: ManagedSessionState,
22855 updated_at_unix_ms: u64,
22856 ) -> ManagedSessionRecord {
22857 ManagedSessionRecord {
22858 record_id: SessionRecordId::new(record_id).unwrap(),
22859 display_name: format!("{record_id} display"),
22860 provider: agent("claude"),
22861 mode: SessionMode::Pty,
22862 state,
22863 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22864 canonical_root: opaque_windows_path(
22865 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22866 ),
22867 provider_session: None,
22868 active_session: None,
22869 environment_profile: None,
22870 bundle: None,
22871 context_id: None,
22872 context: None,
22873 exported_context: None,
22874 task_binding: None,
22875 created_at_unix_ms: updated_at_unix_ms,
22876 updated_at_unix_ms,
22877 last_error: None,
22878 }
22879 }
22880
22881 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22889 vec![
22890 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22891 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22892 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22893 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22894 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22895 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22896 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22897 retention_test_record(
22898 "workspace-a",
22899 "decoy-pending",
22900 ManagedSessionState::IdentityPending,
22901 now - 500_000,
22902 ),
22903 retention_test_record(
22904 "workspace-a",
22905 "decoy-dormant",
22906 ManagedSessionState::Dormant,
22907 now - 500_000,
22908 ),
22909 ]
22910 }
22911
22912 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22913 let root = temporary_workspace_root("session-record-retention");
22914 std::fs::create_dir_all(&root).unwrap();
22915 let catalog = active_registry().unwrap();
22916 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22917 let workspace_a =
22918 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22919 let workspace_b =
22920 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22921 let mut shared = NodeShared::new(
22922 handle,
22923 "fixture-token".to_owned(),
22924 NodeId::new("node-retention-test").unwrap(),
22925 vec![workspace_a, workspace_b],
22926 vec![agent("claude")],
22927 );
22928 shared.state_path = Some(root.join("node-state-v10.json"));
22929 let now = unix_time_ms();
22930 for record in retention_fixture_records(now) {
22931 shared.insert_record(record).unwrap();
22932 }
22933 shared
22934 .persist_state_locked()
22935 .expect("fixture-only, empty-history persist against a fresh temp state path");
22936 (shared, runtime, root, now)
22937 }
22938
22939 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22940 shared
22941 .history
22942 .lock()
22943 .unwrap_or_else(|poisoned| poisoned.into_inner())
22944 .events
22945 .iter()
22946 .filter_map(|envelope| match &envelope.event {
22947 NodeEvent::SessionRecordRemoved { record_id } => {
22948 Some(record_id.as_str().to_owned())
22949 }
22950 _ => None,
22951 })
22952 .collect()
22953 }
22954
22955 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22956 shared
22957 .session_records
22958 .lock()
22959 .unwrap_or_else(|poisoned| poisoned.into_inner())
22960 .records
22961 .keys()
22962 .map(|record_id| record_id.as_str().to_owned())
22963 .collect()
22964 }
22965
22966 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22967 values.iter().map(|value| (*value).to_owned()).collect()
22968 }
22969
22970 #[tokio::test]
22971 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22972 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22973 shared.session_record_retention = SessionRecordRetentionConfig {
22974 age_ms: 0,
22975 keep_per_workspace: 1,
22976 };
22977 shared.retire_unavailable_session_records().await;
22978
22979 assert_eq!(
22980 retention_remaining_record_ids(&shared),
22981 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22982 );
22983 assert_eq!(
22984 retention_removed_record_ids(&shared),
22985 owned(&["a1", "a2", "b1", "b2"]),
22986 );
22987 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22988
22989 drop(shared);
22990 drop(runtime);
22991 std::fs::remove_dir_all(root).unwrap();
22992 }
22993
22994 #[tokio::test]
23000 async fn session_record_retention_both_set_retires_only_the_intersection() {
23001 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23002 shared.session_record_retention = SessionRecordRetentionConfig {
23003 age_ms: 250_000,
23004 keep_per_workspace: 1,
23005 };
23006 shared.retire_unavailable_session_records().await;
23007
23008 assert_eq!(
23009 retention_remaining_record_ids(&shared),
23010 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23011 );
23012 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23013 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23014
23015 drop(shared);
23016 drop(runtime);
23017 std::fs::remove_dir_all(root).unwrap();
23018 }
23019
23020 #[tokio::test]
23021 async fn session_record_retention_both_disabled_is_a_no_op() {
23022 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23023 shared.session_record_retention = SessionRecordRetentionConfig::default();
23024 shared.retire_unavailable_session_records().await;
23025
23026 assert_eq!(
23027 retention_remaining_record_ids(&shared),
23028 owned(&[
23029 "a1", "a2", "a3", "b1", "b2", "b3",
23030 "decoy-live", "decoy-pending", "decoy-dormant",
23031 ]),
23032 );
23033 assert!(retention_removed_record_ids(&shared).is_empty());
23034 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23035
23036 drop(shared);
23037 drop(runtime);
23038 std::fs::remove_dir_all(root).unwrap();
23039 }
23040
23041 #[test]
23042 fn session_record_context_export_selects_one_exact_history_identity() {
23043 let candidates = vec![
23044 HistoryCandidateSummary {
23045 id: "candidate-other".to_owned(),
23046 session_id_hint: "provider-other".to_owned(),
23047 modified_at_unix_ms: Some(1),
23048 },
23049 HistoryCandidateSummary {
23050 id: "candidate-exact".to_owned(),
23051 session_id_hint: "provider-exact".to_owned(),
23052 modified_at_unix_ms: Some(2),
23053 },
23054 ];
23055 assert_eq!(
23056 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23057 "candidate-exact",
23058 );
23059 }
23060
23061 #[test]
23062 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23063 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23064 id: id.to_owned(),
23065 session_id_hint: hint.to_owned(),
23066 modified_at_unix_ms: None,
23067 };
23068 let missing = select_history_candidate(
23069 &[candidate("candidate-other", "provider-other")],
23070 Some("provider-exact"),
23071 )
23072 .unwrap_err();
23073 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23074 let ambiguous = select_history_candidate(
23075 &[
23076 candidate("candidate-a", "provider-exact"),
23077 candidate("candidate-b", "provider-exact"),
23078 ],
23079 Some("provider-exact"),
23080 )
23081 .unwrap_err();
23082 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23083 }
23084
23085 #[test]
23093 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23094 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23095 id: id.to_owned(),
23096 session_id_hint: hint.to_owned(),
23097 modified_at_unix_ms: None,
23098 };
23099 assert_eq!(
23100 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23101 .unwrap(),
23102 "candidate-sole",
23103 );
23104 let missing = select_history_candidate(&[], None).unwrap_err();
23105 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23106 let ambiguous = select_history_candidate(
23107 &[
23108 candidate("candidate-a", "sidecar-session-a"),
23109 candidate("candidate-b", "sidecar-session-b"),
23110 ],
23111 None,
23112 )
23113 .unwrap_err();
23114 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23115 }
23116
23117 #[test]
23118 fn session_record_context_export_revalidation_rejects_record_change() {
23119 let session = terminal_address(1);
23120 let identity = ProviderSessionIdentity {
23121 key: ProviderSessionKey::SessionId,
23122 id: "provider-exact".to_owned(),
23123 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23124 };
23125 let mut expected = record("claude", "record-context-export");
23126 expected.state = ManagedSessionState::Live;
23127 expected.provider_session = Some(identity.clone());
23128 expected.active_session = Some(session.clone());
23129 let binding = SessionBinding {
23130 workspace_id: session.workspace_id.clone(),
23131 generation: session.session.generation,
23132 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23133 pending_resume: None,
23134 record_id: Some(expected.record_id.clone()),
23135 managed_worktree_lease_id: None,
23136 environment_profile: None,
23137 bundle: None,
23138 context: None,
23139 materialization_id: None,
23140 };
23141 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23142 assert!(session_record_context_export_binding_is_exact(
23143 &expected,
23144 &expected,
23145 Some(&identity),
23146 &session,
23147 &binding,
23148 &expected.provider,
23149 ¤t_root,
23150 false,
23151 ));
23152 let mut changed = expected.clone();
23153 changed.updated_at_unix_ms += 1;
23154 assert!(!session_record_context_export_binding_is_exact(
23155 &expected,
23156 &changed,
23157 Some(&identity),
23158 &session,
23159 &binding,
23160 &expected.provider,
23161 ¤t_root,
23162 false,
23163 ));
23164 assert!(!session_record_context_export_binding_is_exact(
23169 &expected,
23170 &changed,
23171 Some(&identity),
23172 &session,
23173 &binding,
23174 &expected.provider,
23175 ¤t_root,
23176 true,
23177 ));
23178 let mut detached = expected.clone();
23179 detached.state = ManagedSessionState::Dormant;
23180 detached.active_session = None;
23181 detached.updated_at_unix_ms += 1;
23182 assert!(!session_record_context_export_binding_is_exact(
23183 &expected,
23184 &detached,
23185 Some(&identity),
23186 &session,
23187 &binding,
23188 &expected.provider,
23189 ¤t_root,
23190 false,
23191 ));
23192 assert!(session_record_context_export_binding_is_exact(
23193 &expected,
23194 &detached,
23195 Some(&identity),
23196 &session,
23197 &binding,
23198 &expected.provider,
23199 ¤t_root,
23200 true,
23201 ));
23202 }
23203
23204 #[test]
23211 fn session_record_context_export_binding_is_exact_without_identity() {
23212 let session = terminal_address(1);
23213 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23214 expected.state = ManagedSessionState::Live;
23215 expected.active_session = Some(session.clone());
23216 let binding = SessionBinding {
23217 workspace_id: session.workspace_id.clone(),
23218 generation: session.session.generation,
23219 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23220 pending_resume: None,
23221 record_id: Some(expected.record_id.clone()),
23222 managed_worktree_lease_id: None,
23223 environment_profile: None,
23224 bundle: None,
23225 context: None,
23226 materialization_id: None,
23227 };
23228 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23229 assert!(session_record_context_export_binding_is_exact(
23230 &expected,
23231 &expected,
23232 None,
23233 &session,
23234 &binding,
23235 &expected.provider,
23236 ¤t_root,
23237 false,
23238 ));
23239 let mut drifted = expected.clone();
23243 drifted.provider_session = Some(ProviderSessionIdentity {
23244 key: ProviderSessionKey::SessionId,
23245 id: "unexpected-identity".to_owned(),
23246 transcript_path: None,
23247 });
23248 assert!(!session_record_context_export_binding_is_exact(
23249 &expected,
23250 &drifted,
23251 None,
23252 &session,
23253 &binding,
23254 &expected.provider,
23255 ¤t_root,
23256 false,
23257 ));
23258 }
23259
23260 #[test]
23261 fn session_record_context_export_preflight_requires_live_usable_source() {
23262 let mut source = record("claude", "record-context-export-preflight");
23263 source.state = ManagedSessionState::Live;
23264 assert!(session_record_context_export_source_is_usable(
23265 &source,
23266 &SessionStatus::Running,
23267 false,
23268 ));
23269 assert!(session_record_context_export_source_is_usable(
23270 &source,
23271 &SessionStatus::Exited { exit_code: Some(0) },
23272 false,
23273 ));
23274
23275 source.state = ManagedSessionState::Dormant;
23276 assert!(!session_record_context_export_source_is_usable(
23277 &source,
23278 &SessionStatus::Running,
23279 false,
23280 ));
23281 assert!(session_record_context_export_source_is_usable(
23285 &source,
23286 &SessionStatus::Exited { exit_code: Some(0) },
23287 true,
23288 ));
23289 assert!(!session_record_context_export_source_is_usable(
23290 &source,
23291 &SessionStatus::Exited { exit_code: Some(0) },
23292 false,
23293 ));
23294 source.state = ManagedSessionState::Live;
23295 assert!(!session_record_context_export_source_is_usable(
23296 &source,
23297 &SessionStatus::Starting,
23298 false,
23299 ));
23300 }
23301
23302 #[test]
23303 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23304 let catalog = active_registry().unwrap();
23305 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23306 let workspace = WorkspaceConfig::new(
23307 WorkspaceId::new("primary").unwrap(),
23308 std::env::current_dir().unwrap(),
23309 )
23310 .unwrap();
23311 let shared = NodeShared::new(
23312 handle,
23313 "fixture-token".to_owned(),
23314 NodeId::new("node-context-export-conflict").unwrap(),
23315 vec![workspace],
23316 vec![agent("claude")],
23317 );
23318 let history = HistorySessionRecord {
23319 session_id: "provider-exact".to_owned(),
23320 title: None,
23321 cwd: None,
23322 model: None,
23323 message_count: 1,
23324 completed_turn_count: None,
23325 total_tokens: 0,
23326 messages: vec![gate4agent_types::HistoryMessageRecord {
23327 role: gate4agent_types::HistoryMessageRole::User,
23328 text: "bounded context".to_owned(),
23329 }],
23330 };
23331 let pack = NodeContextPack::export(
23332 ContextPackLineageReceipt {
23333 source_node_id: shared.node_id.clone(),
23334 source_session: terminal_address(1),
23335 source_provider: agent("claude"),
23336 },
23337 &history,
23338 )
23339 .unwrap();
23340 let context_id = pack.receipt().id.clone();
23341 let session = terminal_address(1);
23342 let identity = ProviderSessionIdentity {
23343 key: ProviderSessionKey::SessionId,
23344 id: history.session_id.clone(),
23345 transcript_path: None,
23346 };
23347 let mut expected = record("claude", "record-context-export-final-conflict");
23348 expected.state = ManagedSessionState::Live;
23349 expected.provider_session = Some(identity.clone());
23350 expected.active_session = Some(session.clone());
23351 let mut changed = expected.clone();
23352 changed.updated_at_unix_ms += 1;
23353 shared.insert_record(changed).unwrap();
23354
23355 let error = shared
23356 .commit_context_pack_for_session_record(
23357 &expected,
23358 Some(&identity),
23359 &session,
23360 "candidate-exact",
23361 &history,
23362 pack,
23363 false,
23364 )
23365 .unwrap_err();
23366
23367 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23368 assert!(shared
23369 .context_catalog
23370 .read()
23371 .unwrap_or_else(|poisoned| poisoned.into_inner())
23372 .get(&context_id)
23373 .is_none());
23374 }
23375
23376 #[tokio::test]
23377 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23378 let root = temporary_workspace_root("session-task-binding");
23379 std::fs::create_dir_all(&root).unwrap();
23380 let state_path = root.join("node-state.json");
23381 let catalog = active_registry().unwrap();
23382 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23383 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23384 let node_id = NodeId::new("node-1").unwrap();
23385 let mut shared = NodeShared::new(
23386 handle,
23387 "fixture-token".to_owned(),
23388 node_id.clone(),
23389 vec![workspace],
23390 vec![agent("claude")],
23391 );
23392 shared.state_path = Some(state_path.clone());
23393 let record = record("claude", "sr-task");
23394 let record_id = record.record_id.clone();
23395 shared.insert_record(record).unwrap();
23396 shared.persist_state().unwrap();
23397 shared
23398 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23399 .unwrap();
23400
23401 let minted = process_request_inner(
23402 &shared,
23403 77,
23404 ClientRole::Operator,
23405 NodeRequest::SetSessionTask {
23406 record_id: record_id.clone(),
23407 expected_revision: 0,
23408 target: SessionTaskTargetV1::New,
23409 },
23410 )
23411 .await
23412 .unwrap();
23413 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23414 panic!("session task mutation returned another response");
23415 };
23416 let minted_binding = minted.task_binding.clone().unwrap();
23417 let minted_task_id = minted_binding.task_id.clone().unwrap();
23418 assert_eq!(minted_binding.revision, 1);
23419 assert!(minted_task_id.as_str().starts_with("task-"));
23420 assert_eq!(minted_task_id.as_str().len(), 29);
23421
23422 let event_count = shared.current_sequence();
23423 let repeated = process_request_inner(
23424 &shared,
23425 77,
23426 ClientRole::Operator,
23427 NodeRequest::SetSessionTask {
23428 record_id: record_id.clone(),
23429 expected_revision: 1,
23430 target: SessionTaskTargetV1::Existing {
23431 task_id: minted_task_id.clone(),
23432 },
23433 },
23434 )
23435 .await
23436 .unwrap();
23437 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23438 assert_eq!(shared.current_sequence(), event_count);
23439
23440 let stale = process_request_inner(
23441 &shared,
23442 77,
23443 ClientRole::Operator,
23444 NodeRequest::SetSessionTask {
23445 record_id: record_id.clone(),
23446 expected_revision: 0,
23447 target: SessionTaskTargetV1::Existing {
23448 task_id: minted_task_id,
23449 },
23450 },
23451 )
23452 .await
23453 .unwrap_err();
23454 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23455
23456 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23457 let assigned = process_request_inner(
23458 &shared,
23459 77,
23460 ClientRole::Operator,
23461 NodeRequest::SetSessionTask {
23462 record_id: record_id.clone(),
23463 expected_revision: 1,
23464 target: SessionTaskTargetV1::Existing {
23465 task_id: assigned_id.clone(),
23466 },
23467 },
23468 )
23469 .await
23470 .unwrap();
23471 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23472 panic!("session task assignment returned another response");
23473 };
23474 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23475 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23476
23477 let cleared = process_request_inner(
23478 &shared,
23479 77,
23480 ClientRole::Operator,
23481 NodeRequest::SetSessionTask {
23482 record_id: record_id.clone(),
23483 expected_revision: 2,
23484 target: SessionTaskTargetV1::Clear,
23485 },
23486 )
23487 .await
23488 .unwrap();
23489 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23490 panic!("session task clear returned another response");
23491 };
23492 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23493 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23494 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23495 assert_eq!(
23496 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23497 cleared.updated_at_unix_ms,
23498 );
23499
23500 let persisted = std::fs::read(&state_path).unwrap();
23501 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23502 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23503 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23504 std::fs::remove_dir_all(root).unwrap();
23505 }
23506
23507 #[test]
23508 fn session_task_capability_strips_snapshots_events_and_replies() {
23509 let mut bound = record("claude", "sr-task-projection");
23510 bound.updated_at_unix_ms = 2;
23511 bound.task_binding = Some(SessionTaskBindingV1 {
23512 revision: 1,
23513 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23514 changed_at_unix_ms: 2,
23515 });
23516 let request = NodeRequest::SetSessionTask {
23517 record_id: bound.record_id.clone(),
23518 expected_revision: 1,
23519 target: SessionTaskTargetV1::Clear,
23520 };
23521 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23522 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23523 }));
23524 assert!(!request_uses_unnegotiated_capability(
23525 &request,
23526 &baseline_capabilities().unwrap(),
23527 ));
23528 assert!(request_uses_unnegotiated_capability(&request, &[]));
23529
23530 let mut snapshot = terminal_test_shared().snapshot();
23531 snapshot.session_records.push(bound.clone());
23532 let mut reply = ResponseEnvelope {
23533 request_id: 1,
23534 result: Ok(NodeResponse::Resync {
23535 event_sequence: 1,
23536 oldest_available_sequence: 1,
23537 snapshot,
23538 events: vec![NodeEventEnvelope {
23539 sequence: 1,
23540 event: NodeEvent::SessionRecordUpserted {
23541 record: bound.clone(),
23542 },
23543 }],
23544 }),
23545 };
23546 project_response_without_session_task_binding(&mut reply);
23547 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23548 panic!("task projection changed the response kind");
23549 };
23550 assert!(snapshot.session_records[0].task_binding.is_none());
23551 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23552 panic!("task projection changed the event kind");
23553 };
23554 assert!(record.task_binding.is_none());
23555
23556 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23557 sequence: 2,
23558 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23559 });
23560 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23561 panic!("task event projection changed the event kind");
23562 };
23563 assert!(record.task_binding.is_none());
23564
23565 let mut direct = ResponseEnvelope {
23566 request_id: 2,
23567 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23568 };
23569 project_response_without_session_task_binding(&mut direct);
23570 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23571 panic!("task reply projection changed the response kind");
23572 };
23573 assert!(record.task_binding.is_none());
23574 }
23575
23576 #[test]
23577 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23578 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23579 let workspace = WorkspaceConfig::new(
23580 WorkspaceId::new("primary").unwrap(),
23581 std::env::current_dir().unwrap(),
23582 )
23583 .unwrap();
23584 let config = NodeServerConfig::new(
23585 DEFAULT_NODE_ENDPOINT,
23586 "fixture-token",
23587 NodeId::new("node-1").unwrap(),
23588 [workspace],
23589 )
23590 .unwrap();
23591 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23592 }
23593
23594 #[cfg(all(feature = "fixture", windows))]
23595 #[test]
23596 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23597 let root = temporary_workspace_root("provider-bundle-argv-hold");
23598 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23599 std::fs::create_dir_all(&root).unwrap();
23600 std::fs::create_dir_all(&outside).unwrap();
23601 let proof_path = root.join("provider.proof");
23602 let release_signal = root.join("release.signal");
23603 let config = NodeServerConfig::new(
23604 format!(
23605 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23606 std::process::id(),
23607 ),
23608 "fixture-token",
23609 NodeId::new("provider-bundle-hold-node").unwrap(),
23610 [WorkspaceConfig::new(
23611 WorkspaceId::new("primary").unwrap(),
23612 std::env::current_dir().unwrap(),
23613 )
23614 .unwrap()],
23615 )
23616 .unwrap()
23617 .with_spawn_profiles(SpawnProfileRegistry::new([
23618 crate::protocol::SpawnProfileDefaults {
23619 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23620 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23621 .unwrap(),
23622 provider: agent("kimi"),
23623 mode: SessionMode::Pty,
23624 terminal_size: gate4agent_types::TerminalSize {
23625 rows: 24,
23626 columns: 80,
23627 },
23628 prompt: None,
23629 bundle_id: None,
23630 context_id: None,
23631 environment_profile_id: None,
23632 },
23633 ])
23634 .unwrap());
23635 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23636 config,
23637 agent("kimi"),
23638 proof_path.clone(),
23639 release_signal.clone(),
23640 )
23641 .unwrap();
23642 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23643
23644 let spec = NodeServer::provider_bundle_argv_fixture_spec(
23645 agent("kimi"),
23646 proof_path.clone(),
23647 Some(release_signal.clone()),
23648 )
23649 .unwrap();
23650 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23651 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23652 let release_wait = wrapper
23653 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23654 .unwrap();
23655 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23656 assert!(proof_write < release_wait);
23657 assert!(release_wait < fixture_ready);
23658 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23659 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23660 assert_eq!(
23661 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23662 &[
23663 proof_path.to_string_lossy().into_owned(),
23664 release_signal.to_string_lossy().into_owned(),
23665 ],
23666 );
23667
23668 let error = NodeServer::provider_bundle_argv_fixture_spec(
23669 agent("kimi"),
23670 proof_path,
23671 Some(outside.join("release.signal")),
23672 )
23673 .unwrap_err();
23674 assert!(matches!(
23675 error,
23676 NodeServerError::Registry(message)
23677 if message == "provider bundle argv release path must share the proof parent"
23678 ));
23679 drop(server);
23680 std::fs::remove_dir_all(root).unwrap();
23681 std::fs::remove_dir_all(outside).unwrap();
23682 }
23683
23684 #[cfg(feature = "fixture")]
23685 #[test]
23686 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23687 let root = temporary_workspace_root("context-fixture-registry");
23688 std::fs::create_dir_all(&root).unwrap();
23689 let proof_path = root.join("context.proof");
23690 let config = NodeServerConfig::new(
23691 format!(
23692 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23693 std::process::id(),
23694 ),
23695 "fixture-token",
23696 NodeId::new("context-fixture-node").unwrap(),
23697 [WorkspaceConfig::new(
23698 WorkspaceId::new("primary").unwrap(),
23699 std::env::current_dir().unwrap(),
23700 )
23701 .unwrap()],
23702 )
23703 .unwrap();
23704 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23705 let mut enabled = server
23706 .shared
23707 .enabled_providers
23708 .iter()
23709 .map(AgentId::as_str)
23710 .collect::<Vec<_>>();
23711 enabled.sort_unstable();
23712 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23713 for provider in &server.shared.enabled_providers {
23714 assert!(server
23715 .shared
23716 .provider_adapter_contracts
23717 .iter()
23718 .any(|contract| {
23719 &contract.provider == provider
23720 && contract.family == AdapterFamily::History
23721 }));
23722 }
23723 drop(server);
23724 std::fs::remove_dir_all(root).unwrap();
23725 }
23726
23727 #[cfg(feature = "fixture")]
23728 #[test]
23729 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23730 let root = temporary_workspace_root("monitoring-context-fixture");
23731 std::fs::create_dir_all(&root).unwrap();
23732 let proof_path = root.join("context.proof");
23733 let standard = NodeServer::context_pack_fixture_catalog(
23734 proof_path.clone(),
23735 None,
23736 false,
23737 ).unwrap();
23738 let monitoring = NodeServer::context_pack_fixture_catalog(
23739 proof_path.clone(),
23740 None,
23741 true,
23742 ).unwrap();
23743 let claude = AgentId::new("claude").unwrap();
23744 let standard_claude = standard.get(&claude).unwrap();
23745 let monitoring_claude = monitoring.get(&claude).unwrap();
23746 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23750 assert_eq!(
23751 monitoring_claude.capabilities.adapters.history,
23752 standard_claude.capabilities.adapters.history,
23753 );
23754 assert_ne!(monitoring_claude.launch, standard_claude.launch);
23755 let codex = AgentId::new("codex").unwrap();
23756 let standard_codex = standard.get(&codex).unwrap();
23757 let monitoring_codex = monitoring.get(&codex).unwrap();
23758 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23759 assert_eq!(
23760 monitoring_codex.capabilities.adapters.history,
23761 standard_codex.capabilities.adapters.history,
23762 );
23763 assert_ne!(monitoring_codex.launch, standard_codex.launch);
23764 let codex_validation = monitoring_codex.launch.fixed_args.iter()
23765 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23766 .unwrap();
23767 for required in [
23768 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23769 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23770 "@('role', 'text')",
23771 "g4a-private-provider-session-canary",
23772 "private-provider-login-identity",
23773 "$rawProof",
23774 "$env:GATE4AGENT_HOOK_URL",
23775 "g4a-private-provider-session-canary",
23776 ] {
23777 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23778 }
23779 for provider in ["grok", "kimi"] {
23780 let provider = AgentId::new(provider).unwrap();
23781 assert_eq!(monitoring.get(&provider), standard.get(&provider));
23782 }
23783
23784 let config = NodeServerConfig::new(
23785 format!(
23786 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23787 std::process::id(),
23788 ),
23789 "fixture-token",
23790 NodeId::new("monitoring-context-fixture-node").unwrap(),
23791 [WorkspaceConfig::new(
23792 WorkspaceId::new("primary").unwrap(),
23793 std::env::current_dir().unwrap(),
23794 )
23795 .unwrap()],
23796 )
23797 .unwrap();
23798 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23799 assert!(server.shared.fixture_semantic_hook_policy);
23800 drop(server);
23801 std::fs::remove_dir_all(root).unwrap();
23802 }
23803
23804 #[cfg(feature = "fixture")]
23805 #[test]
23806 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23807 let root = temporary_workspace_root("kimi-context-only-fixture");
23808 std::fs::create_dir_all(&root).unwrap();
23809 let proof_path = root.join("context.proof");
23810 let standard = NodeServer::context_pack_fixture_catalog(
23811 proof_path.clone(),
23812 None,
23813 false,
23814 ).unwrap();
23815 let kimi = AgentId::new("kimi").unwrap();
23816 let context_only = NodeServer::context_pack_fixture_catalog(
23817 proof_path,
23818 Some(&kimi),
23819 false,
23820 )
23821 .unwrap();
23822
23823 let codex = AgentId::new("codex").unwrap();
23824 assert_eq!(
23825 standard.get(&codex).unwrap().launch.fixed_args,
23826 context_only.get(&codex).unwrap().launch.fixed_args,
23827 );
23828 let script = context_only
23829 .get(&kimi)
23830 .unwrap()
23831 .launch
23832 .fixed_args
23833 .iter()
23834 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23835 .expect("Kimi context-only validation script is missing");
23836 for required in [
23837 "$bundleArgs.Count -ne 0",
23838 "GATE4AGENT_CONTEXT_ROOT",
23839 "context-pack.json",
23840 "$entries.Count -ne 1",
23841 "g4a-context-pack-v1",
23842 "$document.PSObject.Properties['cwd']",
23843 "'claude', 'codex', 'grok', 'kimi'",
23844 "$hasUser",
23845 "$hasAssistant",
23846 "SHA256",
23847 "context-only",
23848 ] {
23849 assert!(script.contains(required), "missing validation: {required}");
23850 }
23851 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23852 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23853 }
23854 assert!(!standard
23855 .get(&kimi)
23856 .unwrap()
23857 .launch
23858 .fixed_args
23859 .iter()
23860 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23861
23862 std::fs::remove_dir_all(root).unwrap();
23863 }
23864
23865 #[cfg(all(feature = "fixture", windows))]
23866 #[tokio::test]
23867 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23868 let root = temporary_workspace_root("kimi-context-only-spawn");
23869 let workspace_root = root.join("workspace");
23870 std::fs::create_dir_all(&workspace_root).unwrap();
23871 let materialization_root = root.join("private-materializations");
23872 let proof_path = root.join("context.proof");
23873 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23874 let workspace_id = WorkspaceId::new("primary").unwrap();
23875 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23876 let endpoint = format!(
23877 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23878 std::process::id(),
23879 unix_time_ms(),
23880 );
23881 let spawn_profiles = SpawnProfileRegistry::new([
23882 crate::protocol::SpawnProfileDefaults {
23883 profile_id: profile_id.clone(),
23884 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23885 provider: agent("kimi"),
23886 mode: SessionMode::Pty,
23887 terminal_size: gate4agent_types::TerminalSize {
23888 rows: 30,
23889 columns: 120,
23890 },
23891 prompt: None,
23892 bundle_id: None,
23893 context_id: None,
23894 environment_profile_id: None,
23895 },
23896 ])
23897 .unwrap();
23898 let mut config = NodeServerConfig::new(
23899 &endpoint,
23900 "fixture-token",
23901 node_id.clone(),
23902 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23903 )
23904 .unwrap()
23905 .with_spawn_profiles(spawn_profiles)
23906 .with_session_environment_materialization(
23907 materialization_root,
23908 Arc::new(FixtureSecretResolver {
23909 deny: Arc::new(AtomicBool::new(false)),
23910 }),
23911 )
23912 .unwrap();
23913 config.fixture_raw_pty_runtime = true;
23914 let server = NodeServer::new_context_only_proof_fixture(
23915 config,
23916 agent("kimi"),
23917 proof_path.clone(),
23918 )
23919 .unwrap();
23920 let shared = Arc::clone(&server.shared);
23921 let shutdown = server.shutdown_handle();
23922
23923 let history = HistorySessionRecord {
23924 session_id: "context-only-source-history".to_owned(),
23925 title: None,
23926 cwd: None,
23927 model: None,
23928 message_count: 2,
23929 completed_turn_count: None,
23930 total_tokens: 0,
23931 messages: vec![
23932 gate4agent_types::HistoryMessageRecord {
23933 role: gate4agent_types::HistoryMessageRole::User,
23934 text: "continue the bounded parent run through native C2".to_owned(),
23935 },
23936 gate4agent_types::HistoryMessageRecord {
23937 role: gate4agent_types::HistoryMessageRole::Assistant,
23938 text: "the exact parent context is ready for the child run".to_owned(),
23939 },
23940 ],
23941 };
23942 let pack = NodeContextPack::export(
23943 ContextPackLineageReceipt {
23944 source_node_id: node_id.clone(),
23945 source_session: SessionAddress {
23946 workspace_id: workspace_id.clone(),
23947 session: SessionKey {
23948 instance_id: AgentInstanceId(71),
23949 generation: SessionGeneration(1),
23950 },
23951 },
23952 source_provider: agent("codex"),
23953 },
23954 &history,
23955 )
23956 .unwrap();
23957 let expected_context_hash = digest(&SHA256, pack.bytes())
23958 .as_ref()
23959 .iter()
23960 .map(|byte| format!("{byte:02x}"))
23961 .collect::<String>();
23962 let context = shared
23963 .context_catalog
23964 .write()
23965 .unwrap_or_else(|poisoned| poisoned.into_inner())
23966 .insert(pack)
23967 .unwrap();
23968 let spec = SpawnSpec {
23969 target: crate::protocol::SpawnTarget {
23970 node_id,
23971 workspace_id: workspace_id.clone(),
23972 worktree_id: None,
23973 },
23974 profile_id,
23975 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23976 "target-kimi-r1",
23977 ).unwrap(),
23978 overrides: crate::protocol::SpawnOverrides {
23979 context_id: crate::protocol::SpawnOverride::Set {
23980 value: context.id.clone(),
23981 },
23982 ..crate::protocol::SpawnOverrides::default()
23983 },
23984 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23985 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23986 required_capabilities: SpawnRequiredCapabilities::default(),
23987 };
23988 let resolved = match shared.resolve_spawn_spec(&spec) {
23989 Ok(resolved) => resolved,
23990 Err(error) => panic!(
23991 "Kimi context-only resolve rejected: exact_code={:?} message={}",
23992 error.code,
23993 error.message,
23994 ),
23995 };
23996 let resolved_context = match shared.resolve_context(&resolved) {
23997 Ok(Some(resolved_context)) => resolved_context,
23998 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23999 Err(error) => panic!(
24000 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24001 error.code,
24002 error.message,
24003 ),
24004 };
24005 assert_eq!(resolved_context, context);
24006 let runtime_policy = match shared
24007 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24008 .await
24009 {
24010 Ok(runtime_policy) => runtime_policy,
24011 Err(error) => panic!(
24012 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24013 error.code,
24014 error.message,
24015 ),
24016 };
24017 assert!(runtime_policy.raw_pty_lifecycle);
24018
24019 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24020 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24021 agent("kimi"),
24022 TransportKind::Pty,
24023 vec![EnvMutation {
24024 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24025 value: Some(OsString::from("must-remain-rejected")),
24026 }],
24027 )
24028 .unwrap();
24029 assert_eq!(
24030 launch_control
24031 .install_native_launch_environment_overlay(
24032 AgentInstanceId(72),
24033 generic_overlay,
24034 )
24035 .unwrap_err(),
24036 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24037 );
24038
24039 let diagnostic_address = SessionAddress {
24040 workspace_id: workspace_id.clone(),
24041 session: SessionKey {
24042 instance_id: AgentInstanceId(73),
24043 generation: SessionGeneration(1),
24044 },
24045 };
24046 let (diagnostic_overlay, diagnostic_materialization) = match shared
24047 .prepare_session_materialization(
24048 &diagnostic_address,
24049 &agent("kimi"),
24050 SessionMode::Pty,
24051 None,
24052 None,
24053 Some(&resolved_context),
24054 None,
24055 )
24056 {
24057 Ok(Some(prepared)) => prepared,
24058 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24059 Err(error) => panic!(
24060 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24061 error.code,
24062 error.message,
24063 ),
24064 };
24065 let diagnostic_overlay = diagnostic_overlay
24066 .expect("Kimi context-only materialization omitted its environment overlay");
24067 assert!(matches!(
24068 &diagnostic_overlay,
24069 PreparedNativeLaunchOverlay::Environment(_),
24070 ));
24071 match shared.install_prepared_launch_overlay(
24072 diagnostic_address.session.instance_id,
24073 diagnostic_overlay,
24074 ) {
24075 Ok(None) => {}
24076 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24077 Err(error) => panic!(
24078 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24079 error.code,
24080 error.message,
24081 ),
24082 }
24083 assert!(launch_control.clear_native_instance_launch_overlay(
24084 diagnostic_address.session.instance_id,
24085 ));
24086 drop(diagnostic_materialization);
24087 assert!(shared
24088 .materializations
24089 .lock()
24090 .unwrap_or_else(|poisoned| poisoned.into_inner())
24091 .is_empty());
24092
24093 let node_task = tokio::spawn(server.run());
24094 let ready_client = timeout(Duration::from_secs(5), async {
24095 loop {
24096 match gate4agent_node_wire::LocalNodeClient::connect(
24097 &endpoint,
24098 &shared.node_id,
24099 ClientRole::Observer,
24100 "fixture-token",
24101 )
24102 .await
24103 {
24104 Ok(client) => break client,
24105 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24106 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24107 }
24108 }
24109 })
24110 .await
24111 .expect("Kimi context-only Node did not become ready");
24112 assert_eq!(
24113 ready_client
24114 .hello()
24115 .snapshot
24116 .provider_runtime_statuses
24117 .iter()
24118 .find(|status| status.provider().as_str() == "kimi")
24119 .expect("Kimi runtime status is missing")
24120 .mode(),
24121 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24122 );
24123 drop(ready_client);
24124
24125 let receipt = match shared.spawn_from_spec(spec).await {
24126 Ok(receipt) => receipt,
24127 Err(error) => panic!(
24128 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24129 error.code,
24130 error.message,
24131 ),
24132 };
24133 assert_eq!(receipt.provider, agent("kimi"));
24134 assert_eq!(receipt.context.as_ref(), Some(&context));
24135 assert!(receipt.context_binding_is_valid());
24136 timeout(Duration::from_secs(5), async {
24137 while !proof_path.is_file() {
24138 sleep(Duration::from_millis(10)).await;
24139 }
24140 })
24141 .await
24142 .expect("Kimi context-only fixture did not write its proof");
24143
24144 let proof = std::fs::read_to_string(&proof_path).unwrap();
24145 let lines = proof.lines().collect::<Vec<_>>();
24146 assert_eq!(lines.len(), 7);
24147 assert_eq!(lines[0], "context-only");
24148 assert!(Path::new(lines[1]).is_absolute());
24149 assert_ne!(
24150 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24151 std::fs::canonicalize(&workspace_root).unwrap(),
24152 );
24153 assert_eq!(
24154 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24155 std::fs::canonicalize(&workspace_root).unwrap(),
24156 );
24157 assert_eq!(lines[3], expected_context_hash);
24158 assert_eq!(lines[4], "g4a-context-pack-v1");
24159 assert_eq!(lines[5], "codex");
24160 assert_eq!(lines[6], "2");
24161 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24162 session.instance_id == receipt.session.session.instance_id
24163 && session.agent_id.as_str() == "kimi"
24164 }));
24165
24166 shutdown.request_shutdown().await.unwrap();
24167 timeout(Duration::from_secs(5), node_task)
24168 .await
24169 .unwrap()
24170 .unwrap()
24171 .unwrap();
24172 drop(shutdown);
24173 drop(shared);
24174 std::fs::remove_dir_all(root).unwrap();
24175 }
24176
24177 #[cfg(feature = "fixture")]
24178 #[test]
24179 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24180 let root = temporary_workspace_root("clean-exit-constructor");
24181 std::fs::create_dir_all(&root).unwrap();
24182 let config = construction_test_config("clean-exit-constructor")
24183 .with_state_path(root.join("node-state.json"))
24184 .unwrap();
24185 let server = NodeServer::new_clean_exit_fixture(
24186 config,
24187 root.clone(),
24188 root.join("started.marker"),
24189 root.join("release.signal"),
24190 )
24191 .unwrap();
24192 assert_eq!(
24193 server
24194 .shared
24195 .enabled_providers
24196 .iter()
24197 .map(AgentId::as_str)
24198 .collect::<Vec<_>>(),
24199 ["claude"],
24200 );
24201 drop(server);
24202 std::fs::remove_dir_all(root).unwrap();
24203 }
24204
24205 #[test]
24206 fn production_registry_provider_contract_manifest_is_exact() {
24207 let registry = active_registry().unwrap();
24208 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24209 assert_eq!(
24210 providers
24211 .iter()
24212 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24213 .collect::<Vec<_>>(),
24214 vec![
24215 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24216 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24217 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24218 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24219 ]
24220 );
24221 assert_eq!(
24222 adapters
24223 .iter()
24224 .map(|contract| (
24225 contract.provider.as_str(),
24226 contract.family,
24227 contract.adapter_id.as_str(),
24228 contract.revision.as_str(),
24229 ))
24230 .collect::<Vec<_>>(),
24231 vec![
24232 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24233 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24234 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24235 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24236 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24237 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24238 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24239 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24240 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24241 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24242 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24243 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24244 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24245 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24246 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24247 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24248 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24249 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24250 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24251 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24252 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24253 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24254 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24255 ]
24256 );
24257 }
24258
24259 fn construction_test_config(name: &str) -> NodeServerConfig {
24260 let workspace = WorkspaceConfig::new(
24261 WorkspaceId::new("primary").unwrap(),
24262 std::env::current_dir().unwrap(),
24263 )
24264 .unwrap();
24265 NodeServerConfig::new(
24266 format!(r"\\.\pipe\gate4agent-{name}"),
24267 "fixture-token",
24268 NodeId::new(name).unwrap(),
24269 [workspace],
24270 )
24271 .unwrap()
24272 }
24273
24274 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24275 let adapter = AdapterBinding::new(
24276 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24277 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24278 gate4agent_types::AdapterVerification::SyntheticFixture,
24279 )
24280 .unwrap();
24281 let agent_ids = ["claude", "codex", "kimi"]
24282 .into_iter()
24283 .map(|agent_id| AgentId::new(agent_id).unwrap())
24284 .collect::<Vec<_>>();
24285 let adapters = gate4agent_catalog::AdapterRegistry::new(
24286 [
24287 AdapterFamily::PtySemantic,
24288 AdapterFamily::Pipe,
24289 AdapterFamily::OneShot,
24290 AdapterFamily::Acp,
24291 AdapterFamily::Hook,
24292 AdapterFamily::ManagedHook,
24293 AdapterFamily::History,
24294 AdapterFamily::Resume,
24295 AdapterFamily::SessionOptions,
24296 AdapterFamily::CapabilityProbe,
24297 ]
24298 .into_iter()
24299 .map(|family| gate4agent_catalog::AdapterDescriptor {
24300 family,
24301 binding: adapter.clone(),
24302 agents: agent_ids.clone(),
24303 }),
24304 )
24305 .unwrap();
24306 let specs = agent_ids
24307 .iter()
24308 .map(|agent_id| {
24309 let agent_id = agent_id.as_str();
24310 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24311 spec.revision =
24312 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24313 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24314 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24315 adapter: adapter.clone(),
24316 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24317 launch_override: None,
24318 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24319 });
24320 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24321 adapter: adapter.clone(),
24322 launch_override: None,
24323 });
24324 spec.capabilities.adapters.hook = Some(adapter.clone());
24325 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24326 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24327 spec.capabilities.adapters.history = Some(adapter.clone());
24328 spec.capabilities.adapters.resume = Some(adapter.clone());
24329 spec.capabilities.adapters.session_options = Some(adapter.clone());
24330 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24331 spec
24332 })
24333 .collect::<Vec<_>>();
24334 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24335 }
24336
24337 #[test]
24345 fn node_server_construction_accepts_the_production_manifest() {
24346 let registry = active_registry().unwrap();
24347 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24348 assert_eq!(providers.len(), registry.iter().count());
24349 assert!(!providers.is_empty());
24350 let server = NodeServer::new_with_registry(
24351 construction_test_config("manifest-production-bound"),
24352 registry,
24353 )
24354 .unwrap();
24355 drop(server);
24356 }
24357
24358 #[test]
24359 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24360 let registry = maximum_size_provider_manifest_registry();
24361 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24362 assert_eq!(providers.len(), 3);
24363 assert_eq!(adapters.len(), 30);
24364 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24365 let error = match NodeServer::new_with_registry(
24366 construction_test_config("manifest-handshake-overflow"),
24367 registry,
24368 ) {
24369 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24370 Err(error) => error,
24371 };
24372 assert!(matches!(
24373 error,
24374 NodeServerError::ProviderContractManifest(message)
24375 if message.contains("authentication binding length")
24376 && message.contains("8192-byte limit")
24377 ));
24378 }
24379
24380 #[test]
24381 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24382 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24383 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24384 let registry = AgentRegistry::new([spec]).unwrap();
24385 assert!(matches!(
24386 provider_contract_manifest(®istry),
24387 Err(NodeServerError::ProviderContractManifest(message))
24388 if message.contains("exceeds the 128-byte limit")
24389 ));
24390 }
24391
24392 #[test]
24393 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24394 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24395 spec.revision = "invalid revision".to_owned();
24396 let registry = AgentRegistry::new([spec]).unwrap();
24397 assert!(matches!(
24398 provider_contract_manifest(®istry),
24399 Err(NodeServerError::ProviderContractManifest(message))
24400 if message.contains("bounded lowercase ASCII")
24401 ));
24402 }
24403
24404 #[test]
24405 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24406 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24407 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24408 let conflicting_binding = builtin_registry()
24409 .get_by_id("codex")
24410 .unwrap()
24411 .capabilities
24412 .adapters
24413 .one_shot
24414 .clone()
24415 .unwrap();
24416 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24417 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24418 pipe.adapter = transport_binding;
24419 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24420 let registry = AgentRegistry::new([spec]).unwrap();
24421 assert!(matches!(
24422 provider_contract_manifest(®istry),
24423 Err(NodeServerError::ProviderContractManifest(message))
24424 if message.contains("conflicting OneShot adapter bindings")
24425 ));
24426 }
24427
24428 #[test]
24429 fn public_node_failures_expose_only_fixed_categories() {
24430 let provider = failure(
24431 NodeFailureCode::BackendOperationFailed,
24432 r"provider bearer-secret failed at C:\private\session.jsonl",
24433 );
24434 assert_eq!(provider.message, "backend-operation-failed");
24435 assert!(!provider.message.contains("bearer-secret"));
24436 assert!(!provider.message.contains(r"C:\private"));
24437 let persistence = persistence_failure(io::Error::new(
24438 io::ErrorKind::Other,
24439 r"state write failed at C:\private\state-v1.json",
24440 ));
24441 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24442 assert!(!persistence.message.contains(r"C:\private"));
24443
24444 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24445 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24446 assert_eq!(
24447 generic.to_string(),
24448 "node durable state failed: durable-state-load-failed",
24449 );
24450 }
24451 }
24452
24453 #[test]
24454 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24455 let sequence = b"\x1b[1;5D".to_vec();
24456 assert_eq!(
24457 terminal_bytes_action(sequence.clone()).unwrap(),
24458 InputAction::TerminalBytes(sequence),
24459 );
24460 assert_eq!(
24461 terminal_bytes_action(Vec::new()).unwrap_err().code,
24462 NodeFailureCode::InvalidRequest,
24463 );
24464 assert_eq!(
24465 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24466 .unwrap_err()
24467 .code,
24468 NodeFailureCode::InvalidRequest,
24469 );
24470 }
24471
24472 #[test]
24473 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24474 let root = temporary_workspace_root("exclusive-state-owner");
24475 std::fs::create_dir_all(&root).unwrap();
24476 let workspace = WorkspaceConfig::new(
24477 WorkspaceId::new("primary").unwrap(),
24478 &root,
24479 )
24480 .unwrap();
24481 let state_path = root.join("state-v1.json");
24482 let endpoint = format!(
24483 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24484 std::process::id(),
24485 std::time::SystemTime::now()
24486 .duration_since(std::time::UNIX_EPOCH)
24487 .unwrap()
24488 .as_nanos(),
24489 );
24490 let config = NodeServerConfig::new(
24491 endpoint,
24492 "fixture-token",
24493 NodeId::new("node-1").unwrap(),
24494 [workspace],
24495 )
24496 .unwrap()
24497 .with_state_path(&state_path)
24498 .unwrap();
24499
24500 let first = NodeServer::new(config.clone()).unwrap();
24501 let error = match NodeServer::new(config.clone()) {
24502 Ok(_) => panic!("a second node server acquired the same durable state path"),
24503 Err(error) => error,
24504 };
24505 assert!(matches!(
24506 &error,
24507 NodeServerError::DurableState(error)
24508 if error.kind() == io::ErrorKind::WouldBlock
24509 ));
24510 assert_eq!(
24511 error.to_string(),
24512 "node durable state failed: durable-state-lock-failed",
24513 );
24514 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24515 drop(first);
24516 let second = NodeServer::new(config).unwrap();
24517 drop(second);
24518 std::fs::remove_dir_all(root).unwrap();
24519 }
24520
24521 #[test]
24522 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24523 let catalog = active_registry().unwrap();
24524 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24525 let workspace_id = WorkspaceId::new("primary").unwrap();
24526 let workspace = WorkspaceConfig::new(
24527 workspace_id.clone(),
24528 std::env::current_dir().unwrap(),
24529 )
24530 .unwrap();
24531 let shared = NodeShared::new(
24532 handle,
24533 "fixture-token".to_owned(),
24534 NodeId::new("node-1").unwrap(),
24535 vec![workspace.clone()],
24536 vec![agent("claude"), agent("codex"), agent("kimi")],
24537 );
24538 let instance_id = AgentInstanceId(17);
24539 let generation = SessionGeneration(3);
24540 assert!(shared.address_for(instance_id, generation).is_none());
24541 shared.bind_session(instance_id, workspace_id.clone(), generation);
24542 let address = SessionAddress {
24543 workspace_id,
24544 session: SessionKey { instance_id, generation },
24545 };
24546 assert_eq!(
24547 shared.workspace_root(&address.workspace_id).unwrap(),
24548 workspace.canonical_root,
24549 );
24550 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24551 shared.remove_binding(&address);
24552 assert!(shared.address_for(instance_id, generation).is_none());
24553 }
24554
24555 #[test]
24556 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24557 let address = SessionAddress {
24558 workspace_id: WorkspaceId::new("primary").unwrap(),
24559 session: SessionKey {
24560 instance_id: AgentInstanceId(7),
24561 generation: SessionGeneration(2),
24562 },
24563 };
24564 let record_id = SessionRecordId::new("record-a").unwrap();
24565 let size = gate4agent_types::TerminalSize {
24566 rows: 24,
24567 columns: 80,
24568 };
24569 let address_requests = [
24570 NodeRequest::Resume {
24571 session: address.clone(),
24572 terminal_size: size,
24573 initial_prompt: None,
24574 },
24575 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24576 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24577 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24578 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24579 NodeRequest::TerminalControl {
24580 session: address.clone(),
24581 control: TerminalControl::Enter,
24582 },
24583 NodeRequest::Resize { session: address.clone(), size },
24584 NodeRequest::Interrupt { session: address.clone() },
24585 NodeRequest::Stop { session: address.clone(), force: false },
24586 NodeRequest::Remove { session: address },
24587 ];
24588 for request in &address_requests {
24589 assert!(request_requires_open_provider_ids_with(
24590 request,
24591 |_| Some(agent("third-party-agent")),
24592 |_| Some(agent("claude")),
24593 ));
24594 assert!(!request_requires_open_provider_ids_with(
24595 request,
24596 |_| Some(agent("claude")),
24597 |_| Some(agent("claude")),
24598 ));
24599 assert!(request_requires_open_provider_ids_with(
24600 request,
24601 |_| None,
24602 |_| Some(agent("claude")),
24603 ));
24604 }
24605
24606 let record_requests = [
24607 NodeRequest::RenameSessionRecord {
24608 record_id: record_id.clone(),
24609 display_name: "renamed".to_owned(),
24610 },
24611 NodeRequest::ResumeSessionRecord {
24612 record_id: record_id.clone(),
24613 terminal_size: size,
24614 initial_prompt: None,
24615 },
24616 NodeRequest::ForgetSessionRecord { record_id },
24617 ];
24618 for request in &record_requests {
24619 assert!(request_requires_open_provider_ids_with(
24620 request,
24621 |_| Some(agent("claude")),
24622 |_| Some(agent("grok")),
24623 ));
24624 assert!(!request_requires_open_provider_ids_with(
24625 request,
24626 |_| Some(agent("claude")),
24627 |_| Some(agent("kimi")),
24628 ));
24629 assert!(request_requires_open_provider_ids_with(
24630 request,
24631 |_| Some(agent("claude")),
24632 |_| None,
24633 ));
24634 }
24635
24636 assert!(request_requires_open_provider_ids_with(
24637 &NodeRequest::Spawn {
24638 workspace_id: WorkspaceId::new("primary").unwrap(),
24639 provider: agent("third-party-agent"),
24640 mode: SessionMode::Pty,
24641 terminal_size: size,
24642 initial_prompt: None,
24643 },
24644 |_| None,
24645 |_| None,
24646 ));
24647 assert!(!request_requires_open_provider_ids_with(
24648 &NodeRequest::Snapshot,
24649 |_| None,
24650 |_| None,
24651 ));
24652 assert!(request_requires_open_provider_ids_with(
24653 &NodeRequest::ForgetContextPack {
24654 context_id: SpawnContextId::new("ctx-forget").unwrap(),
24655 },
24656 |_| None,
24657 |_| None,
24658 ));
24659 }
24660
24661 #[test]
24662 fn legacy_projection_filters_open_record_replies_and_event_history() {
24663 let catalog = active_registry().unwrap();
24664 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24665 let workspace = WorkspaceConfig::new(
24666 WorkspaceId::new("primary").unwrap(),
24667 std::env::current_dir().unwrap(),
24668 )
24669 .unwrap();
24670 let shared = NodeShared::new(
24671 handle,
24672 "fixture-token".to_owned(),
24673 NodeId::new("node-1").unwrap(),
24674 vec![workspace],
24675 vec![agent("claude"), agent("grok")],
24676 );
24677 let legacy = record("claude", "legacy-record");
24678 let open = record("grok", "open-record");
24679 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24680 record: legacy.clone(),
24681 });
24682 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24683 record: open.clone(),
24684 });
24685 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24686 record_id: legacy.record_id.clone(),
24687 });
24688 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24689 record_id: open.record_id.clone(),
24690 });
24691 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24692 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24693 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24694 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24695
24696 let mut record_reply = ResponseEnvelope {
24697 request_id: 1,
24698 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24699 };
24700 project_response_legacy_provider_ids(&shared, &mut record_reply);
24701 assert!(matches!(
24702 record_reply.result,
24703 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24704 ));
24705
24706 let mut resync = ResponseEnvelope {
24707 request_id: 2,
24708 result: Ok(NodeResponse::Resync {
24709 event_sequence: open_removed.sequence,
24710 oldest_available_sequence: 1,
24711 snapshot: shared.snapshot(),
24712 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24713 }),
24714 };
24715 project_response_legacy_provider_ids(&shared, &mut resync);
24716 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24717 panic!("legacy projection changed the resync response kind");
24718 };
24719 assert!(snapshot.session_records.iter().all(|item| {
24720 provider_id_is_legacy(&item.provider)
24721 }));
24722 assert_eq!(events.len(), 2);
24723 assert!(events.iter().all(|event| matches!(
24724 &event.event,
24725 NodeEvent::SessionRecordUpserted { record }
24726 if provider_id_is_legacy(&record.provider)
24727 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24728 }
24729
24730 #[test]
24731 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24732 let catalog = active_registry().unwrap();
24733 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24734 let workspace_id = WorkspaceId::new("primary").unwrap();
24735 let workspace = WorkspaceConfig::new(
24736 workspace_id.clone(),
24737 std::env::current_dir().unwrap(),
24738 )
24739 .unwrap();
24740 let shared = NodeShared::new(
24741 handle,
24742 "fixture-token".to_owned(),
24743 NodeId::new("node-1").unwrap(),
24744 vec![workspace],
24745 vec![agent("claude")],
24746 );
24747 for index in 0..CONTROL_SESSIONS_MAX {
24748 shared.bind_session(
24749 AgentInstanceId((index + 1) as u64),
24750 workspace_id.clone(),
24751 SessionGeneration(1),
24752 );
24753 }
24754 let error = shared.ensure_binding_capacity().unwrap_err();
24755 assert_eq!(error.code, NodeFailureCode::BackendBusy);
24756 }
24757
24758 #[tokio::test]
24759 async fn provider_spawn_admission_rejects_before_session_mutation() {
24760 let catalog = active_registry().unwrap();
24761 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24762 let workspace = WorkspaceConfig::new(
24763 WorkspaceId::new("primary").unwrap(),
24764 std::env::current_dir().unwrap(),
24765 )
24766 .unwrap();
24767 let mut shared = NodeShared::new(
24768 handle,
24769 "fixture-token".to_owned(),
24770 NodeId::new("node-provider-admission").unwrap(),
24771 vec![workspace],
24772 vec![agent("claude")],
24773 );
24774 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24775 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24776 ])
24777 .unwrap();
24778 let terminal_size = gate4agent_types::TerminalSize {
24779 rows: 24,
24780 columns: 80,
24781 };
24782
24783 let unavailable = shared
24784 .spawn_session(
24785 WorkspaceId::new("primary").unwrap(),
24786 agent("claude"),
24787 SessionMode::Pty,
24788 terminal_size,
24789 None,
24790 )
24791 .await
24792 .unwrap_err();
24793 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24794 assert_eq!(unavailable.message, "backend-operation-failed");
24795
24796 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24797 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24798 agent("claude"),
24799 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24800 ),
24801 ])
24802 .unwrap();
24803 let semantic = shared
24804 .spawn_session(
24805 WorkspaceId::new("primary").unwrap(),
24806 agent("claude"),
24807 SessionMode::Inline,
24808 terminal_size,
24809 None,
24810 )
24811 .await
24812 .unwrap_err();
24813 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24814 assert_eq!(semantic.message, "unsupported-capability");
24815
24816 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24817 assert!(shared
24818 .session_bindings
24819 .lock()
24820 .unwrap_or_else(|poisoned| poisoned.into_inner())
24821 .is_empty());
24822 assert!(shared
24823 .session_records
24824 .lock()
24825 .unwrap_or_else(|poisoned| poisoned.into_inner())
24826 .records
24827 .is_empty());
24828 assert!(shared.handle.snapshot().sessions.is_empty());
24829 }
24830
24831 #[tokio::test]
24832 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24833 let catalog = active_registry().unwrap();
24834 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24835 let workspace_id = WorkspaceId::new("primary").unwrap();
24836 let workspace = WorkspaceConfig::new(
24837 workspace_id.clone(),
24838 std::env::current_dir().unwrap(),
24839 )
24840 .unwrap();
24841 let shared = NodeShared::new(
24842 handle,
24843 "fixture-token".to_owned(),
24844 NodeId::new("node-raw-spawn-record").unwrap(),
24845 vec![workspace],
24846 vec![agent("claude")],
24847 );
24848 let address = SessionAddress {
24849 workspace_id,
24850 session: SessionKey {
24851 instance_id: AgentInstanceId(71),
24852 generation: SessionGeneration::default(),
24853 },
24854 };
24855
24856 let record_id = shared
24857 .bind_spawn_session(
24858 &address,
24859 agent("claude"),
24860 SessionMode::Pty,
24861 ProviderRuntimePolicy::raw_pty(),
24862 SpawnRecordPolicy::Always,
24863 None,
24864 )
24865 .unwrap()
24866 .unwrap();
24867 shared
24868 .handle
24869 .dispatch(shared.prepare_command(ControlCommand::Register {
24870 instance_id: address.session.instance_id,
24871 agent_id: agent("claude"),
24872 transport: TransportKind::Pty,
24873 }))
24874 .unwrap();
24875 runtime.tick().await;
24876
24877 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24878 session.instance_id == address.session.instance_id
24879 && session.generation == address.session.generation
24880 }));
24881 let record = shared.record(&record_id).unwrap();
24882 assert_eq!(record.display_name, "claude #1");
24883 assert_eq!(record.state, ManagedSessionState::Live);
24884 assert_eq!(record.active_session.as_ref(), Some(&address));
24885 assert!(record.provider_session.is_none());
24886 let binding_record_id = shared
24887 .session_bindings
24888 .lock()
24889 .unwrap_or_else(|poisoned| poisoned.into_inner())
24890 .get(&address.session.instance_id)
24891 .and_then(|binding| binding.record_id.clone());
24892 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24893
24894 let renamed = shared
24895 .rename_session_record(&record_id, "raw review".to_owned())
24896 .unwrap();
24897 assert_eq!(renamed.record_id, record_id);
24898 assert_eq!(renamed.display_name, "raw review");
24899 shared.reconcile_managed_record(
24900 &record_id,
24901 &address,
24902 &ControlEvent {
24903 sequence: 1,
24904 command_id: None,
24905 instance_id: address.session.instance_id,
24906 generation: address.session.generation,
24907 event: ControlEventKind::Running { process_id: None },
24908 },
24909 false,
24910 false,
24911 );
24912 let running = shared.record(&record_id).unwrap();
24913 assert_eq!(running.state, ManagedSessionState::Live);
24914 assert_eq!(running.active_session.as_ref(), Some(&address));
24915 assert_eq!(running.display_name, "raw review");
24916
24917 shared.reconcile_managed_record(
24918 &record_id,
24919 &address,
24920 &ControlEvent {
24921 sequence: 2,
24922 command_id: None,
24923 instance_id: address.session.instance_id,
24924 generation: address.session.generation,
24925 event: ControlEventKind::Exited {
24926 exit_code: Some(0),
24927 forced: false,
24928 },
24929 },
24930 false,
24931 false,
24932 );
24933 let detached = shared.record(&record_id).unwrap();
24934 assert_eq!(detached.state, ManagedSessionState::Unavailable);
24935 assert!(detached.active_session.is_none());
24936 assert_eq!(detached.display_name, "raw review");
24937 let renamed_detached = shared
24938 .rename_session_record(&record_id, "raw review detached".to_owned())
24939 .unwrap();
24940 assert_eq!(renamed_detached.record_id, record_id);
24941 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24942 assert!(renamed_detached.active_session.is_none());
24943 assert_eq!(renamed_detached.display_name, "raw review detached");
24944 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24945 }
24946
24947 #[test]
24948 fn legacy_raw_spawn_binding_remains_untracked() {
24949 let catalog = active_registry().unwrap();
24950 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24951 let workspace_id = WorkspaceId::new("primary").unwrap();
24952 let workspace = WorkspaceConfig::new(
24953 workspace_id.clone(),
24954 std::env::current_dir().unwrap(),
24955 )
24956 .unwrap();
24957 let shared = NodeShared::new(
24958 handle,
24959 "fixture-token".to_owned(),
24960 NodeId::new("node-legacy-raw-spawn").unwrap(),
24961 vec![workspace],
24962 vec![agent("claude")],
24963 );
24964 let address = SessionAddress {
24965 workspace_id,
24966 session: SessionKey {
24967 instance_id: AgentInstanceId(72),
24968 generation: SessionGeneration::default(),
24969 },
24970 };
24971
24972 let record_id = shared
24973 .bind_spawn_session(
24974 &address,
24975 agent("claude"),
24976 SessionMode::Pty,
24977 ProviderRuntimePolicy::raw_pty(),
24978 SpawnRecordPolicy::ProviderIdentityOnly,
24979 None,
24980 )
24981 .unwrap();
24982
24983 assert!(record_id.is_none());
24984 let binding_record_id = shared
24985 .session_bindings
24986 .lock()
24987 .unwrap_or_else(|poisoned| poisoned.into_inner())
24988 .get(&address.session.instance_id)
24989 .and_then(|binding| binding.record_id.clone());
24990 assert!(binding_record_id.is_none());
24991 assert!(shared.snapshot().session_records.is_empty());
24992 }
24993
24994 #[tokio::test]
24995 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24996 let catalog = active_registry().unwrap();
24997 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24998 let workspace_id = WorkspaceId::new("primary").unwrap();
24999 let workspace = WorkspaceConfig::new(
25000 workspace_id.clone(),
25001 std::env::current_dir().unwrap(),
25002 )
25003 .unwrap();
25004 let mut shared = NodeShared::new(
25005 handle,
25006 "fixture-token".to_owned(),
25007 NodeId::new("node-raw-semantic-gate").unwrap(),
25008 vec![workspace],
25009 vec![agent("claude")],
25010 );
25011 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25012 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25013 ])
25014 .unwrap();
25015 shared
25016 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25017 .unwrap();
25018 let address = SessionAddress {
25019 workspace_id,
25020 session: SessionKey {
25021 instance_id: AgentInstanceId(72),
25022 generation: SessionGeneration::default(),
25023 },
25024 };
25025 shared.bind_session_with_policy(
25026 &address,
25027 ProviderRuntimePolicy::raw_pty(),
25028 None,
25029 );
25030 shared
25031 .handle
25032 .dispatch(shared.prepare_command(ControlCommand::Register {
25033 instance_id: address.session.instance_id,
25034 agent_id: agent("claude"),
25035 transport: TransportKind::Pty,
25036 }))
25037 .unwrap();
25038 runtime.tick().await;
25039 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25040 let revision_before = shared.handle.snapshot().revision;
25041 let history_sequence_before = shared.current_sequence();
25042 let terminal_size = gate4agent_types::TerminalSize {
25043 rows: 24,
25044 columns: 80,
25045 };
25046
25047 for request in [
25048 NodeRequest::Prompt {
25049 session: address.clone(),
25050 text: "prompt".to_owned(),
25051 },
25052 NodeRequest::Paste {
25053 session: address.clone(),
25054 text: "paste".to_owned(),
25055 },
25056 NodeRequest::Resume {
25062 session: address.clone(),
25063 terminal_size,
25064 initial_prompt: Some("resume".to_owned()),
25065 },
25066 ] {
25067 let error = process_request_inner(
25068 &shared,
25069 77,
25070 ClientRole::Operator,
25071 request,
25072 )
25073 .await
25074 .unwrap_err();
25075 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25076 }
25077
25078 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25079 assert_eq!(shared.handle.snapshot().revision, revision_before);
25080 assert_eq!(shared.current_sequence(), history_sequence_before);
25081 let bindings = shared
25082 .session_bindings
25083 .lock()
25084 .unwrap_or_else(|poisoned| poisoned.into_inner());
25085 let binding = bindings.get(&address.session.instance_id).unwrap();
25086 assert_eq!(binding.generation, address.session.generation);
25087 assert!(binding.pending_resume.is_none());
25088 assert!(binding.record_id.is_none());
25089 }
25090
25091 #[tokio::test]
25092 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25093 let catalog = active_registry().unwrap();
25094 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25095 let primary = WorkspaceConfig::new(
25096 WorkspaceId::new("primary").unwrap(),
25097 std::env::current_dir().unwrap(),
25098 )
25099 .unwrap();
25100 let shared = NodeShared::new(
25101 handle,
25102 "fixture-token".to_owned(),
25103 NodeId::new("node-1").unwrap(),
25104 vec![primary],
25105 vec![agent("claude")],
25106 );
25107 shared
25108 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25109 .unwrap();
25110 let before = shared.snapshot();
25111 let error = process_request_inner(
25112 &shared,
25113 77,
25114 ClientRole::Operator,
25115 NodeRequest::RegisterWorkspace {
25116 workspace_id: WorkspaceId::new("foreign").unwrap(),
25117 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25118 },
25119 )
25120 .await
25121 .unwrap_err();
25122 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25123 assert_eq!(error.message, "invalid-request");
25124 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25125 }
25126
25127 #[tokio::test]
25128 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25129 let catalog = active_registry().unwrap();
25130 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25131 let primary_id = WorkspaceId::new("primary").unwrap();
25132 let primary = WorkspaceConfig::new(
25133 primary_id.clone(),
25134 std::env::current_dir().unwrap(),
25135 )
25136 .unwrap();
25137 let shared = NodeShared::new(
25138 handle,
25139 "fixture-token".to_owned(),
25140 NodeId::new("node-1").unwrap(),
25141 vec![primary.clone()],
25142 vec![agent("claude")],
25143 );
25144 let secondary_id = WorkspaceId::new("secondary").unwrap();
25145 let secondary = shared
25146 .register_workspace(
25147 secondary_id.clone(),
25148 std::env::temp_dir().to_string_lossy().into_owned(),
25149 )
25150 .await
25151 .unwrap();
25152 assert_eq!(secondary.workspace_id, secondary_id);
25153 assert!(secondary.sessions.is_empty());
25154 assert_eq!(shared.snapshot().workspaces.len(), 2);
25155
25156 let duplicate_id = shared
25157 .register_workspace(
25158 secondary_id.clone(),
25159 primary.canonical_root().to_owned(),
25160 )
25161 .await
25162 .unwrap_err();
25163 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25164 let duplicate_root = shared
25165 .register_workspace(
25166 WorkspaceId::new("duplicate-root").unwrap(),
25167 windows_path_text(&secondary.canonical_root).to_owned(),
25168 )
25169 .await
25170 .unwrap_err();
25171 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25172
25173 let address = SessionAddress {
25174 workspace_id: secondary_id.clone(),
25175 session: SessionKey {
25176 instance_id: AgentInstanceId(41),
25177 generation: SessionGeneration(1),
25178 },
25179 };
25180 shared.bind_session(
25181 address.session.instance_id,
25182 secondary_id.clone(),
25183 address.session.generation,
25184 );
25185 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25186 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25187 shared.remove_binding(&address);
25188 shared.unregister_workspace(&secondary_id).unwrap();
25189 assert_eq!(shared.snapshot().workspaces.len(), 1);
25190
25191 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25192 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25193 let history = shared
25194 .history
25195 .lock()
25196 .unwrap_or_else(|poisoned| poisoned.into_inner());
25197 assert!(matches!(
25198 &history.events[0],
25199 NodeEventEnvelope {
25200 sequence: 1,
25201 event: NodeEvent::WorkspaceAdded { workspace },
25202 } if workspace.workspace_id == secondary_id
25203 ));
25204 assert!(matches!(
25205 &history.events[1],
25206 NodeEventEnvelope {
25207 sequence: 2,
25208 event: NodeEvent::WorkspaceRemoved { workspace_id },
25209 } if workspace_id == &secondary_id
25210 ));
25211 }
25212
25213 #[tokio::test]
25214 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25215 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25216 std::fs::create_dir_all(&secondary_root).unwrap();
25217 let catalog = active_registry().unwrap();
25218 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25219 let primary_id = WorkspaceId::new("primary").unwrap();
25220 let secondary_id = WorkspaceId::new("secondary").unwrap();
25221 let shared = NodeShared::new(
25222 handle,
25223 "fixture-token".to_owned(),
25224 NodeId::new("node-session-reference-index").unwrap(),
25225 vec![
25226 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25227 .unwrap(),
25228 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25229 ],
25230 vec![agent("claude"), agent("codex")],
25231 );
25232 let identity = ProviderSessionIdentity {
25233 key: gate4agent_types::ProviderSessionKey::SessionId,
25234 id: "provider-session-42".to_owned(),
25235 transcript_path: None,
25236 };
25237 let index_request = |workspace_id: WorkspaceId,
25238 provider: AgentId,
25239 identity: ProviderSessionIdentity,
25240 display_name: &str| {
25241 NodeRequest::IndexProviderSession {
25242 workspace_id,
25243 provider,
25244 identity,
25245 display_name: display_name.to_owned(),
25246 }
25247 };
25248 let request = index_request(
25249 primary_id.clone(),
25250 agent("claude"),
25251 identity.clone(),
25252 "release shepherd",
25253 );
25254 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25255 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25256 }));
25257 let observer = process_request_inner(
25258 &shared,
25259 77,
25260 ClientRole::Observer,
25261 request.clone(),
25262 )
25263 .await
25264 .unwrap_err();
25265 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25266 let uncontrolled = process_request_inner(
25267 &shared,
25268 77,
25269 ClientRole::Operator,
25270 request.clone(),
25271 )
25272 .await
25273 .unwrap_err();
25274 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25275 shared
25276 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25277 .unwrap();
25278
25279 let indexed = process_request_inner(
25280 &shared,
25281 77,
25282 ClientRole::Operator,
25283 request.clone(),
25284 )
25285 .await
25286 .unwrap();
25287 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25288 panic!("provider session reference index returned another response");
25289 };
25290 assert_eq!(record.state, ManagedSessionState::Dormant);
25291 assert_eq!(record.mode, SessionMode::Pty);
25292 assert_eq!(record.workspace_id, primary_id);
25293 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25294 assert!(record.active_session.is_none());
25295 assert!(record.environment_profile.is_none());
25296 assert!(record.bundle.is_none());
25297 assert!(record.context.is_none());
25298
25299 let repeated = process_request_inner(
25300 &shared,
25301 77,
25302 ClientRole::Operator,
25303 index_request(
25304 primary_id.clone(),
25305 agent("claude"),
25306 identity.clone(),
25307 "ignored replacement",
25308 ),
25309 )
25310 .await
25311 .unwrap();
25312 assert!(matches!(
25313 repeated,
25314 NodeResponse::ProviderSessionIndexed { record: ref existing }
25315 if existing.record_id == record.record_id
25316 && existing.display_name == "release shepherd"
25317 ));
25318 let conflicting_workspace = process_request_inner(
25319 &shared,
25320 77,
25321 ClientRole::Operator,
25322 index_request(
25323 secondary_id,
25324 agent("claude"),
25325 identity.clone(),
25326 "release shepherd",
25327 ),
25328 )
25329 .await
25330 .unwrap_err();
25331 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25332 let conflicting_provider = process_request_inner(
25333 &shared,
25334 77,
25335 ClientRole::Operator,
25336 index_request(
25337 primary_id.clone(),
25338 agent("codex"),
25339 identity.clone(),
25340 "release shepherd",
25341 ),
25342 )
25343 .await
25344 .unwrap_err();
25345 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25346 let transcript_rejected = process_request_inner(
25347 &shared,
25348 77,
25349 ClientRole::Operator,
25350 index_request(
25351 primary_id,
25352 agent("claude"),
25353 ProviderSessionIdentity {
25354 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25355 ..identity
25356 },
25357 "release shepherd",
25358 ),
25359 )
25360 .await
25361 .unwrap_err();
25362 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25363 let upserts = shared
25364 .history
25365 .lock()
25366 .unwrap_or_else(|poisoned| poisoned.into_inner())
25367 .events
25368 .iter()
25369 .filter(|event| matches!(
25370 &event.event,
25371 NodeEvent::SessionRecordUpserted { record: published }
25372 if published.record_id == record.record_id
25373 ))
25374 .count();
25375 assert_eq!(upserts, 1);
25376 std::fs::remove_dir_all(secondary_root).unwrap();
25377 }
25378
25379 #[tokio::test]
25380 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25381 let fixture_root = temporary_workspace_root("standalone-independent");
25382 let target = fixture_root.join("independent");
25383 std::fs::create_dir_all(&fixture_root).unwrap();
25384 let catalog = active_registry().unwrap();
25385 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25386 let selected = WorkspaceConfig::new(
25387 WorkspaceId::new("primary").unwrap(),
25388 std::env::current_dir().unwrap(),
25389 )
25390 .unwrap();
25391 let selected_root = selected.canonical_root().to_owned();
25392 let shared = NodeShared::new(
25393 handle,
25394 "fixture-token".to_owned(),
25395 NodeId::new("node-standalone").unwrap(),
25396 vec![selected],
25397 vec![agent("claude")],
25398 );
25399 let workspace_id = WorkspaceId::new("independent").unwrap();
25400 let request = NodeRequest::CreateStandaloneWorkspace {
25401 workspace_id: workspace_id.clone(),
25402 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25403 initial_branch: Some("main".to_owned()),
25404 };
25405 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25406 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25407 }));
25408 assert!(!request_uses_unnegotiated_capability(
25409 &request,
25410 &baseline_capabilities().unwrap(),
25411 ));
25412 assert!(request_uses_unnegotiated_capability(&request, &[]));
25413
25414 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25415 .await
25416 .unwrap_err();
25417 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25418 assert!(!target.exists());
25419 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25420 .await
25421 .unwrap_err();
25422 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25423 assert!(!target.exists());
25424 shared
25425 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25426 .unwrap();
25427
25428 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25429 .await
25430 .unwrap();
25431 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25432 panic!("standalone workspace creation returned another response");
25433 };
25434 assert_eq!(workspace.workspace_id, workspace_id);
25435 assert!(target.join(".git").is_dir());
25436 let branch = run_git_bounded(
25437 target.to_str().unwrap(),
25438 &["branch", "--show-current"],
25439 GIT_OUTPUT_MAX_BYTES,
25440 )
25441 .await
25442 .unwrap();
25443 assert!(branch.success);
25444 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25445 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25446 assert_eq!(worktrees.len(), 1);
25447 let listed_root = WorkspaceConfig::new(
25448 WorkspaceId::new("listed-independent").unwrap(),
25449 &worktrees[0].path,
25450 )
25451 .unwrap();
25452 assert!(platform::roots_equal(
25453 listed_root.canonical_root(),
25454 windows_path_text(&workspace.canonical_root),
25455 ));
25456 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25457 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25458 snapshot.workspace_id == workspace_id
25459 && snapshot.canonical_root == workspace.canonical_root
25460 }));
25461 assert!(shared
25462 .history
25463 .lock()
25464 .unwrap_or_else(|poisoned| poisoned.into_inner())
25465 .events
25466 .iter()
25467 .any(|event| matches!(
25468 &event.event,
25469 NodeEvent::WorkspaceAdded { workspace }
25470 if workspace.workspace_id == workspace_id
25471 )));
25472 std::fs::remove_dir_all(fixture_root).unwrap();
25473 }
25474
25475 #[tokio::test]
25476 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25477 let fixture_root = temporary_workspace_root("standalone-compensation");
25478 let created_target = fixture_root.join("created-target");
25479 let existing_target = fixture_root.join("existing-target");
25480 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25481 let invalid_state_path = invalid_state_parent.join("state.json");
25482 std::fs::create_dir_all(&existing_target).unwrap();
25483 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25484 let catalog = active_registry().unwrap();
25485 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25486 let primary = WorkspaceConfig::new(
25487 WorkspaceId::new("primary").unwrap(),
25488 std::env::current_dir().unwrap(),
25489 )
25490 .unwrap();
25491 let mut shared = NodeShared::new(
25492 handle,
25493 "fixture-token".to_owned(),
25494 NodeId::new("node-standalone-compensation").unwrap(),
25495 vec![primary],
25496 vec![agent("claude")],
25497 );
25498 shared.state_path = Some(invalid_state_path);
25499
25500 let created_recovery = shared
25501 .create_standalone_workspace(
25502 WorkspaceId::new("compensated").unwrap(),
25503 created_target.to_string_lossy().into_owned(),
25504 Some("main".to_owned()),
25505 )
25506 .await
25507 .unwrap_err();
25508 assert_eq!(
25509 created_recovery.code,
25510 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25511 );
25512 assert!(created_target.join(".git").is_dir());
25513 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25514 workspace.workspace_id.as_str() != "compensated"
25515 }));
25516
25517 let existing_recovery = shared
25518 .create_standalone_workspace(
25519 WorkspaceId::new("restored").unwrap(),
25520 existing_target.to_string_lossy().into_owned(),
25521 None,
25522 )
25523 .await
25524 .unwrap_err();
25525 assert_eq!(
25526 existing_recovery.code,
25527 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25528 );
25529 assert!(existing_target.is_dir());
25530 assert!(existing_target.join(".git").is_dir());
25531 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25532 workspace.workspace_id.as_str() != "restored"
25533 }));
25534
25535 let registered_empty = fixture_root.join("registered-empty");
25536 std::fs::create_dir(®istered_empty).unwrap();
25537 let catalog = active_registry().unwrap();
25538 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25539 let registered = WorkspaceConfig::new(
25540 WorkspaceId::new("registered-empty").unwrap(),
25541 ®istered_empty,
25542 )
25543 .unwrap();
25544 let duplicate_shared = NodeShared::new(
25545 handle,
25546 "fixture-token".to_owned(),
25547 NodeId::new("node-standalone-duplicate-root").unwrap(),
25548 vec![registered],
25549 vec![agent("claude")],
25550 );
25551 let duplicate = duplicate_shared
25552 .create_standalone_workspace(
25553 WorkspaceId::new("duplicate-root").unwrap(),
25554 registered_empty.to_string_lossy().into_owned(),
25555 None,
25556 )
25557 .await
25558 .unwrap_err();
25559 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25560 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25561 std::fs::remove_dir_all(fixture_root).unwrap();
25562 }
25563
25564 #[test]
25565 fn managed_record_id_collision_preserves_the_existing_record() {
25566 let catalog = active_registry().unwrap();
25567 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25568 let workspace = WorkspaceConfig::new(
25569 WorkspaceId::new("primary").unwrap(),
25570 std::env::current_dir().unwrap(),
25571 )
25572 .unwrap();
25573 let shared = NodeShared::new(
25574 handle,
25575 "fixture-token".to_owned(),
25576 NodeId::new("node-1").unwrap(),
25577 vec![workspace.clone()],
25578 vec![agent("claude")],
25579 );
25580 let record_id = SessionRecordId::new("sr-collision").unwrap();
25581 let original = ManagedSessionRecord {
25582 record_id: record_id.clone(),
25583 display_name: "original".to_owned(),
25584 provider: agent("claude"),
25585 mode: SessionMode::Pty,
25586 state: ManagedSessionState::Dormant,
25587 workspace_id: workspace.workspace_id,
25588 canonical_root: opaque_windows_path(workspace.canonical_root),
25589 provider_session: None,
25590 active_session: None,
25591 environment_profile: None,
25592 bundle: None,
25593 context_id: None,
25594 context: None,
25595 exported_context: None,
25596 task_binding: None,
25597 created_at_unix_ms: 1,
25598 updated_at_unix_ms: 1,
25599 last_error: None,
25600 };
25601 shared.insert_record(original.clone()).unwrap();
25602 let mut collision = original.clone();
25603 collision.display_name = "replacement".to_owned();
25604 let error = shared.insert_record(collision).unwrap_err();
25605 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25606 assert_eq!(shared.record(&record_id).unwrap(), original);
25607 }
25608
25609 #[test]
25610 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25611 let catalog = active_registry().unwrap();
25612 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25613 let workspace = WorkspaceConfig::new(
25614 WorkspaceId::new("primary").unwrap(),
25615 std::env::current_dir().unwrap(),
25616 )
25617 .unwrap();
25618 let shared = NodeShared::new(
25619 handle,
25620 "fixture-token".to_owned(),
25621 NodeId::new("node-1").unwrap(),
25622 vec![workspace.clone()],
25623 vec![agent("claude")],
25624 );
25625 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25626 shared
25627 .insert_record(ManagedSessionRecord {
25628 record_id: record_id.clone(),
25629 display_name: "runtime error".to_owned(),
25630 provider: agent("claude"),
25631 mode: SessionMode::Pty,
25632 state: ManagedSessionState::IdentityPending,
25633 workspace_id: workspace.workspace_id,
25634 canonical_root: opaque_windows_path(workspace.canonical_root),
25635 provider_session: None,
25636 active_session: None,
25637 environment_profile: None,
25638 bundle: None,
25639 context_id: None,
25640 context: None,
25641 exported_context: None,
25642 task_binding: None,
25643 created_at_unix_ms: 1,
25644 updated_at_unix_ms: 1,
25645 last_error: None,
25646 })
25647 .unwrap();
25648
25649 shared
25650 .mark_record_error(
25651 &record_id,
25652 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25653 )
25654 .unwrap();
25655 let record = shared.record(&record_id).unwrap();
25656 assert_eq!(record.state, ManagedSessionState::Unavailable);
25657 assert_eq!(
25658 record.last_error.as_deref(),
25659 Some("provider-runtime-failed"),
25660 );
25661 let snapshot = shared.snapshot();
25662 assert_eq!(snapshot.session_records, vec![record]);
25663 }
25664
25665 #[test]
25674 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25675 let catalog = active_registry().unwrap();
25676 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25677 let workspace = WorkspaceConfig::new(
25678 WorkspaceId::new("primary").unwrap(),
25679 std::env::current_dir().unwrap(),
25680 )
25681 .unwrap();
25682 let shared = NodeShared::new(
25683 handle,
25684 "fixture-token".to_owned(),
25685 NodeId::new("node-1").unwrap(),
25686 vec![workspace],
25687 vec![agent("claude")],
25688 );
25689
25690 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25691
25692 shared.record_rejected_command(
25693 CommandId(1),
25694 "agent 'claude' does not support transport Acp".to_owned(),
25695 true,
25696 );
25697 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25699
25700 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25701 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25702 assert!(reason.unsupported_transport);
25703
25704 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25706
25707 shared.record_rejected_command(
25712 CommandId(3),
25713 "backend busy".to_owned(),
25714 false,
25715 );
25716 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25717 assert!(!unclassified.unsupported_transport);
25718 }
25719
25720 #[test]
25721 fn provider_identity_update_is_ignored_without_exact_session_policy() {
25722 let catalog = active_registry().unwrap();
25723 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25724 let workspace = WorkspaceConfig::new(
25725 WorkspaceId::new("primary").unwrap(),
25726 std::env::current_dir().unwrap(),
25727 )
25728 .unwrap();
25729 let shared = NodeShared::new(
25730 handle,
25731 "fixture-token".to_owned(),
25732 NodeId::new("node-identity-policy").unwrap(),
25733 vec![workspace.clone()],
25734 vec![agent("claude")],
25735 );
25736 let address = SessionAddress {
25737 workspace_id: workspace.workspace_id.clone(),
25738 session: SessionKey {
25739 instance_id: AgentInstanceId(90),
25740 generation: SessionGeneration(1),
25741 },
25742 };
25743 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25744 shared
25745 .insert_record(ManagedSessionRecord {
25746 record_id: record_id.clone(),
25747 display_name: "policy denied".to_owned(),
25748 provider: agent("claude"),
25749 mode: SessionMode::Pty,
25750 state: ManagedSessionState::IdentityPending,
25751 workspace_id: workspace.workspace_id,
25752 canonical_root: opaque_windows_path(workspace.canonical_root),
25753 provider_session: None,
25754 active_session: Some(address.clone()),
25755 environment_profile: None,
25756 bundle: None,
25757 context_id: None,
25758 context: None,
25759 exported_context: None,
25760 task_binding: None,
25761 created_at_unix_ms: 1,
25762 updated_at_unix_ms: 1,
25763 last_error: None,
25764 })
25765 .unwrap();
25766 let event = ControlEvent {
25767 sequence: 1,
25768 command_id: None,
25769 instance_id: address.session.instance_id,
25770 generation: address.session.generation,
25771 event: ControlEventKind::ProviderEvent {
25772 sequence: 1,
25773 source: gate4agent_types::ProviderSource {
25774 family: AdapterFamily::PtySemantic,
25775 binding: AdapterBinding::new(
25776 gate4agent_types::AdapterId::new("claude").unwrap(),
25777 "fixture/v1",
25778 gate4agent_types::AdapterVerification::SyntheticFixture,
25779 )
25780 .unwrap(),
25781 },
25782 source_sequence: 1,
25783 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25784 identity: gate4agent_types::ProviderSessionIdentity {
25785 key: gate4agent_types::ProviderSessionKey::SessionId,
25786 id: "provider-session".to_owned(),
25787 transcript_path: None,
25788 },
25789 },
25790 },
25791 };
25792
25793 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25794
25795 let record = shared.record(&record_id).unwrap();
25796 assert!(record.provider_session.is_none());
25797 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25798 assert_eq!(record.active_session.as_ref(), Some(&address));
25799 }
25800
25801 #[test]
25802 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25803 let secondary_root = temporary_workspace_root("identity-scope");
25804 std::fs::create_dir_all(&secondary_root).unwrap();
25805 let catalog = active_registry().unwrap();
25806 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25807 let primary = WorkspaceConfig::new(
25808 WorkspaceId::new("primary").unwrap(),
25809 std::env::current_dir().unwrap(),
25810 )
25811 .unwrap();
25812 let secondary = WorkspaceConfig::new(
25813 WorkspaceId::new("secondary").unwrap(),
25814 &secondary_root,
25815 )
25816 .unwrap();
25817 let shared = NodeShared::new(
25818 handle,
25819 "fixture-token".to_owned(),
25820 NodeId::new("node-1").unwrap(),
25821 vec![primary.clone(), secondary.clone()],
25822 vec![agent("claude")],
25823 );
25824 let identity = gate4agent_types::ProviderSessionIdentity {
25825 key: gate4agent_types::ProviderSessionKey::SessionId,
25826 id: "provider-session".to_owned(),
25827 transcript_path: None,
25828 };
25829 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25830 shared
25831 .insert_record(ManagedSessionRecord {
25832 record_id: dormant_id.clone(),
25833 display_name: "dormant".to_owned(),
25834 provider: agent("claude"),
25835 mode: SessionMode::Pty,
25836 state: ManagedSessionState::Dormant,
25837 workspace_id: primary.workspace_id,
25838 canonical_root: opaque_windows_path(primary.canonical_root),
25839 provider_session: Some(identity.clone()),
25840 active_session: None,
25841 environment_profile: None,
25842 bundle: None,
25843 context_id: None,
25844 context: None,
25845 exported_context: None,
25846 task_binding: None,
25847 created_at_unix_ms: 1,
25848 updated_at_unix_ms: 1,
25849 last_error: None,
25850 })
25851 .unwrap();
25852 let address = SessionAddress {
25853 workspace_id: secondary.workspace_id.clone(),
25854 session: SessionKey {
25855 instance_id: AgentInstanceId(91),
25856 generation: SessionGeneration(1),
25857 },
25858 };
25859 let current_id = SessionRecordId::new("sr-current").unwrap();
25860 shared
25861 .insert_record(ManagedSessionRecord {
25862 record_id: current_id.clone(),
25863 display_name: "current".to_owned(),
25864 provider: agent("claude"),
25865 mode: SessionMode::Pty,
25866 state: ManagedSessionState::IdentityPending,
25867 workspace_id: secondary.workspace_id,
25868 canonical_root: opaque_windows_path(secondary.canonical_root),
25869 provider_session: None,
25870 active_session: Some(address.clone()),
25871 environment_profile: None,
25872 bundle: None,
25873 context_id: None,
25874 context: None,
25875 exported_context: None,
25876 task_binding: None,
25877 created_at_unix_ms: 2,
25878 updated_at_unix_ms: 2,
25879 last_error: None,
25880 })
25881 .unwrap();
25882 shared.bind_managed_session(
25883 &address,
25884 current_id.clone(),
25885 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25886 None,
25887 );
25888 let event = ControlEvent {
25889 sequence: 1,
25890 command_id: None,
25891 instance_id: address.session.instance_id,
25892 generation: address.session.generation,
25893 event: ControlEventKind::ProviderEvent {
25894 sequence: 1,
25895 source: gate4agent_types::ProviderSource {
25896 family: gate4agent_types::AdapterFamily::PtySemantic,
25897 binding: gate4agent_types::AdapterBinding::new(
25898 gate4agent_types::AdapterId::new("claude").unwrap(),
25899 "fixture/v1",
25900 gate4agent_types::AdapterVerification::SyntheticFixture,
25901 )
25902 .unwrap(),
25903 },
25904 source_sequence: 1,
25905 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25906 identity,
25907 },
25908 },
25909 };
25910 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
25911
25912 let binding = shared
25913 .session_bindings
25914 .lock()
25915 .unwrap_or_else(|poisoned| poisoned.into_inner())
25916 .get(&address.session.instance_id)
25917 .cloned()
25918 .unwrap();
25919 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
25920 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25921 let current = shared.record(¤t_id).unwrap();
25922 assert_eq!(current.state, ManagedSessionState::Unavailable);
25923 assert_eq!(current.active_session.as_ref(), Some(&address));
25924 assert_eq!(
25925 current.last_error.as_deref(),
25926 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25927 );
25928 std::fs::remove_dir_all(secondary_root).unwrap();
25929 }
25930
25931 #[tokio::test]
25932 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25933 let root = temporary_workspace_root("semantic-provider-session");
25934 std::fs::create_dir_all(&root).unwrap();
25935 let state_path = root.join("state-v1.json");
25936 let catalog = active_registry().unwrap();
25937 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25938 let workspace = WorkspaceConfig::new(
25939 WorkspaceId::new("primary").unwrap(),
25940 &root,
25941 )
25942 .unwrap();
25943 let node_id = NodeId::new("node-semantic-identity").unwrap();
25944 let shared = NodeShared::new_with_incarnation(
25945 handle,
25946 "fixture-token".to_owned(),
25947 node_id.clone(),
25948 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25949 vec![workspace.clone()],
25950 vec![agent("claude")],
25951 ProviderRuntimeStatuses::default(),
25952 None,
25953 Vec::new(),
25954 Vec::new(),
25955 SpawnProfileRegistry::default(),
25956 None,
25957 None,
25958 None,
25959 Some(state_path.clone()),
25960 Vec::new(),
25961 Vec::new(),
25962 Vec::new(),
25963 Vec::new(),
25964 None,
25965 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25966 );
25967 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25968 key: gate4agent_types::ProviderSessionKey::SessionId,
25969 id: "provider-session".to_owned(),
25970 transcript_path: None,
25971 };
25972 let durable_id = SessionRecordId::new("sr-durable").unwrap();
25973 shared
25974 .insert_record(ManagedSessionRecord {
25975 record_id: durable_id.clone(),
25976 display_name: "durable".to_owned(),
25977 provider: agent("claude"),
25978 mode: SessionMode::Pty,
25979 state: ManagedSessionState::Dormant,
25980 workspace_id: workspace.workspace_id.clone(),
25981 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25982 provider_session: Some(persisted_identity.clone()),
25983 active_session: None,
25984 environment_profile: None,
25985 bundle: None,
25986 context_id: None,
25987 context: None,
25988 exported_context: None,
25989 task_binding: None,
25990 created_at_unix_ms: 1,
25991 updated_at_unix_ms: 1,
25992 last_error: None,
25993 })
25994 .unwrap();
25995 let address = SessionAddress {
25996 workspace_id: workspace.workspace_id.clone(),
25997 session: SessionKey {
25998 instance_id: AgentInstanceId(92),
25999 generation: SessionGeneration(2),
26000 },
26001 };
26002 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26003 shared
26004 .insert_record(ManagedSessionRecord {
26005 record_id: pending_id.clone(),
26006 display_name: "pending".to_owned(),
26007 provider: agent("claude"),
26008 mode: SessionMode::Pty,
26009 state: ManagedSessionState::IdentityPending,
26010 workspace_id: workspace.workspace_id.clone(),
26011 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26012 provider_session: None,
26013 active_session: Some(address.clone()),
26014 environment_profile: None,
26015 bundle: None,
26016 context_id: None,
26017 context: None,
26018 exported_context: None,
26019 task_binding: None,
26020 created_at_unix_ms: 2,
26021 updated_at_unix_ms: 2,
26022 last_error: None,
26023 })
26024 .unwrap();
26025 shared.bind_managed_session(
26026 &address,
26027 pending_id.clone(),
26028 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26029 None,
26030 );
26031 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26032 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26033 ..persisted_identity
26034 };
26035 let event = ControlEvent {
26036 sequence: 1,
26037 command_id: None,
26038 instance_id: address.session.instance_id,
26039 generation: address.session.generation,
26040 event: ControlEventKind::ProviderEvent {
26041 sequence: 1,
26042 source: gate4agent_types::ProviderSource {
26043 family: gate4agent_types::AdapterFamily::PtySemantic,
26044 binding: gate4agent_types::AdapterBinding::new(
26045 gate4agent_types::AdapterId::new("claude").unwrap(),
26046 "fixture/v1",
26047 gate4agent_types::AdapterVerification::SyntheticFixture,
26048 )
26049 .unwrap(),
26050 },
26051 source_sequence: 1,
26052 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26053 identity: observed_identity.clone(),
26054 },
26055 },
26056 };
26057 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26058
26059 let snapshot = shared.snapshot();
26060 assert_eq!(snapshot.session_records.len(), 1);
26061 let live = &snapshot.session_records[0];
26062 assert_eq!(live.record_id, durable_id);
26063 assert_eq!(live.state, ManagedSessionState::Live);
26064 assert_eq!(live.active_session.as_ref(), Some(&address));
26065 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26066 let binding = shared
26067 .session_bindings
26068 .lock()
26069 .unwrap_or_else(|poisoned| poisoned.into_inner())
26070 .get(&address.session.instance_id)
26071 .cloned()
26072 .unwrap();
26073 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26074
26075 let mut duplicate = live.clone();
26076 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26077 duplicate.display_name = "duplicate".to_owned();
26078 duplicate.state = ManagedSessionState::Dormant;
26079 duplicate.active_session = None;
26080 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26081 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26082 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26083 assert_eq!(shared.snapshot().session_records.len(), 1);
26084
26085 let terminal_size = gate4agent_types::TerminalSize {
26086 rows: 24,
26087 columns: 80,
26088 };
26089 let (first_busy, second_busy) = tokio::join!(
26090 shared.resume_session_record(&durable_id, terminal_size, None),
26091 shared.resume_session_record(&durable_id, terminal_size, None),
26092 );
26093 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26094 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26095 assert_eq!(busy.message, "session-record-busy");
26096 }
26097
26098 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26099 assert_eq!(loaded.records.len(), 1);
26100 assert_eq!(loaded.records[0].record_id, durable_id);
26101 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26102 assert_eq!(
26103 loaded.records[0]
26104 .provider_session
26105 .as_ref()
26106 .unwrap()
26107 .transcript_path,
26108 None,
26109 );
26110 let catalog = active_registry().unwrap();
26111 let (reloaded_handle, _reloaded_runtime) =
26112 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26113 let reloaded = NodeShared::new_with_incarnation(
26114 reloaded_handle,
26115 "fixture-token".to_owned(),
26116 node_id,
26117 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26118 vec![workspace],
26119 vec![agent("claude")],
26120 ProviderRuntimeStatuses::default(),
26121 None,
26122 Vec::new(),
26123 Vec::new(),
26124 SpawnProfileRegistry::default(),
26125 None,
26126 None,
26127 None,
26128 None,
26129 loaded.records,
26130 loaded.managed_worktrees,
26131 loaded.managed_worktree_tombstones,
26132 loaded.materializations,
26133 loaded.warning,
26134 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26135 );
26136 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26137 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26138 std::fs::remove_dir_all(root).unwrap();
26139 }
26140
26141 #[tokio::test]
26142 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26143 let secondary_root = temporary_workspace_root("workspace-restore");
26144 let other_root = temporary_workspace_root("workspace-conflict");
26145 std::fs::create_dir_all(&secondary_root).unwrap();
26146 std::fs::create_dir_all(&other_root).unwrap();
26147 let catalog = active_registry().unwrap();
26148 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26149 let primary = WorkspaceConfig::new(
26150 WorkspaceId::new("primary").unwrap(),
26151 std::env::current_dir().unwrap(),
26152 )
26153 .unwrap();
26154 let secondary = WorkspaceConfig::new(
26155 WorkspaceId::new("secondary").unwrap(),
26156 &secondary_root,
26157 )
26158 .unwrap();
26159 let shared = NodeShared::new(
26160 handle,
26161 "fixture-token".to_owned(),
26162 NodeId::new("node-1").unwrap(),
26163 vec![primary, secondary.clone()],
26164 vec![agent("claude")],
26165 );
26166 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26167 shared
26168 .insert_record(ManagedSessionRecord {
26169 record_id: record_id.clone(),
26170 display_name: "workspace record".to_owned(),
26171 provider: agent("claude"),
26172 mode: SessionMode::Pty,
26173 state: ManagedSessionState::Dormant,
26174 workspace_id: secondary.workspace_id.clone(),
26175 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26176 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26177 key: gate4agent_types::ProviderSessionKey::SessionId,
26178 id: "provider-session".to_owned(),
26179 transcript_path: None,
26180 }),
26181 active_session: None,
26182 environment_profile: None,
26183 bundle: None,
26184 context_id: None,
26185 context: None,
26186 exported_context: None,
26187 task_binding: None,
26188 created_at_unix_ms: 1,
26189 updated_at_unix_ms: 1,
26190 last_error: None,
26191 })
26192 .unwrap();
26193 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26194 let unavailable = shared.record(&record_id).unwrap();
26195 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26196 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26197
26198 let conflict = shared
26199 .register_workspace(
26200 secondary.workspace_id.clone(),
26201 other_root.to_string_lossy().into_owned(),
26202 )
26203 .await
26204 .unwrap_err();
26205 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26206 shared
26207 .register_workspace(
26208 secondary.workspace_id,
26209 secondary_root.to_string_lossy().into_owned(),
26210 )
26211 .await
26212 .unwrap();
26213 let restored = shared.record(&record_id).unwrap();
26214 assert_eq!(restored.state, ManagedSessionState::Dormant);
26215 assert!(restored.last_error.is_none());
26216 std::fs::remove_dir_all(secondary_root).unwrap();
26217 std::fs::remove_dir_all(other_root).unwrap();
26218 }
26219
26220 #[test]
26221 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26222 let error = validate_workspace_request_root(
26223 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26224 )
26225 .unwrap_err();
26226 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26227 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26228 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26229 }
26230
26231 #[test]
26232 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26233 let catalog = active_registry().unwrap();
26234 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26235 let workspace = WorkspaceConfig::new(
26236 WorkspaceId::new("primary").unwrap(),
26237 std::env::current_dir().unwrap(),
26238 )
26239 .unwrap();
26240 let shared = NodeShared::new(
26241 handle,
26242 "fixture-token".to_owned(),
26243 NodeId::new("node-1").unwrap(),
26244 vec![workspace],
26245 vec![agent("claude")],
26246 );
26247 let publishers = 4_u64;
26248 let events_per_publisher = 256_u64;
26249 let start = std::sync::Barrier::new(publishers as usize + 1);
26250 let finished = AtomicU64::new(0);
26251
26252 std::thread::scope(|scope| {
26253 for _ in 0..publishers {
26254 scope.spawn(|| {
26255 start.wait();
26256 for _ in 0..events_per_publisher {
26257 shared.publish(NodeEvent::ControllerChanged { controller: None });
26258 }
26259 finished.fetch_add(1, Ordering::Release);
26260 });
26261 }
26262 start.wait();
26263 while finished.load(Ordering::Acquire) != publishers {
26264 assert_resync_history_is_linearized(shared.resync(0));
26265 std::thread::yield_now();
26266 }
26267 });
26268 let response = shared.resync(0);
26269 assert_resync_history_is_linearized(response.clone());
26270 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26271 unreachable!("resync helper returned another response");
26272 };
26273 assert_eq!(event_sequence, publishers * events_per_publisher);
26274 assert_eq!(events.len() as u64, event_sequence);
26275 }
26276
26277 fn assert_resync_history_is_linearized(response: NodeResponse) {
26278 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26279 unreachable!("resync helper returned another response");
26280 };
26281 assert!(events.windows(2).all(|pair| {
26282 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26283 }));
26284 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26285 }
26286
26287 #[test]
26288 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26289 assert_eq!(
26290 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26291 r"C:\repo\worktree",
26292 );
26293 assert_eq!(
26294 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26295 r"\\server\share\repo",
26296 );
26297 assert!(validate_workspace_root(
26298 &WorkspaceId::new("network").unwrap(),
26299 r"\\server\share\repo",
26300 )
26301 .is_err());
26302 let workspace = WorkspaceConfig::new(
26303 WorkspaceId::new("primary").unwrap(),
26304 std::env::current_dir().unwrap(),
26305 )
26306 .unwrap();
26307 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26308 }
26309
26310 #[test]
26311 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26312 let catalog = active_registry().unwrap();
26313 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26314 let workspace_id = WorkspaceId::new("primary").unwrap();
26315 let workspace = WorkspaceConfig::new(
26316 workspace_id.clone(),
26317 std::env::current_dir().unwrap(),
26318 )
26319 .unwrap();
26320 let shared = NodeShared::new(
26321 handle,
26322 "fixture-token".to_owned(),
26323 NodeId::new("node-1").unwrap(),
26324 vec![workspace],
26325 vec![agent("claude")],
26326 );
26327 let instance_id = AgentInstanceId(17);
26328 let original = SessionAddress {
26329 workspace_id,
26330 session: SessionKey {
26331 instance_id,
26332 generation: SessionGeneration(3),
26333 },
26334 };
26335 shared.bind_session(
26336 instance_id,
26337 original.workspace_id.clone(),
26338 original.session.generation,
26339 );
26340 shared
26341 .arm_resume(
26342 &original,
26343 CommandId(41),
26344 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26345 )
26346 .unwrap();
26347
26348 shared.publish_control(ControlEvent {
26349 sequence: 1,
26350 command_id: None,
26351 instance_id,
26352 generation: SessionGeneration(4),
26353 event: ControlEventKind::Running { process_id: None },
26354 });
26355 assert_eq!(
26356 shared.address_for(instance_id, SessionGeneration(3)),
26357 Some(original.clone()),
26358 );
26359 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26360
26361 shared.publish_control(ControlEvent {
26362 sequence: 2,
26363 command_id: Some(CommandId(42)),
26364 instance_id,
26365 generation: SessionGeneration(3),
26366 event: ControlEventKind::CommandRejected {
26367 message: "unrelated command".to_owned(),
26368 },
26369 });
26370
26371 shared.publish_control(ControlEvent {
26372 sequence: 3,
26373 command_id: None,
26374 instance_id,
26375 generation: SessionGeneration(4),
26376 event: ControlEventKind::ResumeAuthorized {
26377 session: gate4agent_types::ResumeSessionSummary {
26378 key: gate4agent_types::ProviderSessionKey::SessionId,
26379 id: "fixture-session".to_owned(),
26380 },
26381 },
26382 });
26383 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26384 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26385 assert_eq!(rebound.workspace_id, original.workspace_id);
26386 let history = shared
26387 .history
26388 .lock()
26389 .unwrap_or_else(|poisoned| poisoned.into_inner());
26390 assert!(history.events.iter().any(|envelope| matches!(
26391 &envelope.event,
26392 NodeEvent::Control { address, event }
26393 if address == &rebound
26394 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26395 )));
26396 }
26397
26398 #[test]
26399 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26400 for agent in ["claude", "codex", "kimi"] {
26401 assert_eq!(
26402 prompt_framing(&AgentId::new(agent).unwrap()),
26403 PromptFraming::BracketedPaste
26404 );
26405 }
26406 }
26407
26408 fn generous_walk_deadline() -> Instant {
26409 Instant::now() + Duration::from_secs(30)
26410 }
26411
26412 #[test]
26413 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26414 let root = temporary_workspace_root("tree");
26415 std::fs::create_dir_all(root.join("src")).unwrap();
26416 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26417 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26418 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26419 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26420 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26421 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26427 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26428 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26432 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26433
26434 let (entries, truncated, _budget) =
26435 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26436 assert!(!truncated);
26437 assert!(entries.iter().any(|entry| {
26438 entry.relative_path.as_utf8() == Some("src")
26439 && entry.kind == WorkspaceEntryKind::Directory
26440 }));
26441 assert!(entries.iter().any(|entry| {
26442 entry.relative_path.as_utf8() == Some("src/lib.rs")
26443 && entry.kind == WorkspaceEntryKind::File
26444 }));
26445 assert!(entries.iter().any(|entry| {
26446 entry.relative_path.as_utf8() == Some("target-notes")
26447 && entry.kind == WorkspaceEntryKind::Directory
26448 }));
26449 assert!(entries.iter().any(|entry| {
26450 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26451 && entry.kind == WorkspaceEntryKind::File
26452 }));
26453 assert!(entries.iter().all(|entry| {
26454 let path = entry.relative_path.as_utf8().unwrap();
26455 !path.starts_with(".git")
26456 && !(path == "target" || path.starts_with("target/"))
26457 && !(path == "target-a2" || path.starts_with("target-a2/"))
26458 && !path.starts_with("node_modules")
26459 }));
26460
26461 std::fs::remove_dir_all(&root).unwrap();
26462 }
26463
26464 #[test]
26465 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26466 let root = temporary_workspace_root("tree-entry-cap");
26467 std::fs::create_dir_all(&root).unwrap();
26468 for index in 0..20 {
26469 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26470 }
26471
26472 let (entries, truncated, budget) =
26473 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26474 assert!(truncated);
26475 assert!(budget.entry_cap_exceeded);
26476 assert!(!budget.time_budget_exceeded);
26477 assert!(entries.len() <= 5);
26478
26479 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26480 .expect("an entry-cap-truncated walk must report a truncation marker");
26481 assert!(inspection_truncation.walk_entry_cap_exceeded);
26482 assert!(!inspection_truncation.walk_time_budget_exceeded);
26483 assert!(!inspection_truncation.git_time_budget_exceeded);
26484 assert!(inspection_truncation.entries_visited >= 5);
26485
26486 std::fs::remove_dir_all(&root).unwrap();
26487 }
26488
26489 #[test]
26490 fn workspace_walk_within_budget_reports_no_truncation() {
26491 let root = temporary_workspace_root("tree-no-truncation");
26492 std::fs::create_dir_all(&root).unwrap();
26493 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26494
26495 let (_entries, truncated, budget) =
26496 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26497 assert!(!truncated);
26498 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26499
26500 std::fs::remove_dir_all(&root).unwrap();
26501 }
26502
26503 #[test]
26504 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26505 let root = temporary_workspace_root("tree-prefix-siblings");
26506 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26507 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26508 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26509 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), 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 let paths = entries
26515 .iter()
26516 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26517 .collect::<Vec<_>>();
26518 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26519 assert_eq!(
26520 paths,
26521 vec![
26522 "gate4agent-c2".to_owned(),
26523 "gate4agent-c2-client".to_owned(),
26524 "gate4agent-c2-client/lib.rs".to_owned(),
26525 "gate4agent-c2/src".to_owned(),
26526 "gate4agent-c2/src/lib.rs".to_owned(),
26527 ],
26528 );
26529
26530 std::fs::remove_dir_all(&root).unwrap();
26531 }
26532
26533 #[test]
26534 fn git_parsers_cap_status_and_commit_results() {
26535 let mut status_output = Vec::new();
26536 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26537 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26538 }
26539 let mut snapshot = git_snapshot_for_parser();
26540 parse_git_status(&status_output, &mut snapshot);
26541 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26542 assert!(snapshot.truncated);
26543 assert_eq!(snapshot.status[0].index_status, " ");
26544 assert_eq!(snapshot.status[0].worktree_status, "M");
26545
26546 snapshot.truncated = false;
26547 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26548 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26549 .collect::<String>();
26550 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26551 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26552 assert!(snapshot.truncated);
26553 }
26554
26555 #[test]
26556 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26557 let mut snapshot = git_snapshot_for_parser();
26558 parse_git_status(
26559 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26560 &mut snapshot,
26561 );
26562
26563 assert!(!snapshot.truncated);
26564 assert_eq!(snapshot.status.len(), 3);
26565 assert_eq!(snapshot.status[0].index_status, "R");
26566 assert_eq!(snapshot.status[0].worktree_status, " ");
26567 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26568 assert_eq!(
26569 snapshot.status[0]
26570 .previous_path
26571 .as_ref()
26572 .and_then(RepositoryPath::as_utf8),
26573 Some("src/previous name.rs"),
26574 );
26575 assert_eq!(snapshot.status[1].index_status, " ");
26576 assert_eq!(snapshot.status[1].worktree_status, "C");
26577 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26578 assert_eq!(
26579 snapshot.status[1]
26580 .previous_path
26581 .as_ref()
26582 .and_then(RepositoryPath::as_utf8),
26583 Some("src/original.rs"),
26584 );
26585 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26586 assert!(snapshot.status[2].previous_path.is_none());
26587 }
26588
26589 #[test]
26590 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26591 let mut snapshot = git_snapshot_for_parser();
26592 parse_git_status(
26593 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
26594 &mut snapshot,
26595 );
26596
26597 assert!(snapshot.truncated);
26598 assert_eq!(snapshot.status.len(), 1);
26599 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26600 assert!(snapshot.status[0].previous_path.is_none());
26601 }
26602
26603 #[tokio::test]
26604 async fn observer_can_browse_host_directories_without_controller() {
26605 let root = temporary_workspace_root("host-directory-observer");
26606 std::fs::create_dir_all(root.join("directory-b")).unwrap();
26607 std::fs::create_dir_all(root.join("directory-a")).unwrap();
26608 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26609 let workspace_id = WorkspaceId::new("primary").unwrap();
26610 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26611 let catalog = active_registry().unwrap();
26612 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26613 let shared = NodeShared::new(
26614 handle,
26615 "fixture-token".to_owned(),
26616 NodeId::new("node-1").unwrap(),
26617 vec![workspace],
26618 vec![agent("claude")],
26619 );
26620 let request = NodeRequest::BrowseHostDirectories {
26621 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26622 after: None,
26623 };
26624 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26625 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26626 }));
26627 assert!(!request_uses_unnegotiated_capability(
26628 &request,
26629 &baseline_capabilities().unwrap(),
26630 ));
26631 assert!(request_uses_unnegotiated_capability(&request, &[]));
26632
26633 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26634 .await
26635 .unwrap();
26636 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26637 panic!("host directory browse returned another response");
26638 };
26639 assert_eq!(
26640 listing
26641 .entries
26642 .iter()
26643 .map(|entry| entry.display_name.as_str())
26644 .collect::<Vec<_>>(),
26645 vec!["directory-a", "directory-b"],
26646 );
26647 assert!(listing.entries.iter().all(|entry| !entry.is_link));
26648 assert!(!listing.incomplete);
26649 std::fs::remove_dir_all(root).unwrap();
26650 }
26651
26652 #[tokio::test]
26653 async fn observer_can_inspect_only_a_registered_workspace() {
26654 let root = temporary_workspace_root("observer");
26655 std::fs::create_dir_all(root.join("src")).unwrap();
26656 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26657 let workspace_id = WorkspaceId::new("primary").unwrap();
26658 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26659 let catalog = active_registry().unwrap();
26660 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26661 let shared = NodeShared::new(
26662 handle,
26663 "fixture-token".to_owned(),
26664 NodeId::new("node-1").unwrap(),
26665 vec![workspace],
26666 vec![agent("claude")],
26667 );
26668
26669 let response = process_request_inner(
26670 &shared,
26671 77,
26672 ClientRole::Observer,
26673 NodeRequest::InspectWorkspace {
26674 workspace_id: workspace_id.clone(),
26675 },
26676 )
26677 .await
26678 .unwrap();
26679 let NodeResponse::WorkspaceInspected { inspection } = response else {
26680 panic!("workspace inspection returned another response");
26681 };
26682 assert_eq!(inspection.workspace_id, workspace_id);
26683 assert!(inspection.entries.iter().any(|entry| {
26684 entry.relative_path.as_utf8() == Some("src/main.rs")
26685 }));
26686
26687 let unknown = process_request_inner(
26688 &shared,
26689 77,
26690 ClientRole::Observer,
26691 NodeRequest::InspectWorkspace {
26692 workspace_id: WorkspaceId::new("unknown").unwrap(),
26693 },
26694 )
26695 .await
26696 .unwrap_err();
26697 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26698 std::fs::remove_dir_all(&root).unwrap();
26699 }
26700
26701 #[test]
26702 fn context_pack_source_accepts_only_running_or_successful_exit() {
26703 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26704 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26705 exit_code: Some(0),
26706 }));
26707 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26708 exit_code: Some(1),
26709 }));
26710 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26711 exit_code: None,
26712 }));
26713 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26714 message: "provider failed".to_owned(),
26715 }));
26716 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26717 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26718 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26719 }
26720
26721 #[test]
26722 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26723 let git = git_snapshot_for_parser();
26724 let first = ContextPackRepositoryObservation {
26725 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26726 git: git.clone(),
26727 files: Vec::new(),
26728 };
26729 let second = ContextPackRepositoryObservation {
26730 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26731 git,
26732 files: Vec::new(),
26733 };
26734
26735 let error = stable_context_pack_repository(first, second).unwrap_err();
26736
26737 assert_eq!(
26738 error.code,
26739 NodeFailureCode::ContextPackMaterializationFailed,
26740 );
26741 }
26742
26743 #[test]
26744 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26745 let git = git_snapshot_for_parser();
26746 let first = ContextPackRepositoryObservation {
26747 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26748 git: git.clone(),
26749 files: vec![ContextPackRepositoryFileSource::utf8(
26750 "README.md",
26751 "first\n".to_owned(),
26752 6,
26753 )],
26754 };
26755 let second = ContextPackRepositoryObservation {
26756 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26757 git,
26758 files: vec![ContextPackRepositoryFileSource::utf8(
26759 "README.md",
26760 "other\n".to_owned(),
26761 6,
26762 )],
26763 };
26764
26765 let error = stable_context_pack_repository(first, second).unwrap_err();
26766
26767 assert_eq!(
26768 error.code,
26769 NodeFailureCode::ContextPackMaterializationFailed,
26770 );
26771 }
26772
26773 #[tokio::test]
26774 async fn native_session_catalog_zero_active_session_fixture() {
26775 let workspace_root = temporary_workspace_root("native-catalog-workspace");
26776 let history_root = temporary_workspace_root("native-catalog-history");
26777 let outside_root = temporary_workspace_root("native-catalog-outside");
26778 std::fs::create_dir_all(&workspace_root).unwrap();
26779 std::fs::create_dir_all(&history_root).unwrap();
26780 std::fs::create_dir_all(&outside_root).unwrap();
26781 let transcript = |session_id: &str, cwd: &Path, title: &str| {
26782 [
26783 serde_json::json!({
26784 "type": "user",
26785 "sessionId": session_id,
26786 "cwd": cwd.to_str().unwrap(),
26787 "message": { "content": "raw user text must not cross the catalog wire" }
26788 }),
26789 serde_json::json!({
26790 "type": "assistant",
26791 "sessionId": session_id,
26792 "cwd": cwd.to_str().unwrap(),
26793 "message": { "model": "claude-sonnet", "content": "raw answer" }
26794 }),
26795 serde_json::json!({
26796 "type": "ai-title",
26797 "sessionId": session_id,
26798 "aiTitle": title
26799 }),
26800 ]
26801 .into_iter()
26802 .map(|record| record.to_string())
26803 .collect::<Vec<_>>()
26804 .join("\n")
26805 };
26806 std::fs::write(
26807 history_root.join("stable-session.jsonl"),
26808 transcript("stable-session", &workspace_root, "Workspace review"),
26809 )
26810 .unwrap();
26811 std::fs::write(
26812 history_root.join("outside-session.jsonl"),
26813 transcript("outside-session", &outside_root, "Outside review"),
26814 )
26815 .unwrap();
26816 let history = NativeHistoryConfig::new(vec![
26817 NativeHistoryRoot::new(
26818 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26819 HistorySourceLayout::SingleNdjson,
26820 history_root.clone(),
26821 )
26822 .unwrap(),
26823 ])
26824 .unwrap();
26825 let catalog = active_registry().unwrap();
26826 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26827 assert!(handle.snapshot().sessions.is_empty());
26828 let workspace_id = WorkspaceId::new("primary").unwrap();
26829 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26830 let mut shared = NodeShared::new(
26831 handle,
26832 "fixture-token".to_owned(),
26833 NodeId::new("node-native-catalog").unwrap(),
26834 vec![workspace],
26835 vec![agent("claude")],
26836 );
26837 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26838 NativeSessionCatalogAuthority::new(history),
26839 )));
26840 let route = NativeSessionCatalogRoute::workspace(
26841 workspace_id.clone(),
26842 agent("claude"),
26843 );
26844 let request = NodeRequest::CatalogNativeSessions {
26845 route: route.clone(),
26846 limit: 2,
26847 };
26848 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26849 .await
26850 .unwrap_err();
26851 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26852 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26853 .await
26854 .unwrap();
26855 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26856 panic!("native catalog returned a different response");
26857 };
26858 let summary = summary.expect("node must emit native session catalog summary metadata");
26859 assert_eq!(entries.len(), 1);
26860 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26861 assert_eq!(entries[0].external_group, None);
26862 assert_eq!(entries[0].record_id, None);
26863 assert_eq!(entries[0].title, None);
26864 assert_eq!(entries[0].model, None);
26865 assert_eq!(entries[0].message_count, 0);
26866 let encoded_entries = serde_json::to_string(&entries).unwrap();
26867 assert!(!encoded_entries.contains("stable-session"));
26868 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26869 let page_request = NodeRequest::PageNativeSessions {
26870 route: route.clone(),
26871 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26872 catalog_revision: summary.catalog_revision,
26873 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26874 after_selection_id: None,
26875 limit: 2,
26876 };
26877 let observer = process_request_inner(
26878 &shared,
26879 7,
26880 ClientRole::Observer,
26881 page_request.clone(),
26882 )
26883 .await
26884 .unwrap_err();
26885 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26886 let page_response = process_request_inner(
26887 &shared,
26888 7,
26889 ClientRole::Operator,
26890 page_request.clone(),
26891 )
26892 .await
26893 .unwrap();
26894 assert!(matches!(
26895 page_response,
26896 NodeResponse::NativeSessionsPaged { page, .. }
26897 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26898 ));
26899 let stale = process_request_inner(
26900 &shared,
26901 7,
26902 ClientRole::Operator,
26903 NodeRequest::PageNativeSessions {
26904 route: route.clone(),
26905 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26906 catalog_revision: summary.catalog_revision.wrapping_add(1),
26907 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26908 after_selection_id: None,
26909 limit: 2,
26910 },
26911 )
26912 .await
26913 .unwrap_err();
26914 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26915 let selection = NativeSessionSelection {
26916 route: route.clone(),
26917 catalog_revision: summary.catalog_revision,
26918 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26919 selection_id: entries[0].selection_id.clone(),
26920 };
26921 let preview_response = process_request_inner(
26922 &shared,
26923 7,
26924 ClientRole::Operator,
26925 NodeRequest::PreviewNativeSession {
26926 selection: selection.clone(),
26927 message_limit: 1,
26928 },
26929 )
26930 .await
26931 .unwrap();
26932 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26933 panic!("native preview returned a different response");
26934 };
26935 assert_eq!(preview.messages.len(), 1);
26936 assert!(preview.message_count_exact);
26937 assert_eq!(preview.messages[0].text, "raw answer");
26938 assert!(preview.truncated);
26939
26940 let index_request = NodeRequest::IndexNativeSession {
26941 selection: selection.clone(),
26942 display_name: "Saved Claude".to_owned(),
26943 };
26944 let observer = process_request_inner(
26945 &shared,
26946 7,
26947 ClientRole::Observer,
26948 index_request.clone(),
26949 )
26950 .await
26951 .unwrap_err();
26952 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26953 let without_controller = process_request_inner(
26954 &shared,
26955 7,
26956 ClientRole::Operator,
26957 index_request.clone(),
26958 )
26959 .await
26960 .unwrap_err();
26961 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26962 let acquired = process_request_inner(
26963 &shared,
26964 7,
26965 ClientRole::Operator,
26966 NodeRequest::AcquireController { lease_ms: 5_000 },
26967 )
26968 .await
26969 .unwrap();
26970 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26971 let indexed = process_request_inner(
26972 &shared,
26973 7,
26974 ClientRole::Operator,
26975 index_request,
26976 )
26977 .await
26978 .unwrap();
26979 assert!(indexed.native_session_index_contract_is_valid());
26980 let NodeResponse::NativeSessionIndexed {
26981 selection: echoed_selection,
26982 record,
26983 } = indexed else {
26984 panic!("native session index returned a different response");
26985 };
26986 assert_eq!(echoed_selection, selection);
26987 let recataloged = process_request_inner(
26988 &shared,
26989 7,
26990 ClientRole::Operator,
26991 NodeRequest::CatalogNativeSessions {
26992 route,
26993 limit: 2,
26994 },
26995 )
26996 .await
26997 .unwrap();
26998 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26999 panic!("native catalog returned a different response after indexing");
27000 };
27001 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27002
27003 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27004 let external_catalog = process_request_inner(
27005 &shared,
27006 7,
27007 ClientRole::Operator,
27008 NodeRequest::CatalogNativeSessions {
27009 route: unregistered_route.clone(),
27010 limit: 2,
27011 },
27012 )
27013 .await
27014 .unwrap();
27015 let external_json = serde_json::to_string(&external_catalog).unwrap();
27016 assert!(!external_json.contains("outside-session"));
27017 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27018 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27019 assert!(!external_json.contains("\"session_id\""));
27020 assert!(!external_json.contains("\"cwd\""));
27021 assert!(!external_json.contains("\"path\""));
27022 let NodeResponse::NativeSessionsCataloged {
27023 route: echoed_route,
27024 entries: external_entries,
27025 summary: Some(external_summary),
27026 } = external_catalog
27027 else {
27028 panic!("unregistered native catalog returned a different response");
27029 };
27030 assert_eq!(echoed_route, unregistered_route);
27031 assert_eq!(external_entries.len(), 1);
27032 assert_eq!(external_entries[0].record_id, None);
27033 let external_group = external_entries[0]
27034 .external_group
27035 .as_ref()
27036 .expect("unregistered session must have an external group");
27037 assert_eq!(
27038 external_group.kind,
27039 gate4agent_types::NativeSessionExternalGroupKind::Project,
27040 );
27041 assert!(external_group.group_id.starts_with("external-"));
27042 assert_eq!(
27043 external_group.display_name,
27044 outside_root.file_name().unwrap().to_string_lossy(),
27045 );
27046 let external_selection = NativeSessionSelection {
27047 route: unregistered_route,
27048 catalog_revision: external_summary.catalog_revision,
27049 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27050 selection_id: external_entries[0].selection_id.clone(),
27051 };
27052 let external_preview = process_request_inner(
27053 &shared,
27054 7,
27055 ClientRole::Operator,
27056 NodeRequest::PreviewNativeSession {
27057 selection: external_selection.clone(),
27058 message_limit: 1,
27059 },
27060 )
27061 .await
27062 .unwrap();
27063 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27064 assert!(!external_preview_json.contains("outside-session"));
27065 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27066 assert!(!external_preview_json.contains("\"session_id\""));
27067 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27068 panic!("unregistered native preview returned a different response");
27069 };
27070 assert_eq!(preview.messages.len(), 1);
27071 let external_index = process_request_inner(
27072 &shared,
27073 7,
27074 ClientRole::Operator,
27075 NodeRequest::IndexNativeSession {
27076 selection: external_selection,
27077 display_name: "External Claude".to_owned(),
27078 },
27079 )
27080 .await
27081 .unwrap_err();
27082 assert_eq!(
27083 external_index.code,
27084 NodeFailureCode::WorkspaceRegistrationRequired,
27085 );
27086 let record_response = process_request_inner(
27087 &shared,
27088 7,
27089 ClientRole::Operator,
27090 NodeRequest::PreviewSessionRecord {
27091 record_id: record.record_id.clone(),
27092 message_limit: 2,
27093 },
27094 )
27095 .await
27096 .unwrap();
27097 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27098 panic!("record preview returned a different response");
27099 };
27100 assert_eq!(record_id, record.record_id);
27101 assert_eq!(preview.messages.len(), 2);
27102 let encoded = serde_json::to_string(&preview).unwrap();
27103 assert!(!encoded.contains("stable-session"));
27104 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27105 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27106 panic!("record preview history summary returned a different response");
27107 };
27108 let summaries = events
27109 .iter()
27110 .filter_map(|envelope| match &envelope.event {
27111 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27112 if record_id == &record.record_id =>
27113 {
27114 Some(*summary)
27115 }
27116 _ => None,
27117 })
27118 .collect::<Vec<_>>();
27119 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27120 let summary_json = serde_json::to_string(&summaries).unwrap();
27121 for private in [
27122 "stable-session",
27123 "raw user text must not cross the catalog wire",
27124 "raw answer",
27125 "Workspace review",
27126 "claude-sonnet",
27127 ] {
27128 assert!(!summary_json.contains(private), "{private}");
27129 }
27130 let identity = record.provider_session.clone().unwrap();
27131 assert_eq!(
27132 shared.revalidate_session_record_preview(&record, &identity),
27133 Ok(())
27134 );
27135 shared.remove_record_memory(&record.record_id);
27136 assert_eq!(
27137 shared
27138 .revalidate_session_record_preview(&record, &identity)
27139 .unwrap_err()
27140 .code,
27141 NodeFailureCode::UnknownSessionRecord
27142 );
27143 let released = process_request_inner(
27144 &shared,
27145 7,
27146 ClientRole::Operator,
27147 NodeRequest::ReleaseController,
27148 )
27149 .await
27150 .unwrap();
27151 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27152 assert!(shared.controller_state().is_none());
27153 std::fs::remove_dir_all(workspace_root).unwrap();
27154 std::fs::remove_dir_all(history_root).unwrap();
27155 std::fs::remove_dir_all(outside_root).unwrap();
27156 }
27157
27158 #[tokio::test]
27159 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27160 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27161 std::fs::create_dir_all(&history_root).unwrap();
27162 let history = NativeHistoryConfig::new(vec![
27163 NativeHistoryRoot::new(
27164 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27165 HistorySourceLayout::SingleNdjson,
27166 history_root.clone(),
27167 )
27168 .unwrap(),
27169 ])
27170 .unwrap();
27171 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27172 let held = catalog.lock().unwrap();
27173 let result = timeout(
27174 Duration::from_secs(1),
27175 run_native_session_catalog_operation(
27176 Arc::clone(&catalog),
27177 "native session catalog",
27178 |_| Ok::<(), std::convert::Infallible>(()),
27179 ),
27180 )
27181 .await
27182 .expect("concurrent native catalog request must not wait for the operation deadline")
27183 .unwrap_err();
27184 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27185 drop(held);
27186 std::fs::remove_dir_all(history_root).unwrap();
27187 }
27188
27189 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27190 let unique = std::time::SystemTime::now()
27191 .duration_since(std::time::UNIX_EPOCH)
27192 .unwrap()
27193 .as_nanos();
27194 std::env::temp_dir().join(format!(
27195 "gate4agent-node-{label}-{}-{unique}",
27196 std::process::id(),
27197 ))
27198 }
27199
27200 fn git_snapshot_for_parser() -> GitSnapshot {
27201 GitSnapshot {
27202 is_repository: true,
27203 branch: Some("main".to_owned()),
27204 status: Vec::new(),
27205 recent_commits: Vec::new(),
27206 worktrees: Vec::new(),
27207 managed_worktree: None,
27208 truncated: false,
27209 diagnostic: None,
27210 }
27211 }
27212
27213 #[tokio::test]
27214 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27215 let root = temporary_workspace_root("managed-git-scope");
27216 std::fs::create_dir_all(&root).unwrap();
27217 run_test_git(&root, &["init"]);
27218 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27219 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27220 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27221 run_test_git(&root, &["add", "seed.txt"]);
27222 run_test_git(&root, &["commit", "-m", "seed"]);
27223 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27224
27225 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27226 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27227 let catalog = active_registry().unwrap();
27228 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27229 let shared = NodeShared::new(
27230 handle,
27231 "fixture-token".to_owned(),
27232 NodeId::new("node-1").unwrap(),
27233 vec![workspace],
27234 vec![agent("claude")],
27235 );
27236 let mut lease = ManagedWorktreeLeaseRecord {
27237 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27238 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27239 workspace_id: workspace_id.clone(),
27240 profile_id: WorktreeProfileId::new("default").unwrap(),
27241 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27242 target_root: root.to_string_lossy().into_owned(),
27243 branch: "gate4agent/a".to_owned(),
27244 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27245 expected_head: None,
27246 retention: ManagedWorktreeRetention::Retain,
27247 state: ManagedWorktreeLeaseState::InUse,
27248 session_holders: vec![ManagedWorktreeSessionHolder {
27249 incarnation_id: shared.incarnation_id,
27250 instance_id: AgentInstanceId(7),
27251 generation: SessionGeneration(1),
27252 }],
27253 record_holders: Vec::new(),
27254 cleanup_failure: None,
27255 created_at_unix_ms: 1,
27256 updated_at_unix_ms: 1,
27257 };
27258 *shared
27259 .managed_worktrees
27260 .lock()
27261 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27262 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27263
27264 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27265 let scope = inspection.git.managed_worktree.unwrap();
27266 assert_eq!(scope.lease_id, lease.lease_id);
27267 assert_eq!(scope.branch, "gate4agent/a");
27268 assert_eq!(scope.active_session_count, 1);
27269
27270 lease.branch = "gate4agent/b".to_owned();
27271 *shared
27272 .managed_worktrees
27273 .lock()
27274 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27275 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27276 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27277 assert!(mismatch.git.managed_worktree.is_none());
27278 std::fs::remove_dir_all(root).unwrap();
27279 }
27280
27281 #[tokio::test]
27282 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27283 let root = temporary_workspace_root("workspace-file-write-controller");
27284 std::fs::create_dir_all(root.join("src")).unwrap();
27285 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27286 let workspace_id = WorkspaceId::new("primary").unwrap();
27287 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27288 let catalog = active_registry().unwrap();
27289 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27290 let shared = NodeShared::new(
27291 handle,
27292 "fixture-token".to_owned(),
27293 NodeId::new("node-1").unwrap(),
27294 vec![workspace],
27295 vec![agent("claude")],
27296 );
27297 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27298 let expected_revision = workspace_file_revision(b"old\n");
27299 let request = NodeRequest::WriteWorkspaceFile {
27300 workspace_id: workspace_id.clone(),
27301 path: path.clone(),
27302 expected_revision: expected_revision.clone(),
27303 text: "new\n".to_owned(),
27304 };
27305 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27306 .await
27307 .unwrap_err();
27308 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27309 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27310 .await
27311 .unwrap_err();
27312 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27313 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27314 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27315 let NodeResponse::WorkspaceFileWritten { file } = response else {
27316 panic!("workspace file write returned another response");
27317 };
27318 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27319 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27320 let stale = process_request_inner(
27321 &shared,
27322 77,
27323 ClientRole::Operator,
27324 NodeRequest::WriteWorkspaceFile {
27325 workspace_id,
27326 path,
27327 expected_revision,
27328 text: "lost update\n".to_owned(),
27329 },
27330 )
27331 .await
27332 .unwrap_err();
27333 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27334 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27335 std::fs::remove_dir_all(root).unwrap();
27336 }
27337
27338 #[tokio::test]
27339 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27340 let root = temporary_workspace_root("workspace-entry-create-controller");
27341 std::fs::create_dir_all(root.join("src")).unwrap();
27342 let workspace_id = WorkspaceId::new("primary").unwrap();
27343 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27344 let catalog = active_registry().unwrap();
27345 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27346 let shared = NodeShared::new(
27347 handle,
27348 "fixture-token".to_owned(),
27349 NodeId::new("node-1").unwrap(),
27350 vec![workspace],
27351 vec![agent("claude")],
27352 );
27353 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27354 let request = NodeRequest::CreateWorkspaceFile {
27355 workspace_id: workspace_id.clone(),
27356 path: path.clone(),
27357 };
27358 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27359 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27360 }));
27361 assert!(!request_uses_unnegotiated_capability(
27362 &request,
27363 &baseline_capabilities().unwrap(),
27364 ));
27365 assert!(request_uses_unnegotiated_capability(&request, &[]));
27366 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27367 .await
27368 .unwrap_err();
27369 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27370 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27371 .await
27372 .unwrap_err();
27373 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27374 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27375
27376 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27377 .await
27378 .unwrap();
27379 let NodeResponse::WorkspaceFileCreated { file } = response else {
27380 panic!("workspace file create returned another response");
27381 };
27382 assert_eq!(file.workspace_id, workspace_id);
27383 assert_eq!(file.path, path);
27384 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27385 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27386
27387 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27388 .await
27389 .unwrap_err();
27390 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27391
27392 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27393 let response = process_request_inner(
27394 &shared,
27395 77,
27396 ClientRole::Operator,
27397 NodeRequest::CreateWorkspaceDirectory {
27398 workspace_id: workspace_id.clone(),
27399 path: directory_path.clone(),
27400 },
27401 )
27402 .await
27403 .unwrap();
27404 assert_eq!(
27405 response,
27406 NodeResponse::WorkspaceDirectoryCreated {
27407 workspace_id: workspace_id.clone(),
27408 entry: WorkspaceEntry {
27409 relative_path: directory_path,
27410 kind: WorkspaceEntryKind::Directory,
27411 },
27412 },
27413 );
27414 assert!(root.join("src/nested").is_dir());
27415
27416 let missing_parent = process_request_inner(
27417 &shared,
27418 77,
27419 ClientRole::Operator,
27420 NodeRequest::CreateWorkspaceFile {
27421 workspace_id: workspace_id.clone(),
27422 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27423 },
27424 )
27425 .await
27426 .unwrap_err();
27427 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27428 let non_directory_parent = process_request_inner(
27429 &shared,
27430 77,
27431 ClientRole::Operator,
27432 NodeRequest::CreateWorkspaceFile {
27433 workspace_id,
27434 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27435 },
27436 )
27437 .await
27438 .unwrap_err();
27439 assert_eq!(
27440 non_directory_parent.code,
27441 NodeFailureCode::RepositoryParentNotDirectory,
27442 );
27443 std::fs::remove_dir_all(root).unwrap();
27444 }
27445
27446 #[tokio::test]
27447 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27448 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27449 std::fs::create_dir_all(root.join("src")).unwrap();
27450 #[cfg(windows)]
27451 let root = std::fs::canonicalize(root).unwrap();
27452 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27453 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27454 let barrier = Arc::new(std::sync::Barrier::new(2));
27455 let done = Arc::new(AtomicBool::new(false));
27456 let task_commit_state = Arc::clone(&commit_state);
27457 let task_barrier = Arc::clone(&barrier);
27458 let task_done = Arc::clone(&done);
27459 let task_root = root.clone();
27460 let task = tokio::task::spawn_blocking(move || {
27461 task_barrier.wait();
27462 let result = create_workspace_file_on_disk(
27463 &task_root,
27464 &path,
27465 &task_commit_state,
27466 );
27467 task_done.store(true, Ordering::Release);
27468 result
27469 });
27470
27471 let error = settle_workspace_entry_create(
27472 task,
27473 Arc::clone(&commit_state),
27474 Duration::from_millis(10),
27475 "fixture deadline",
27476 "fixture task failed",
27477 )
27478 .await
27479 .unwrap_err();
27480 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27481 assert_eq!(
27482 commit_state.load(Ordering::Acquire),
27483 WORKSPACE_ENTRY_CREATE_CANCELED,
27484 );
27485
27486 barrier.wait();
27487 timeout(Duration::from_secs(1), async {
27488 while !done.load(Ordering::Acquire) {
27489 tokio::task::yield_now().await;
27490 }
27491 })
27492 .await
27493 .expect("canceled blocking create did not settle");
27494 assert!(!root.join("src/late.rs").exists());
27495 std::fs::remove_dir_all(root).unwrap();
27496 }
27497
27498}