1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219}
220
221fn spawn_selection(
223 options: &gate4agent_node_protocol::SpawnSessionOptions,
224) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
225 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
226 match (&options.model, &options.effort) {
227 (None, None) => Ok(None),
228 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
229 (Some(model), effort) => {
230 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
231 if let Some(e) = effort {
232 sel = sel.with_value("effort", e.clone());
233 }
234 Ok(Some(sel))
235 }
236 }
237}
238
239mod browser_bridge;
240
241use gate4agent_node_core::drive_loop_profile;
242
243#[path = "bundle_delivery.rs"]
244mod bundle_delivery;
245
246#[path = "context_pack_store.rs"]
247mod context_pack_store;
248
249#[cfg(windows)]
250pub use crate::platform::DEFAULT_NODE_ENDPOINT;
251
252const NODE_EVENT_HISTORY_MAX: usize = 4_096;
253const NODE_BROADCAST_CAPACITY: usize = 1_024;
254const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
255const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
261const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
268const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
269const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
270const MAX_PREAUTH_CONNECTIONS: usize = 32;
271const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
272const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
273const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
274const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
275const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
276const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
282const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
290const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
298const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
305const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
310const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
311const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
312const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
313const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
314const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
315const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
316const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
317const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
318const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
319const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
320const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
321const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
322const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
325const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
332const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
335const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
336const GIT_STATUS_MAX_ENTRIES: usize = 128;
337const GIT_COMMIT_MAX_ENTRIES: usize = 12;
338const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
339const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
340const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
341const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
342const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
343const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
344const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
345const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
346const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
347const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
348 "provider-session-identity-allocation-failed";
349const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
350const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
351const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
352const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
353const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
354const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
355const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
356 "durable-state-path-semantics-unsupported";
357
358fn opaque_windows_path(value: String) -> OpaqueHostPath {
359 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
360}
361
362fn agent_progress_from_provider_snapshot(
363 address: SessionAddress,
364 provider: &ProviderSnapshot,
365) -> Option<SessionAgentProgress> {
366 let mut truncated = false;
367 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
368 let mut active_tool_labels = Vec::with_capacity(
369 provider
370 .active_tools
371 .len()
372 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
373 );
374 for tool in &provider.active_tools {
375 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
376 truncated = true;
377 break;
378 }
379 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
380 active_tool_labels.push(label);
381 }
382 }
383 let attention = provider
384 .interactions
385 .iter()
386 .find(|interaction| {
387 matches!(
388 interaction.status,
389 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
390 )
391 })
392 .map(|interaction| AgentProgressAttentionV1 {
393 kind: match interaction.interaction_kind {
394 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
395 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
396 },
397 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
398 });
399 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
400 let usage = (provider.completed_turns > 0
401 || provider.usage.input_tokens > 0
402 || provider.usage.output_tokens > 0
403 || provider.usage.cache_read_tokens > 0
404 || provider.usage.cache_write_tokens > 0
405 || provider.usage.reasoning_tokens > 0)
406 .then_some(AgentProgressUsageV1 {
407 input_tokens: provider.usage.input_tokens,
408 output_tokens: provider.usage.output_tokens,
409 cache_read_tokens: provider.usage.cache_read_tokens,
410 cache_write_tokens: provider.usage.cache_write_tokens,
411 reasoning_tokens: provider.usage.reasoning_tokens,
412 });
413 let progress = AgentProgressV1 {
414 provider_sequence: provider.sequence,
415 activity: provider.activity,
416 completed_turns: provider.completed_turns,
417 usage,
418 current: AgentProgressCurrentV1::from(provider.activity),
419 active_tool_labels,
420 active_tool_count,
421 attention,
422 subagent_count,
423 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
424 gap_count: provider.gap_count,
425 stale: provider.stale,
426 truncated,
427 };
428 let entry = SessionAgentProgress { address, progress };
429 serde_json::to_vec(&entry)
430 .ok()
431 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
432 .map(|_| entry)
433}
434
435fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
436 match u32::try_from(count) {
437 Ok(count) => count,
438 Err(_) => {
439 *truncated = true;
440 u32::MAX
441 }
442 }
443}
444
445fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
446 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
447 return None;
448 }
449 Some(match event {
450 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
451 ProviderEvent::SessionIdentityObserved { .. } => {
452 AgentProgressEventKindV1::SessionIdentityObserved
453 }
454 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
455 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
456 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
457 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
458 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
459 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
460 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
461 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
462 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
463 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
464 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
465 ProviderEvent::InteractionRequested { .. } => {
466 AgentProgressEventKindV1::InteractionRequested
467 }
468 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
469 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
470 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
471 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
472 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
473 ProviderEvent::UnrecognizedNotification { .. } => {
474 AgentProgressEventKindV1::UnrecognizedNotification
475 }
476 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
477 ProviderEvent::UserMessage { .. }
488 | ProviderEvent::Plan { .. }
489 | ProviderEvent::AvailableCommandsUpdated { .. }
490 | ProviderEvent::ModeChanged { .. }
491 | ProviderEvent::SessionInfoUpdated { .. }
492 | ProviderEvent::UsageUpdated { .. }
493 | ProviderEvent::ConfigOptionsUpdated { .. } => {
494 AgentProgressEventKindV1::UnrecognizedNotification
495 }
496 })
497}
498
499fn provider_family_is_live(family: AdapterFamily) -> bool {
512 match family {
513 AdapterFamily::PtySemantic
514 | AdapterFamily::Hook
515 | AdapterFamily::ManagedHook
516 | AdapterFamily::Pipe
517 | AdapterFamily::OneShot
518 | AdapterFamily::Acp => true,
519 AdapterFamily::History
520 | AdapterFamily::Resume
521 | AdapterFamily::SessionOptions
522 | AdapterFamily::CapabilityProbe => false,
523 }
524}
525
526fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
527 match by {
528 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
529 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
530 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
531 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
532 }
533}
534
535fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
547 match authority {
548 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
549 BlockAuthorityV1::HostPolicy => "denied by host policy",
550 BlockAuthorityV1::HostDeadline => {
551 "denied by host policy after the operator deadline expired"
552 }
553 BlockAuthorityV1::Operator => "declined by the operator",
554 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
555 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
556 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
557 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
558 BlockAuthorityV1::UserRejected => "rejected by the user",
559 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
560 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
561 }
562}
563
564fn host_request_denied_block(
567 method: &str,
568 by: ProviderHostDecisionAuthority,
569 reason: Option<&str>,
570) -> (String, BlockAuthorityV1, String) {
571 let authority = block_authority(by);
572 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
573 let (reason_text, _) =
577 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
578 (tool_class_label(method), authority, reason_text)
579}
580
581fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
600 match kind? {
601 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
602 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
603 _ => None,
604 }
605}
606
607fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
619 let trimmed = text.trim();
620 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
621 return (trimmed.to_owned(), None);
622 };
623 let tail = after_reason.trim_start();
624 let Some((reason, help)) = tail.split_once(". ") else {
625 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
629 return (reason.to_owned(), None);
630 };
631 let help = help.trim();
632 if help.is_empty() {
633 (reason.trim().to_owned(), None)
634 } else {
635 (reason.trim().to_owned(), Some(help.to_owned()))
636 }
637}
638
639fn tool_class_from_denial_help(help: Option<&str>) -> String {
649 help.and_then(|text| text.split("add a ").nth(1))
650 .and_then(|tail| tail.split(' ').next())
651 .filter(|word| !word.is_empty())
652 .map(str::to_owned)
653 .unwrap_or_else(|| "Tool".to_owned())
654}
655
656fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
664 fn names_quota(s: &str) -> bool {
665 let lower = s.to_ascii_lowercase();
666 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
667 }
668 vendor_code.is_some_and(names_quota) || names_quota(message)
669}
670
671fn provider_session_end_block(
687 stop_reason: &ProviderStopReason,
688) -> Option<(BlockAuthorityV1, String, String)> {
689 match stop_reason {
690 ProviderStopReason::Refusal => Some((
691 BlockAuthorityV1::ProviderRefusal,
692 "refusal".to_owned(),
693 "the provider ended the turn with stopReason: refusal".to_owned(),
694 )),
695 ProviderStopReason::ProviderError { message, vendor_code, .. }
696 if provider_error_is_quota(vendor_code.as_deref(), message) =>
697 {
698 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
699 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
700 }
701 _ => None,
702 }
703}
704
705fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
746 let ControlEventKind::ProviderEvent {
747 sequence: provider_sequence,
748 source,
749 source_sequence,
750 event: provider_event,
751 } = &event.event
752 else {
753 return None;
754 };
755 if !provider_family_is_live(source.family) {
756 return None;
757 }
758 let kind = match provider_event {
759 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
760 text: text.clone(),
761 is_delta: *is_delta,
762 },
763 ProviderEvent::Thinking { text } => {
764 AgentStreamChunkKindV1::Thinking { text: text.clone() }
765 }
766 ProviderEvent::InteractionRequested {
767 interaction_kind,
768 tool_name,
769 title,
770 prompt,
771 options,
772 ..
773 } => AgentStreamChunkKindV1::InteractionPrompt {
774 correlation_id: interaction_correlation(
775 event.instance_id,
776 event.generation,
777 *provider_sequence,
778 ),
779 interaction_kind: *interaction_kind,
780 tool_name: tool_name.clone(),
781 title: title.clone(),
790 prompt: prompt.clone(),
791 options: options
792 .iter()
793 .map(|option| AgentStreamInteractionOptionV1 {
794 option_id: option.option_id.clone(),
795 name: option.name.clone(),
796 kind: option.kind.clone(),
797 })
798 .collect(),
799 },
800 ProviderEvent::ConfigOptionsUpdated { options } => {
801 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
802 }
803 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
814 current: Some(mode_id.clone()),
815 available: available
816 .iter()
817 .map(|mode| AgentStreamNamedIdV1 {
818 id: mode.id.clone(),
819 name: mode.name.clone(),
820 description: mode.description.clone(),
821 })
822 .collect(),
823 },
824 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
828 let ProviderHostRequestDecision::Denied { by } = decision else {
829 return None;
830 };
831 let (tool_class, authority, reason_text) =
832 host_request_denied_block(method, *by, reason.as_deref());
833 AgentStreamChunkKindV1::Blocked {
834 correlation_id: None,
835 tool_class,
836 authority,
837 reason_kind: None,
838 reason: reason_text,
839 help: None,
840 }
841 }
842 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
846 let (authority, reason_kind) =
847 provider_tool_block_authority(non_execution_kind.as_deref())?;
848 let (reason, help) = split_provider_denial_text(output);
849 let (reason, _) =
850 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
851 let help = help.map(|help| {
852 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
853 });
854 AgentStreamChunkKindV1::Blocked {
855 correlation_id: Some(tool_correlation(event, source, id)),
856 tool_class: tool_class_from_denial_help(help.as_deref()),
857 authority,
858 reason_kind: Some(reason_kind.to_owned()),
859 reason,
860 help,
861 }
862 }
863 ProviderEvent::SessionEnded { stop_reason, .. } => {
867 let (authority, reason_kind, reason) =
868 stop_reason.as_ref().and_then(provider_session_end_block)?;
869 let (reason, _) =
870 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
871 AgentStreamChunkKindV1::Blocked {
872 correlation_id: None,
873 tool_class: "Session".to_owned(),
874 authority,
875 reason_kind: Some(reason_kind),
876 reason,
877 help: None,
878 }
879 }
880 ProviderEvent::SessionStarted { .. }
881 | ProviderEvent::SessionIdentityObserved { .. }
882 | ProviderEvent::TurnStarted { .. }
883 | ProviderEvent::WorkingObserved
884 | ProviderEvent::ToolStarted { .. }
885 | ProviderEvent::TurnCompleted { .. }
886 | ProviderEvent::ContextWindowUsage { .. }
887 | ProviderEvent::TurnInterrupted
888 | ProviderEvent::Error { .. }
889 | ProviderEvent::Ready
890 | ProviderEvent::InteractionResolved { .. }
891 | ProviderEvent::SubagentStarted { .. }
892 | ProviderEvent::SubagentStopped { .. }
893 | ProviderEvent::RateLimited { .. }
894 | ProviderEvent::UnrecognizedNotification { .. }
895 | ProviderEvent::UserMessage { .. }
896 | ProviderEvent::Plan { .. }
897 | ProviderEvent::AvailableCommandsUpdated { .. }
898 | ProviderEvent::SessionInfoUpdated { .. }
899 | ProviderEvent::UsageUpdated { .. } => return None,
900 };
901 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
902}
903
904async fn run_native_session_catalog_operation<T, E, F>(
905 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
906 operation: &'static str,
907 execute: F,
908) -> Result<T, NodeFailure>
909where
910 T: Send + 'static,
911 E: std::fmt::Display + Send + 'static,
912 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
913{
914 let task = tokio::task::spawn_blocking(move || {
915 let mut catalog = match catalog.try_lock() {
916 Ok(catalog) => catalog,
917 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
918 Err(std::sync::TryLockError::WouldBlock) => {
919 return Err(failure(
920 NodeFailureCode::BackendBusy,
921 "native session catalog operation is already running",
922 ));
923 }
924 };
925 execute(&mut catalog).map_err(|error| {
926 failure(
927 NodeFailureCode::BackendOperationFailed,
928 &format!("{operation} failed: {error}"),
929 )
930 })
931 });
932 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
933 .await
934 .map_err(|_| {
935 failure(
936 NodeFailureCode::BackendBusy,
937 &format!("{operation} exceeded its bounded deadline"),
938 )
939 })?
940 .map_err(|_| {
941 failure(
942 NodeFailureCode::BackendOperationFailed,
943 &format!("{operation} task failed"),
944 )
945 })?
946}
947
948fn windows_path_text(path: &OpaqueHostPath) -> &str {
949 path.as_utf8()
950 .expect("Windows node only creates UTF-8 opaque host paths")
951}
952
953fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
954 path.as_utf8()
955 .map(str::to_owned)
956 .ok_or_else(|| failure(
957 NodeFailureCode::InvalidRequest,
958 "this node requires UTF-8 host paths",
959 ))
960}
961
962fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
963 GitWorktreeSnapshot {
964 path: opaque_windows_path(worktree.path),
965 head: worktree.head,
966 branch: worktree.branch,
967 is_bare: worktree.is_bare,
968 is_main: worktree.is_main,
969 locked: worktree.locked,
970 lock_reason: worktree.lock_reason,
971 prunable: worktree.prunable,
972 prunable_reason: worktree.prunable_reason,
973 workspace_id: worktree.workspace_id.take(),
974 }
975}
976
977#[derive(Clone, Debug, Eq, PartialEq)]
978pub struct WorkspaceConfig {
979 workspace_id: WorkspaceId,
980 canonical_root: String,
981 worktree_service_mode: WorktreeServiceMode,
982 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
983}
984
985impl WorkspaceConfig {
986 pub fn new(
987 workspace_id: WorkspaceId,
988 root: impl AsRef<Path>,
989 ) -> Result<Self, NodeServerError> {
990 let root = root.as_ref();
991 if !root.is_absolute() {
992 return Err(NodeServerError::InvalidWorkspaceRoot {
993 workspace_id,
994 path: root.to_string_lossy().into_owned(),
995 message: "path is not absolute".to_owned(),
996 });
997 }
998 let canonical = std::fs::canonicalize(root).map_err(|error| {
999 NodeServerError::InvalidWorkspaceRoot {
1000 workspace_id: workspace_id.clone(),
1001 path: root.to_string_lossy().into_owned(),
1002 message: error.to_string(),
1003 }
1004 })?;
1005 if !canonical.is_dir() {
1006 return Err(NodeServerError::InvalidWorkspaceRoot {
1007 workspace_id,
1008 path: root.to_string_lossy().into_owned(),
1009 message: "path is not a directory".to_owned(),
1010 });
1011 }
1012 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1013 NodeServerError::InvalidWorkspaceRoot {
1014 workspace_id: workspace_id.clone(),
1015 path: path.to_string_lossy().into_owned(),
1016 message: "canonical path is not valid Unicode".to_owned(),
1017 }
1018 })?;
1019 let canonical_root = platform::normalize_canonical_root(canonical_root);
1020 validate_workspace_root(&workspace_id, &canonical_root)?;
1021 Ok(Self {
1022 workspace_id,
1023 canonical_root,
1024 worktree_service_mode: WorktreeServiceMode::Manual,
1025 managed_worktree_profiles: BTreeMap::new(),
1026 })
1027 }
1028
1029 pub fn workspace_id(&self) -> &WorkspaceId {
1030 &self.workspace_id
1031 }
1032
1033 pub fn canonical_root(&self) -> &str {
1034 &self.canonical_root
1035 }
1036
1037 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1038 self.worktree_service_mode = mode;
1039 self
1040 }
1041
1042 pub fn with_managed_worktree_profile(
1043 mut self,
1044 profile: ManagedWorktreeProfile,
1045 ) -> Result<Self, NodeServerError> {
1046 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1047 NodeServerError::InvalidManagedWorktreeProfile {
1048 workspace_id: self.workspace_id.clone(),
1049 profile_id: profile.profile_id().clone(),
1050 message,
1051 }
1052 })?;
1053 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1054 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1055 workspace_id: self.workspace_id,
1056 profile_id: profile.profile_id().clone(),
1057 });
1058 }
1059 if self.managed_worktree_profiles.len()
1060 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1061 {
1062 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1063 workspace_id: self.workspace_id,
1064 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1065 });
1066 }
1067 self.managed_worktree_profiles
1068 .insert(profile.profile_id().clone(), profile);
1069 Ok(self)
1070 }
1071}
1072
1073#[derive(Clone)]
1074pub struct NodeServerConfig {
1075 pub endpoint: String,
1076 api_listen: Option<std::net::SocketAddr>,
1077 bridge_listen: Option<std::net::SocketAddr>,
1080 bridge_token: Option<String>,
1083 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1086 call_home: Option<std::net::SocketAddr>,
1089 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1093 pub node_id: NodeId,
1094 pub workspaces: Vec<WorkspaceConfig>,
1095 access_token: String,
1096 pub runtime: NativeRuntimeConfig,
1097 session_record_retention: SessionRecordRetentionConfig,
1098 state_path: Option<PathBuf>,
1099 spawn_profiles: SpawnProfileRegistry,
1100 session_environment: Option<NodeSessionEnvironmentConfig>,
1101 history: Option<NativeHistoryConfig>,
1102 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1103 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1107 #[cfg(feature = "fixture")]
1108 fixture_raw_pty_runtime: bool,
1109}
1110
1111#[derive(Clone)]
1112struct NodeSessionEnvironmentConfig {
1113 root: PathBuf,
1114 resolver: Arc<dyn NodeSecretResolver>,
1115}
1116
1117impl NodeServerConfig {
1118 pub fn new(
1119 endpoint: impl Into<String>,
1120 access_token: impl Into<String>,
1121 node_id: NodeId,
1122 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1123 ) -> Result<Self, NodeServerError> {
1124 let endpoint = endpoint.into();
1125 let access_token = access_token.into();
1126 if !platform::validate_endpoint(&endpoint) {
1127 return Err(NodeServerError::InvalidEndpoint);
1128 }
1129 if access_token.is_empty() || access_token.len() > 4_096 {
1130 return Err(NodeServerError::InvalidAccessToken);
1131 }
1132 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1133 if workspaces.is_empty() {
1134 return Err(NodeServerError::NoWorkspaces);
1135 }
1136 let mut workspace_ids = BTreeMap::new();
1137 let mut workspace_roots = BTreeMap::new();
1138 for workspace in &workspaces {
1139 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1140 && workspace.managed_worktree_profiles.is_empty()
1141 {
1142 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1143 workspace.workspace_id.clone(),
1144 ));
1145 }
1146 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1147 && !workspace.managed_worktree_profiles.is_empty()
1148 {
1149 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1150 workspace.workspace_id.clone(),
1151 ));
1152 }
1153 if workspace_ids
1154 .insert(workspace.workspace_id.clone(), ())
1155 .is_some()
1156 {
1157 return Err(NodeServerError::DuplicateWorkspaceId(
1158 workspace.workspace_id.clone(),
1159 ));
1160 }
1161 let root_key = platform::root_identity(&workspace.canonical_root);
1162 if let Some(existing) = workspace_roots.insert(
1163 root_key,
1164 workspace.workspace_id.clone(),
1165 ) {
1166 return Err(NodeServerError::DuplicateWorkspaceRoot {
1167 first: existing,
1168 second: workspace.workspace_id.clone(),
1169 root: workspace.canonical_root.clone(),
1170 });
1171 }
1172 }
1173 Ok(Self {
1174 endpoint,
1175 api_listen: None,
1176 bridge_listen: None,
1177 bridge_token: None,
1178 bridge_underlay: None,
1179 call_home: None,
1180 call_home_identity: None,
1181 node_id,
1182 workspaces,
1183 access_token,
1184 runtime: NativeRuntimeConfig {
1187 worker_poll_interval_ms: 4,
1188 ..NativeRuntimeConfig::default()
1189 },
1190 session_record_retention: SessionRecordRetentionConfig::default(),
1191 state_path: None,
1192 spawn_profiles: SpawnProfileRegistry::default(),
1193 session_environment: None,
1194 history: None,
1195 harness_mcp_helper: None,
1196 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1197 #[cfg(feature = "fixture")]
1198 fixture_raw_pty_runtime: false,
1199 })
1200 }
1201
1202 pub fn with_api_listen(
1203 mut self,
1204 api_listen: std::net::SocketAddr,
1205 ) -> Result<Self, NodeServerError> {
1206 if !api_listen.ip().is_loopback() {
1207 return Err(NodeServerError::InvalidApiListen(api_listen));
1208 }
1209 self.api_listen = Some(api_listen);
1210 Ok(self)
1211 }
1212
1213 pub fn with_bridge_listen(
1217 mut self,
1218 bridge_listen: std::net::SocketAddr,
1219 ) -> Result<Self, NodeServerError> {
1220 if !bridge_listen.ip().is_loopback() {
1221 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1222 }
1223 self.bridge_listen = Some(bridge_listen);
1224 Ok(self)
1225 }
1226
1227 pub fn with_bridge_token(
1230 mut self,
1231 bridge_token: impl Into<String>,
1232 ) -> Result<Self, NodeServerError> {
1233 let bridge_token = bridge_token.into();
1234 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1235 return Err(NodeServerError::InvalidBridgeToken);
1236 }
1237 self.bridge_token = Some(bridge_token);
1238 Ok(self)
1239 }
1240
1241 pub fn with_bridge_underlay(
1246 mut self,
1247 bind: std::net::SocketAddr,
1248 transport_key: [u8; 32],
1249 underlay_token: impl Into<String>,
1250 ) -> Result<Self, NodeServerError> {
1251 if self.bridge_listen.is_none() {
1252 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1253 }
1254 if self.bridge_token.is_none() {
1255 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1256 }
1257 let underlay_token = underlay_token.into();
1258 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1259 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1260 }
1261 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1263 bind,
1264 transport_key,
1265 underlay_token,
1266 });
1267 Ok(self)
1268 }
1269
1270 pub fn with_call_home(
1291 mut self,
1292 relay: std::net::SocketAddr,
1293 ) -> Result<Self, NodeServerError> {
1294 if relay.port() == 0 || relay.ip().is_unspecified() {
1297 return Err(NodeServerError::InvalidCallHome(relay));
1298 }
1299 self.call_home = Some(relay);
1300 Ok(self)
1301 }
1302
1303 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1305 self.call_home_identity = Some(identity);
1306 self
1307 }
1308
1309 pub fn with_state_path(
1310 mut self,
1311 state_path: impl Into<PathBuf>,
1312 ) -> Result<Self, NodeServerError> {
1313 let state_path = state_path.into();
1314 if !state_path.is_absolute() || state_path.file_name().is_none() {
1315 return Err(NodeServerError::InvalidStatePath(
1316 state_path.to_string_lossy().into_owned(),
1317 ));
1318 }
1319 self.state_path = Some(state_path);
1320 Ok(self)
1321 }
1322
1323 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1324 self.spawn_profiles = spawn_profiles;
1325 self
1326 }
1327
1328 pub fn with_session_record_retention(
1334 mut self,
1335 retention: SessionRecordRetentionConfig,
1336 ) -> Self {
1337 self.session_record_retention = retention;
1338 self
1339 }
1340
1341 pub fn with_session_environment_materialization(
1342 mut self,
1343 root: impl Into<PathBuf>,
1344 resolver: Arc<dyn NodeSecretResolver>,
1345 ) -> Result<Self, NodeServerError> {
1346 let root = root.into();
1347 if !root.is_absolute() {
1348 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1349 }
1350 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1351 Ok(self)
1352 }
1353
1354 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1357 self.history = Some(history);
1358 self
1359 }
1360
1361 pub fn with_harness_mcp_helper(
1367 mut self,
1368 helper_program: impl Into<PathBuf>,
1369 ) -> Result<Self, NodeServerError> {
1370 self.harness_mcp_helper = Some(
1371 ReviewedHarnessMcpProgram::review(helper_program.into())
1372 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1373 );
1374 Ok(self)
1375 }
1376
1377 pub fn with_network_allowlist_catalog(
1382 mut self,
1383 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1384 ) -> Result<Self, NodeServerError> {
1385 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1386 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1387 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1388 });
1389 }
1390 self.network_allowlist_catalog = catalog;
1391 Ok(self)
1392 }
1393
1394 pub fn with_network_allowlist_catalog_file(
1398 self,
1399 path: impl AsRef<std::path::Path>,
1400 ) -> Result<Self, NodeServerError> {
1401 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1402 .map_err(NodeServerError::from)?;
1403 self.with_network_allowlist_catalog(catalog)
1404 }
1405}
1406
1407pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1408 platform::default_state_path(node_id.as_str())
1409 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1410}
1411
1412pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1413 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1414}
1415
1416fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1417 let parent = state_path.parent()?;
1418 let mut name = state_path.file_name()?.to_os_string();
1419 name.push(".delivery-store");
1420 Some(parent.join(name))
1421}
1422
1423fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1424 let parent = state_path.parent()?;
1425 let mut name = state_path.file_name()?.to_os_string();
1426 name.push(".context-pack-store");
1427 Some(parent.join(name))
1428}
1429
1430pub struct NodeServer {
1431 config: NodeServerConfig,
1432 runtime: NativeRuntime,
1433 shared: Arc<NodeShared>,
1434 events: EventSubscription,
1435 state_path_lock: Option<session_registry::StatePathLock>,
1436}
1437
1438#[cfg(feature = "fixture")]
1439#[derive(Clone, Default)]
1440pub struct SpawnManagedWorktreeV2FailureProbe {
1441 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1442}
1443
1444#[cfg(feature = "fixture")]
1445impl SpawnManagedWorktreeV2FailureProbe {
1446 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1447 *self
1448 .latest_code
1449 .lock()
1450 .unwrap_or_else(|poisoned| poisoned.into_inner())
1451 }
1452
1453 fn record(&self, code: Option<NodeFailureCode>) {
1454 *self
1455 .latest_code
1456 .lock()
1457 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1458 }
1459}
1460
1461#[cfg(all(feature = "fixture", windows))]
1462struct IsolatedCodexHomeFixtureEnvironment;
1463
1464#[cfg(all(feature = "fixture", windows))]
1465impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1466 for IsolatedCodexHomeFixtureEnvironment
1467{
1468 fn resolve_child_environment(
1469 &self,
1470 ) -> Result<
1471 Vec<gate4agent_catalog::EnvMutation>,
1472 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1473 > {
1474 Ok(vec![gate4agent_catalog::EnvMutation {
1475 key: OsString::from("USERPROFILE"),
1476 value: None,
1477 }])
1478 }
1479}
1480
1481impl NodeServer {
1482 #[cfg(unix)]
1486 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1487 self.shared.signer_proxy.set(proxy).is_ok()
1488 }
1489
1490 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1491 self.shared.m4a_accounts.as_ref()?;
1492 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1493 }
1494
1495 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1496 let catalog = active_registry()?;
1497 Self::new_with_registry(config, catalog)
1498 }
1499
1500 #[cfg(feature = "fixture")]
1501 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1502 let mut spec = gate4agent_testkit::interactive_agent_spec();
1503 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1504 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1505 Self::new_fixture_with_spec(config, spec)
1506 }
1507
1508 #[cfg(feature = "fixture")]
1509 pub fn new_clean_exit_fixture(
1510 config: NodeServerConfig,
1511 fixture_root: PathBuf,
1512 started_marker: PathBuf,
1513 release_signal: PathBuf,
1514 ) -> Result<Self, NodeServerError> {
1515 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1516 &fixture_root,
1517 &started_marker,
1518 &release_signal,
1519 )
1520 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1521 spec.id = AgentId::new("claude")
1522 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1524 Self::new_fixture_with_spec(config, spec)
1525 }
1526
1527 #[cfg(all(feature = "fixture", windows))]
1537 pub fn new_durable_context_pack_clean_exit_fixture(
1538 config: NodeServerConfig,
1539 fixture_root: PathBuf,
1540 started_marker: PathBuf,
1541 release_signal: PathBuf,
1542 session_id: &str,
1543 ) -> Result<Self, NodeServerError> {
1544 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1545 &fixture_root,
1546 &started_marker,
1547 &release_signal,
1548 session_id,
1549 )
1550 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1551 let source_id = AgentId::new("claude")
1552 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1554 NodeServerError::Registry(
1555 "durable context pack clean-exit fixture source adapter is unavailable"
1556 .to_owned(),
1557 )
1558 })?;
1559 source_spec.capabilities.adapters.history =
1560 source_provider.capabilities.adapters.history.clone();
1561 source_spec.id = source_id;
1562 source_spec.display_name =
1563 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1564 source_spec.detection.command =
1570 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1571 source_spec.detection.aliases.clear();
1572
1573 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1574 target_spec.id = AgentId::new("codex")
1575 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1576 target_spec.display_name =
1577 "Controlled Codex durable context-pack target fixture".to_owned();
1578 target_spec.detection.command =
1579 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1580 target_spec.detection.aliases.clear();
1581
1582 let catalog = AgentRegistry::new([source_spec, target_spec])
1583 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1584 let mut server = Self::new_with_registry(config, catalog)?;
1585 Arc::get_mut(&mut server.shared)
1586 .expect("fixture Node shared state must remain uniquely owned before run")
1587 .fixture_semantic_hook_policy = true;
1588 Ok(server)
1589 }
1590
1591 #[cfg(feature = "fixture")]
1592 pub fn new_monitoring_hook_fixture(
1593 config: NodeServerConfig,
1594 ) -> Result<Self, NodeServerError> {
1595 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1596 spec.id = AgentId::new("claude")
1597 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598 let mut server = Self::new_fixture_with_spec(config, spec)?;
1599 Arc::get_mut(&mut server.shared)
1600 .expect("fixture Node shared state must remain uniquely owned before run")
1601 .fixture_semantic_hook_policy = true;
1602 Ok(server)
1603 }
1604
1605 #[cfg(all(feature = "fixture", windows))]
1606 pub fn new_provider_bundle_argv_fixture(
1607 config: NodeServerConfig,
1608 agent_id: AgentId,
1609 proof_path: PathBuf,
1610 ) -> Result<Self, NodeServerError> {
1611 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1612 Self::new_fixture_with_spec(config, spec)
1613 }
1614
1615 #[cfg(all(feature = "fixture", windows))]
1616 pub fn new_provider_bundle_argv_hold_fixture(
1617 config: NodeServerConfig,
1618 agent_id: AgentId,
1619 proof_path: PathBuf,
1620 release_signal: PathBuf,
1621 ) -> Result<Self, NodeServerError> {
1622 let spec = Self::provider_bundle_argv_fixture_spec(
1623 agent_id,
1624 proof_path,
1625 Some(release_signal),
1626 )?;
1627 Self::new_fixture_with_spec(config, spec)
1628 }
1629
1630 #[cfg(all(feature = "fixture", windows))]
1631 fn provider_bundle_argv_fixture_spec(
1632 agent_id: AgentId,
1633 proof_path: PathBuf,
1634 release_signal: Option<PathBuf>,
1635 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1636 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1637 return Err(NodeServerError::Registry(
1638 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1639 ));
1640 }
1641 let mut spec = gate4agent_testkit::interactive_agent_spec();
1642 spec.id = agent_id.clone();
1643 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1644 let provider = builtin_registry()
1645 .get(&agent_id)
1646 .ok_or_else(|| {
1647 NodeServerError::Registry(
1648 "provider bundle argv fixture adapter is unavailable".to_owned(),
1649 )
1650 })?;
1651 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1652 if !proof_path.is_absolute()
1653 || !proof_path
1654 .parent()
1655 .is_some_and(|parent| parent.is_dir())
1656 {
1657 return Err(NodeServerError::Registry(
1658 "provider bundle argv proof path requires an existing absolute parent"
1659 .to_owned(),
1660 ));
1661 }
1662 if let Some(release_signal) = release_signal.as_ref() {
1663 if !release_signal.is_absolute()
1664 || !release_signal
1665 .parent()
1666 .is_some_and(|parent| parent.is_dir())
1667 {
1668 return Err(NodeServerError::Registry(
1669 "provider bundle argv release path requires an existing absolute parent"
1670 .to_owned(),
1671 ));
1672 }
1673 if release_signal.exists() {
1674 return Err(NodeServerError::Registry(
1675 "provider bundle argv release path must not exist".to_owned(),
1676 ));
1677 }
1678 let proof_parent = std::fs::canonicalize(
1679 proof_path
1680 .parent()
1681 .expect("validated provider bundle proof parent"),
1682 )
1683 .map_err(|_| {
1684 NodeServerError::Registry(
1685 "provider bundle argv proof parent is unavailable".to_owned(),
1686 )
1687 })?;
1688 let release_parent = std::fs::canonicalize(
1689 release_signal
1690 .parent()
1691 .expect("validated provider bundle release parent"),
1692 )
1693 .map_err(|_| {
1694 NodeServerError::Registry(
1695 "provider bundle argv release parent is unavailable".to_owned(),
1696 )
1697 })?;
1698 if proof_parent != release_parent {
1699 return Err(NodeServerError::Registry(
1700 "provider bundle argv release path must share the proof parent".to_owned(),
1701 ));
1702 }
1703 }
1704 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1705 NodeServerError::Registry(
1706 "provider bundle argv proof path is not valid Unicode".to_owned(),
1707 )
1708 })?;
1709 let release_signal = release_signal.map(|release_signal| {
1710 release_signal.into_os_string().into_string().map_err(|_| {
1711 NodeServerError::Registry(
1712 "provider bundle argv release path is not valid Unicode".to_owned(),
1713 )
1714 })
1715 }).transpose()?;
1716 let script = spec
1717 .launch
1718 .fixed_args
1719 .pop()
1720 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1721 let bundle_validation = if agent_id.as_str() == "codex" {
1722 "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') }"
1723 } else if agent_id.as_str() == "claude" {
1724 "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 }"
1725 } else {
1726 "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 }"
1727 };
1728 if let Some(release_signal) = release_signal {
1729 spec.launch.fixed_args.push(format!(
1730 "& {{ 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} }}",
1731 ));
1732 spec.launch.fixed_args.push(proof_path);
1733 spec.launch.fixed_args.push(release_signal);
1734 } else {
1735 spec.launch.fixed_args.push(format!(
1736 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1737 ));
1738 spec.launch.fixed_args.push(proof_path);
1739 }
1740 Ok(spec)
1741 }
1742
1743 #[cfg(all(feature = "fixture", windows))]
1744 pub fn new_context_pack_fixture(
1745 config: NodeServerConfig,
1746 proof_path: PathBuf,
1747 ) -> Result<Self, NodeServerError> {
1748 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1749 Self::new_with_registry(config, catalog)
1750 }
1751
1752 #[cfg(all(feature = "fixture", windows))]
1753 pub fn new_monitoring_context_pack_fixture(
1754 config: NodeServerConfig,
1755 proof_path: PathBuf,
1756 ) -> Result<Self, NodeServerError> {
1757 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1758 let mut server = Self::new_with_registry(config, catalog)?;
1759 Arc::get_mut(&mut server.shared)
1760 .expect("fixture Node shared state must remain uniquely owned before run")
1761 .fixture_semantic_hook_policy = true;
1762 Ok(server)
1763 }
1764
1765 #[cfg(all(feature = "fixture", windows))]
1766 pub fn install_isolated_codex_home_environment_fixture(
1767 &mut self,
1768 profile_id: SpawnEnvironmentProfileId,
1769 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1770 ) -> Result<(), NodeServerError> {
1771 let provider = AgentId::new("codex")
1772 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1773 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1774 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1775 "{}-pty",
1776 profile_id.as_str(),
1777 ))
1778 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1779 provider.clone(),
1780 TransportKind::Pty,
1781 vec![OsString::from("USERPROFILE")],
1782 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1783 )
1784 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1785 let materialization = NodeSessionMaterializationProfile::new(
1786 Vec::new(),
1787 vec![crate::session_environment::NodeSessionPathBinding::new(
1788 "CODEX_HOME",
1789 crate::session_environment::NodeSessionPathClass::ProviderHome,
1790 )
1791 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1792 Vec::new(),
1793 )
1794 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1795 self.install_environment_profile(
1796 NodeEnvironmentProfile::new_with_materialization(
1797 profile_id,
1798 profile_revision,
1799 provider,
1800 [native_profile],
1801 Some(materialization),
1802 )
1803 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1804 )
1805 }
1806
1807 #[cfg(all(feature = "fixture", windows))]
1808 pub fn new_context_only_proof_fixture(
1809 config: NodeServerConfig,
1810 target_provider: AgentId,
1811 proof_path: PathBuf,
1812 ) -> Result<Self, NodeServerError> {
1813 if target_provider.as_str() != "kimi" {
1814 return Err(NodeServerError::Registry(
1815 "context-only proof fixture requires Kimi".to_owned(),
1816 ));
1817 }
1818 let catalog = Self::context_pack_fixture_catalog(
1819 proof_path,
1820 Some(&target_provider),
1821 false,
1822 )?;
1823 Self::new_with_registry(config, catalog)
1824 }
1825
1826 #[cfg(all(feature = "fixture", windows))]
1827 fn context_pack_fixture_catalog(
1828 proof_path: PathBuf,
1829 context_only_target: Option<&AgentId>,
1830 monitoring_claude_source: bool,
1831 ) -> Result<AgentRegistry, NodeServerError> {
1832 if !proof_path.is_absolute()
1833 || !proof_path
1834 .parent()
1835 .is_some_and(|parent| parent.is_dir())
1836 {
1837 return Err(NodeServerError::Registry(
1838 "context pack proof path requires an existing absolute parent".to_owned(),
1839 ));
1840 }
1841 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1842 NodeServerError::Registry(
1843 "context pack proof path is not valid Unicode".to_owned(),
1844 )
1845 })?;
1846 let providers = ["claude", "codex", "grok", "kimi"];
1847 let builtins = builtin_registry();
1848 let mut specs = Vec::with_capacity(providers.len());
1849 for provider_id in providers {
1850 let agent_id = AgentId::new(provider_id)
1851 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1852 let provider = builtins.get(&agent_id).ok_or_else(|| {
1853 NodeServerError::Registry(
1854 "context pack fixture provider adapter is unavailable".to_owned(),
1855 )
1856 })?;
1857 let monitored_provider =
1858 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1859 let mut spec = if monitored_provider {
1860 gate4agent_testkit::monitoring_hook_agent_spec()
1861 } else {
1862 gate4agent_testkit::interactive_agent_spec()
1863 };
1864 spec.id = agent_id.clone();
1865 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1866 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1867 spec.detection.aliases.clear();
1868 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1869 if provider_id == "codex" {
1870 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1871 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1872 })?;
1873 let validation = r#"
1874$bundleArgs = @($bundleArgs)
1875if ($bundleArgs.Count -ne 0) { exit 91 }
1876$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1877$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1878$cwd = (Get-Location).ProviderPath
1879if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1880$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1881if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1882$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1883if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1884$sha = [Security.Cryptography.SHA256]::Create()
1885try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1886if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1887if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1888$contextPath = Join-Path $contextRoot 'context-pack.json'
1889if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1890$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1891if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1892$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1893$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1894$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$separator = [IO.Path]::DirectorySeparatorChar
1896if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1897if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1898$rawContext = [IO.File]::ReadAllText($contextPath)
1899if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1900try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1901$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1902$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1903foreach ($property in @($document.PSObject.Properties.Name)) {
1904 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1905}
1906foreach ($property in $requiredTopLevel) {
1907 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1908}
1909if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1910if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1911$messages = @($document.retained_messages)
1912if ($messages.Count -eq 0) { exit 107 }
1913foreach ($message in $messages) {
1914 $messageProperties = @($message.PSObject.Properties.Name)
1915 foreach ($property in $messageProperties) {
1916 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1917 }
1918 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1919}
1920$sha = [Security.Cryptography.SHA256]::Create()
1921try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1922[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))
1923$rawProof = [IO.File]::ReadAllText($proofPath)
1924if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1925[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1926"#;
1927 spec.launch.fixed_args.push(format!(
1928 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1929 ));
1930 spec.launch.fixed_args.push(proof_path.clone());
1931 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1932 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1933 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1934 })?;
1935 let validation = r#"
1936$bundleArgs = @($bundleArgs)
1937if ($bundleArgs.Count -ne 0) { exit 91 }
1938$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1939$cwd = (Get-Location).ProviderPath
1940if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1941$contextPath = Join-Path $contextRoot 'context-pack.json'
1942if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1943$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1944if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1945$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1946$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1947$separator = [IO.Path]::DirectorySeparatorChar
1948if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1949try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1950if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1951if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1952$messages = @($document.retained_messages)
1953if ($messages.Count -eq 0) { exit 99 }
1954$hasUser = $false
1955$hasAssistant = $false
1956foreach ($message in $messages) {
1957 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1958 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1959 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1960 else { exit 101 }
1961}
1962if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1963$sha = [Security.Cryptography.SHA256]::Create()
1964try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1965[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1966[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1967"#;
1968 spec.launch.fixed_args.push(format!(
1969 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1970 ));
1971 spec.launch.fixed_args.push(proof_path.clone());
1972 }
1973 specs.push(spec);
1974 }
1975 let catalog = AgentRegistry::new(specs)
1976 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1977 Ok(catalog)
1978 }
1979
1980 #[cfg(feature = "fixture")]
1981 pub fn new_exact_launcher_fixture(
1982 config: NodeServerConfig,
1983 agent_id: AgentId,
1984 launcher: String,
1985 ) -> Result<Self, NodeServerError> {
1986 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1987 return Err(NodeServerError::Registry(
1988 "exact launcher fixture requires a supported provider".to_owned(),
1989 ));
1990 }
1991 if launcher.contains('\0') {
1992 return Err(NodeServerError::Registry(
1993 "exact launcher fixture path contains NUL".to_owned(),
1994 ));
1995 }
1996 let launcher_path = Path::new(&launcher);
1997 if !launcher_path.is_absolute() {
1998 return Err(NodeServerError::Registry(
1999 "exact launcher fixture path must be absolute".to_owned(),
2000 ));
2001 }
2002 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2003 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2004 })?;
2005 if !metadata.is_file() {
2006 return Err(NodeServerError::Registry(
2007 "exact launcher fixture path must be a regular file".to_owned(),
2008 ));
2009 }
2010 let mut spec = builtin_registry()
2011 .get(&agent_id)
2012 .ok_or_else(|| {
2013 NodeServerError::Registry(
2014 "exact launcher fixture provider is unavailable".to_owned(),
2015 )
2016 })?
2017 .clone();
2018 spec.launch.program = launcher;
2019 Self::new_fixture_with_spec(config, spec)
2020 }
2021
2022 #[cfg(feature = "fixture")]
2031 fn h3b_provider_spec_with_program_and_args(
2032 agent_id: AgentId,
2033 provider_program: PathBuf,
2034 fixed_args: Vec<OsString>,
2035 ) -> Result<AgentSpec, NodeServerError> {
2036 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2037 return Err(NodeServerError::Registry(
2038 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2039 ));
2040 }
2041 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2042 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2043 })?;
2044 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2045 return Err(NodeServerError::Registry(
2046 "H3B fixture provider must be an exact regular file".to_owned(),
2047 ));
2048 }
2049 let program = provider_program.into_os_string().into_string().map_err(|_| {
2050 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2051 })?;
2052 let fixed_args = fixed_args.into_iter().map(|argument| {
2053 argument.into_string().map_err(|_| NodeServerError::Registry(
2054 "H3B fixture argument is not Unicode".to_owned(),
2055 ))
2056 }).collect::<Result<Vec<_>, _>>()?;
2057 let argument_bytes = fixed_args.iter()
2058 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2059 if program.contains('\0')
2060 || fixed_args.iter().any(|argument| argument.contains('\0'))
2061 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2062 {
2063 return Err(NodeServerError::Registry(
2064 "H3B fixture provider or arguments are invalid".to_owned(),
2065 ));
2066 }
2067 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2068 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2069 })?.clone();
2070 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2071 .ok_or_else(|| NodeServerError::Registry(
2072 "H3B fixture provider basename is unavailable".to_owned(),
2073 ))?.to_owned();
2074 spec.detection.command = process_name.clone();
2075 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2076 name: process_name,
2077 }];
2078 spec.launch.program = program;
2079 spec.launch.fixed_args = fixed_args;
2080 Ok(spec)
2081 }
2082
2083 #[cfg(feature = "fixture")]
2084 pub fn new_harness_mcp_proxy_fixture(
2085 config: NodeServerConfig,
2086 provider_program: PathBuf,
2087 fixed_args: Vec<OsString>,
2088 harness_mcp_program: PathBuf,
2089 ) -> Result<Self, NodeServerError> {
2090 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2091 let agent_id = AgentId::new("codex")
2092 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2093 let mut spec =
2094 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2095 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2096 config.fixture_raw_pty_runtime = true;
2097 Self::new_fixture_with_spec(config, spec)
2098 }
2099
2100 #[cfg(feature = "fixture")]
2124 pub fn new_harness_mcp_acp_launcher_fixture(
2125 config: NodeServerConfig,
2126 helper_program: PathBuf,
2127 provider: AgentId,
2128 program: PathBuf,
2129 args: Vec<OsString>,
2130 ) -> Result<Self, NodeServerError> {
2131 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2132 return Err(NodeServerError::Registry(
2133 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2134 ));
2135 }
2136 let config = config.with_harness_mcp_helper(helper_program)?;
2137 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2138 let launch = spec.launch.clone();
2139 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2140 NodeServerError::Registry(
2141 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2142 .to_owned(),
2143 )
2144 })?;
2145 acp.launch_override = Some(launch);
2153 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2154 Self::new_fixture_with_spec(config, spec)
2155 }
2156
2157 #[cfg(feature = "fixture")]
2158 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2159 let mut spec = gate4agent_testkit::interactive_agent_spec();
2160 let claude_id = AgentId::new("claude")
2161 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2162 let claude = builtin_registry()
2163 .get(&claude_id)
2164 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2165 spec.id = claude_id;
2166 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2167 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2168 name: "claude".to_owned(),
2169 }];
2170 spec.capabilities.transports.pty_adapter = claude
2171 .capabilities
2172 .transports
2173 .pty_adapter
2174 .clone();
2175 spec.capabilities.adapters.resume = claude
2176 .capabilities
2177 .adapters
2178 .resume
2179 .clone();
2180 let script = spec
2181 .launch
2182 .fixed_args
2183 .pop()
2184 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2185 spec.launch.fixed_args.push(format!(
2186 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2187 ));
2188 Self::new_fixture_with_spec(config, spec)
2189 }
2190
2191 #[cfg(feature = "fixture")]
2192 pub fn new_cmd_cwd_fixture(
2193 config: NodeServerConfig,
2194 command: String,
2195 ) -> Result<Self, NodeServerError> {
2196 let mut spec = gate4agent_testkit::interactive_agent_spec();
2197 spec.id = AgentId::new("claude")
2198 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2199 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2200 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2201 spec.launch.program = command;
2202 spec.launch.fixed_args.clear();
2203 Self::new_fixture_with_spec(config, spec)
2204 }
2205
2206 #[cfg(feature = "fixture")]
2207 fn new_fixture_with_spec(
2208 config: NodeServerConfig,
2209 spec: gate4agent_types::AgentSpec,
2210 ) -> Result<Self, NodeServerError> {
2211 let catalog = AgentRegistry::new([spec])
2212 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213 Self::new_with_registry(config, catalog)
2214 }
2215
2216 fn new_with_registry(
2217 config: NodeServerConfig,
2218 catalog: AgentRegistry,
2219 ) -> Result<Self, NodeServerError> {
2220 for profile in config.spawn_profiles.iter() {
2221 if catalog.get(&profile.provider).is_none() {
2222 return Err(NodeServerError::Registry(
2223 "spawn profile references an unavailable provider".to_owned(),
2224 ));
2225 }
2226 }
2227 let input_settle_timeout_ms = catalog
2228 .iter()
2229 .map(|spec| spec.readiness.timeout_ms)
2230 .max()
2231 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2232 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2233 .max(MUTATION_SETTLE_TIMEOUT_MS);
2234 let (provider_contracts, provider_adapter_contracts) =
2235 provider_contract_manifest(&catalog)?;
2236 let compatibility_support = node_compatibility_support_for_manifest(
2237 &provider_contracts,
2238 &provider_adapter_contracts,
2239 )?;
2240 validate_node_negotiated_handshake_capacity(&compatibility_support)
2241 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2242 let enabled_providers = provider_contracts
2243 .iter()
2244 .map(|contract| contract.provider.clone())
2245 .collect();
2246 #[cfg(feature = "fixture")]
2247 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2248 #[cfg(not(feature = "fixture"))]
2249 let fixture_raw_pty_runtime = false;
2250 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2251 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2252 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2253 })).expect("fixture provider catalog remains bounded");
2254 (None, statuses)
2255 } else {
2256 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2257 let statuses = monitor.collect();
2258 (Some(monitor), statuses)
2259 };
2260 let state_path_lock =
2261 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2262 .map_err(|error| {
2263 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2264 })?;
2265 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2266 Some(state_path) => {
2267 let delivery_root = delivery_store_root_for_state_path(state_path)
2268 .ok_or_else(|| {
2269 NodeServerError::DeliveryStore(
2270 "delivery store root could not be derived from state path".to_owned(),
2271 )
2272 })?;
2273 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2274 let detail = match &error {
2275 DeliveryStoreError::Storage(io_error) => {
2276 format!("{error}: {io_error}")
2277 }
2278 _ => error.to_string(),
2279 };
2280 NodeServerError::DeliveryStore(detail)
2281 })?;
2282 (Some(store), bundles)
2283 }
2284 None => (None, Vec::new()),
2285 };
2286 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2287 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2288 })?;
2289 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2290 Some(state_path) => {
2291 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2292 .ok_or(NodeServerError::ContextPackStore)?;
2293 let (store, packs) = ContextPackStore::open(context_pack_root)
2294 .map_err(|_| NodeServerError::ContextPackStore)?;
2295 (Some(store), packs)
2296 }
2297 None => (None, Vec::new()),
2298 };
2299 let mut durable_context_catalog = ContextPackCatalog::default();
2300 for pack in durable_context_packs {
2301 durable_context_catalog
2302 .insert(pack)
2303 .map_err(|_| NodeServerError::ContextPackStore)?;
2304 }
2305 let session_environment_materializer = config
2306 .session_environment
2307 .as_ref()
2308 .map(|environment| {
2309 SessionEnvironmentMaterializer::new(
2310 environment.root.clone(),
2311 Arc::clone(&environment.resolver),
2312 )
2313 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2314 })
2315 .transpose()?;
2316 #[cfg(unix)]
2319 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2320 let native_session_catalog = config.history.clone().map(|history| {
2321 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2322 });
2323 let (handle, runtime) = match config.history.clone() {
2324 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2325 None => NativeRuntime::new(catalog, config.runtime),
2326 };
2327 let native_launch_profile_control = runtime.native_launch_profile_control();
2328 let harness_mcp_registry = config
2329 .harness_mcp_helper
2330 .clone()
2331 .map(HarnessMcpProxyRegistry::new);
2332 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2333 let incarnation_id = random_incarnation_id()
2334 .map_err(NodeServerError::IncarnationIdentity)?;
2335 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2336 .map_err(durable_state_load_error)?;
2337 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2338 let managed_worktree_tombstones =
2339 std::mem::take(&mut loaded.managed_worktree_tombstones);
2340 let materializations = std::mem::take(&mut loaded.materializations);
2341 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2342 let (workspaces, records, persistence_warning) =
2343 merge_durable_state(&config.workspaces, loaded)?;
2344 let mut shared = NodeShared::new_with_incarnation(
2345 handle,
2346 config.access_token.clone(),
2347 config.node_id.clone(),
2348 incarnation_id,
2349 workspaces,
2350 enabled_providers,
2351 provider_runtime_statuses,
2352 provider_runtime_monitor,
2353 provider_contracts,
2354 provider_adapter_contracts,
2355 config.spawn_profiles.clone(),
2356 Some(native_launch_profile_control),
2357 native_session_catalog,
2358 session_environment_materializer,
2359 config.state_path.clone(),
2360 records,
2361 managed_worktrees,
2362 managed_worktree_tombstones,
2363 materializations,
2364 persistence_warning,
2365 input_settle_timeout_ms,
2366 );
2367 shared.harness_mcp_registry = harness_mcp_registry.clone();
2368 shared.session_record_retention = config.session_record_retention;
2369 shared.network_allowlist_catalog =
2370 RwLock::new(config.network_allowlist_catalog.clone());
2371 shared.bundle_catalog = RwLock::new(delivered_catalog);
2372 shared.delivery_store = Mutex::new(delivery_store);
2373 shared.context_catalog = RwLock::new(durable_context_catalog);
2374 shared.context_pack_store = Mutex::new(context_pack_store);
2375 shared.managed_spawn_replays = Mutex::new(
2376 managed_spawn_replays
2377 .into_iter()
2378 .map(|record| (record.idempotency_key.clone(), record))
2379 .collect(),
2380 );
2381 let shared = Arc::new(shared);
2382 if let Some(registry) = harness_mcp_registry {
2383 let weak = Arc::downgrade(&shared);
2384 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2385 Some(shared) => shared.publish_transient(event),
2386 None => false,
2387 }));
2388 }
2389 if shared.persistence_error().is_none() {
2390 shared.persist_state().map_err(|error| {
2391 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2392 })?;
2393 }
2394 Ok(Self {
2395 config,
2396 runtime,
2397 shared,
2398 events,
2399 state_path_lock,
2400 })
2401 }
2402
2403 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2404 NodeShutdownHandle {
2405 shared: Arc::clone(&self.shared),
2406 }
2407 }
2408
2409 #[cfg(feature = "fixture")]
2410 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2411 &self,
2412 ) -> SpawnManagedWorktreeV2FailureProbe {
2413 self.shared
2414 .fixture_spawn_managed_worktree_v2_failure_probe
2415 .clone()
2416 }
2417
2418 pub fn install_environment_profile(
2423 &mut self,
2424 profile: NodeEnvironmentProfile,
2425 ) -> Result<(), NodeServerError> {
2426 if !self.shared.enabled_providers.contains(profile.provider()) {
2427 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2428 profile.provider().clone(),
2429 ));
2430 }
2431 let (binding, native_profiles, materialization) = profile.into_parts();
2432 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2433 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2434 }
2435 let mut profiles = self
2436 .shared
2437 .environment_profiles
2438 .write()
2439 .unwrap_or_else(|poisoned| poisoned.into_inner());
2440 if profiles.contains_key(&binding.id) {
2441 return Err(NodeServerError::DuplicateEnvironmentProfile(
2442 binding.id.clone(),
2443 ));
2444 }
2445 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2446 return Err(NodeServerError::EnvironmentProfileCapacity {
2447 max: MAX_NODE_ENVIRONMENT_PROFILES,
2448 });
2449 }
2450 for native_id in binding.native_profile_ids() {
2451 if profiles.values().any(|current| {
2452 current.native_profile_ids().any(|current_id| current_id == native_id)
2453 }) {
2454 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2455 native_id.to_string(),
2456 ));
2457 }
2458 }
2459
2460 let mut installed = Vec::with_capacity(native_profiles.len());
2461 for native_profile in native_profiles {
2462 let native_id = native_profile.id().clone();
2463 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2464 for installed_id in installed.into_iter().rev() {
2465 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2466 }
2467 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2468 }
2469 installed.push(native_id);
2470 }
2471 if let Some(materialization) = materialization {
2472 self.shared
2473 .environment_materialization_profiles
2474 .write()
2475 .unwrap_or_else(|poisoned| poisoned.into_inner())
2476 .insert(binding.id.clone(), materialization);
2477 }
2478 profiles.insert(binding.id.clone(), binding);
2479 Ok(())
2480 }
2481
2482 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2484 if self.shared.session_environment_materializer.is_none() {
2485 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2486 }
2487 let mut catalog = self
2488 .shared
2489 .bundle_catalog
2490 .write()
2491 .unwrap_or_else(|poisoned| poisoned.into_inner());
2492 catalog
2493 .insert_idempotent(bundle)
2494 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2495 Ok(())
2496 }
2497
2498 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2499 let shutdown = self.shutdown_handle();
2500 let run = self.run();
2501 tokio::pin!(run);
2502 tokio::select! {
2503 result = &mut run => result,
2504 signal = wait_for_ctrl_signal() => {
2505 signal?;
2506 let shutdown_result = shutdown.request_shutdown().await;
2507 let run_result = run.await;
2508 shutdown_result.and(run_result)
2509 }
2510 }
2511 }
2512
2513 pub async fn run(self) -> Result<(), NodeServerError> {
2514 let NodeServer {
2515 config,
2516 mut runtime,
2517 shared,
2518 events,
2519 state_path_lock: _state_path_lock,
2520 } = self;
2521 shared.reconcile_materializations();
2522 shared.reconcile_managed_worktrees().await;
2523 shared.retire_unavailable_session_records().await;
2524 shared.reconcile_context_pack_exports().await;
2525 let endpoint = config.endpoint.clone();
2529 let call_home = config.call_home;
2530 let call_home_identity = config.call_home_identity.clone();
2531 let api_listen = config.api_listen;
2532 let bridge_listen = config.bridge_listen;
2533 let bridge_underlay = config.bridge_underlay;
2534 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2535 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2536 let accept_shared = Arc::clone(&shared);
2537 let api_shared = Arc::clone(&shared);
2538 let bridge_shared = Arc::clone(&shared);
2539 let shutdown_shared = Arc::clone(&shared);
2540 let shutdown_timeout = Duration::from_millis(
2541 config.runtime.provider_shutdown_timeout_ms.max(1),
2542 );
2543 let result = {
2544 let runtime_loop = drive_runtime_until_shutdown(
2545 &mut runtime,
2546 events,
2547 Arc::clone(&shared),
2548 shutdown_timeout,
2549 );
2550 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2551 let api_loop = http_api::run(api_listen, api_shared);
2552 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2553 tokio::pin!(runtime_loop);
2554 tokio::pin!(accept_loop);
2555 tokio::pin!(api_loop);
2556 tokio::pin!(bridge_loop);
2557 tokio::select! {
2558 runtime_result = &mut runtime_loop => {
2559 let shutdown_result = shutdown_shared
2560 .begin_shutdown()
2561 .await
2562 .map_err(NodeServerError::ShutdownDispatch);
2563 let (accept_result, api_result, bridge_result) =
2564 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2565 match runtime_result {
2566 Err(error) => Err(error),
2567 Ok(()) => shutdown_result
2568 .and(accept_result)
2569 .and(api_result.map_err(NodeServerError::HttpApi))
2570 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2571 }
2572 }
2573 accept_result = &mut accept_loop => {
2574 let shutdown_result = shutdown_shared
2575 .begin_shutdown()
2576 .await
2577 .map_err(NodeServerError::ShutdownDispatch);
2578 let (runtime_result, api_result, bridge_result) =
2579 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2580 match accept_result {
2581 Err(error) => Err(error),
2582 Ok(()) => shutdown_result
2583 .and(runtime_result)
2584 .and(api_result.map_err(NodeServerError::HttpApi))
2585 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2586 }
2587 }
2588 api_result = &mut api_loop => {
2589 let shutdown_result = shutdown_shared
2590 .begin_shutdown()
2591 .await
2592 .map_err(NodeServerError::ShutdownDispatch);
2593 let (runtime_result, accept_result, bridge_result) =
2594 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2595 match api_result {
2596 Err(error) => Err(NodeServerError::HttpApi(error)),
2597 Ok(()) => shutdown_result
2598 .and(runtime_result)
2599 .and(accept_result)
2600 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2601 }
2602 }
2603 bridge_result = &mut bridge_loop => {
2604 let shutdown_result = shutdown_shared
2605 .begin_shutdown()
2606 .await
2607 .map_err(NodeServerError::ShutdownDispatch);
2608 let (runtime_result, accept_result, api_result) =
2609 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2610 match bridge_result {
2611 Err(error) => Err(NodeServerError::BridgeApi(error)),
2612 Ok(()) => shutdown_result
2613 .and(runtime_result)
2614 .and(accept_result)
2615 .and(api_result.map_err(NodeServerError::HttpApi)),
2616 }
2617 }
2618 }
2619 };
2620 if let Ok(mut guard) = shared.delivery_store.lock() {
2627 *guard = None;
2628 }
2629 if let Ok(mut guard) = shared.context_pack_store.lock() {
2630 *guard = None;
2631 }
2632 result
2633 }
2634}
2635
2636async fn drive_runtime_until_shutdown(
2637 runtime: &mut NativeRuntime,
2638 events: EventSubscription,
2639 shared: Arc<NodeShared>,
2640 shutdown_timeout: Duration,
2641) -> Result<(), NodeServerError> {
2642 let _ = shared
2646 .shell_efficiency_profile
2647 .set(runtime.shell_efficiency_profile());
2648 let mut shutdown_started = None;
2649 let mut last_retention_sweep = Instant::now();
2650 let mut last_provisional_reap = Instant::now();
2651 loop {
2652 let runtime_tick_start = Instant::now();
2659 let tick_result = runtime.tick().await;
2660 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2661
2662 let event_drain_start = Instant::now();
2663 for outcome in &tick_result.command_outcomes {
2671 if let Err(error) = &outcome.result {
2672 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2673 tracing::warn!(
2674 command_id = outcome.command_id.0,
2675 cause = %error,
2676 "control command rejected"
2677 );
2678 shared.record_rejected_command(
2679 outcome.command_id,
2680 error.to_string(),
2681 error.is_unsupported_transport(),
2682 );
2683 }
2684 }
2685 let mut events_drained: u64 = 0;
2686 while let Ok(event) = events.try_recv() {
2687 events_drained += 1;
2688 let clean_exit = matches!(
2689 event.event,
2690 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2691 );
2692 let instance_id = event.instance_id;
2693 let generation = event.generation;
2694 if clean_exit {
2706 if let Some((record_id, session)) =
2707 shared.managed_record_export_target_for_instance(instance_id, generation)
2708 {
2709 let export_shared = Arc::clone(&shared);
2710 tokio::spawn(async move {
2711 export_shared
2712 .reconcile_context_pack_export_for_record(&record_id, session)
2713 .await;
2714 });
2715 }
2716 }
2717 shared.publish_control(event);
2718 }
2719 let event_drain_elapsed = event_drain_start.elapsed();
2720
2721 let publish_terminal_frames_start = Instant::now();
2722 shared.publish_terminal_frames();
2723 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2724
2725 shared
2731 .native_session_gauge
2732 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2733 *shared
2734 .runtime_tick_profile
2735 .lock()
2736 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2737
2738 let remainder_start = Instant::now();
2739 if shared.shutdown.load(Ordering::Acquire) {
2740 let started = *shutdown_started.get_or_insert_with(Instant::now);
2741 let snapshot = shared.handle.snapshot();
2742 let sessions_settled = snapshot.sessions.iter().all(|session| {
2743 !matches!(
2744 session.status,
2745 gate4agent_types::SessionStatus::Starting
2746 | gate4agent_types::SessionStatus::Running
2747 | gate4agent_types::SessionStatus::Stopping
2748 )
2749 });
2750 if runtime.active_native_sessions() == 0 && sessions_settled {
2751 return Ok(());
2752 }
2753 if started.elapsed() >= shutdown_timeout {
2754 return Err(NodeServerError::ShutdownTimedOut {
2755 active_native_sessions: runtime.active_native_sessions(),
2756 });
2757 }
2758 }
2759 let remainder_elapsed = remainder_start.elapsed();
2760
2761 {
2762 let mut profile = shared
2763 .drive_loop_profile
2764 .lock()
2765 .unwrap_or_else(|poisoned| poisoned.into_inner());
2766 profile.record_runtime_tick(runtime_tick_elapsed);
2767 profile.record_event_drain(event_drain_elapsed);
2768 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2769 profile.record_remainder(remainder_elapsed);
2770 profile.note_iteration(Instant::now());
2771 }
2772
2773 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2785 if tick_result.observations_applied == 0 && events_drained == 0 {
2786 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2787 }
2788
2789 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2794 shared.retire_unavailable_session_records().await;
2795 last_retention_sweep = Instant::now();
2796 }
2797 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2799 for address in shared.take_due_provisional_spawns() {
2800 shared.reap_provisional_spawn(address).await;
2801 }
2802 last_provisional_reap = Instant::now();
2803 }
2804
2805 let hot = shared
2808 .pty_input_marks
2809 .lock()
2810 .unwrap_or_else(|poisoned| poisoned.into_inner())
2811 .values()
2812 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2813 if hot {
2814 sleep(Duration::from_millis(1)).await;
2815 } else {
2816 tokio::select! {
2817 _ = sleep(Duration::from_millis(10)) => {}
2818 _ = shared.pty_input_wake.notified() => {}
2819 }
2820 }
2821 }
2822}
2823
2824#[derive(Clone)]
2825pub struct NodeShutdownHandle {
2826 shared: Arc<NodeShared>,
2827}
2828
2829impl NodeShutdownHandle {
2830 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2831 self.shared
2832 .begin_shutdown()
2833 .await
2834 .map_err(NodeServerError::ShutdownDispatch)
2835 }
2836}
2837
2838async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2839 #[cfg(windows)]
2840 {
2841 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2842 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2843 return tokio::select! {
2844 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2845 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2846 };
2847 }
2848 #[cfg(unix)]
2849 {
2850 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2851 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2852 tokio::select! {
2853 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2854 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2855 }
2856 }
2857}
2858
2859fn shell_host_spec() -> AgentSpec {
2873 AgentSpec {
2874 id: AgentId::new("shell").expect("shell agent id"),
2875 revision: "hatchery:shell-pty-1".to_owned(),
2876 display_name: "Host shell".to_owned(),
2877 detection: DetectionSpec {
2878 command: "bash".to_owned(),
2879 aliases: vec!["sh".to_owned(), "shell".to_owned()],
2880 required_commands: Vec::new(),
2881 unsupported_platforms: Vec::new(),
2882 },
2883 launch: LaunchSpec {
2884 program: "/bin/bash".to_owned(),
2886 fixed_args: vec!["-i".to_owned()],
2887 },
2888 expected_processes: vec![ProcessMatcher::Exact {
2889 name: "bash".to_owned(),
2890 }],
2891 prompt: PromptSpec {
2892 initial: InitialPromptMode::None,
2893 native_draft: None,
2894 },
2895 readiness: AgentReadinessSpec::default(),
2896 capabilities: AgentCapabilities {
2897 agent_commands: None,
2898 transports: AgentTransportCapabilities {
2899 pty: true,
2900 pty_adapter: None,
2901 pipe: None,
2902 acp: None,
2903 },
2904 adapters: AgentAdapterCapabilities::default(),
2905 },
2906 verification: SpecVerification::Gate4AgentVerified,
2907 }
2908}
2909
2910fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2911 let mut specs = vec![shell_host_spec()];
2912 specs.extend(
2913 builtin_registry()
2914 .iter()
2915 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2916 .cloned(),
2917 );
2918 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2919}
2920
2921fn provider_contract_manifest(
2922 catalog: &AgentRegistry,
2923) -> Result<
2924 (
2925 Vec<ProviderContractSupport>,
2926 Vec<ProviderAdapterContractSupport>,
2927 ),
2928 NodeServerError,
2929> {
2930 let mut provider_contracts = Vec::new();
2931 let mut provider_adapter_contracts = Vec::new();
2932 for spec in catalog.iter() {
2933 let provider = spec.id.clone();
2934 let revision = ProviderContractRevision::new(spec.revision.clone())
2935 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2936 provider_contracts.push(ProviderContractSupport {
2937 provider: provider.clone(),
2938 revision,
2939 });
2940 for (family, binding) in declared_adapter_bindings(spec)? {
2941 let revision = AdapterContractRevision::new(binding.revision.clone())
2942 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2943 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2944 provider: provider.clone(),
2945 family,
2946 adapter_id: binding.id.clone(),
2947 revision,
2948 });
2949 }
2950 }
2951 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2952 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2953 Ok((provider_contracts, provider_adapter_contracts))
2954}
2955
2956fn declared_adapter_bindings(
2957 spec: &AgentSpec,
2958) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2959 let mut bindings = Vec::new();
2960 push_declared_binding(
2961 &mut bindings,
2962 AdapterFamily::PtySemantic,
2963 spec.capabilities.transports.pty_adapter.as_ref(),
2964 )?;
2965 push_declared_binding(
2966 &mut bindings,
2967 AdapterFamily::Pipe,
2968 spec.capabilities.adapters.pty_sidecar.as_ref(),
2969 )?;
2970 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2971 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2972 gate4agent_types::PipeProtocol::SemanticNdjson
2973 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2974 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2975 });
2976 push_declared_binding(
2977 &mut bindings,
2978 AdapterFamily::Pipe,
2979 pipe_transport
2980 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2981 .map(|transport| &transport.adapter),
2982 )?;
2983 push_declared_binding(
2984 &mut bindings,
2985 AdapterFamily::Acp,
2986 spec.capabilities
2987 .transports
2988 .acp
2989 .as_ref()
2990 .map(|transport| &transport.adapter),
2991 )?;
2992 push_declared_binding(
2993 &mut bindings,
2994 AdapterFamily::Hook,
2995 spec.capabilities.adapters.hook.as_ref(),
2996 )?;
2997 push_declared_binding(
2998 &mut bindings,
2999 AdapterFamily::ManagedHook,
3000 spec.capabilities.adapters.managed_hook.as_ref(),
3001 )?;
3002 push_declared_binding(
3003 &mut bindings,
3004 AdapterFamily::OneShot,
3005 pipe_transport
3006 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3007 .map(|transport| &transport.adapter),
3008 )?;
3009 push_declared_binding(
3010 &mut bindings,
3011 AdapterFamily::OneShot,
3012 spec.capabilities.adapters.one_shot.as_ref(),
3013 )?;
3014 push_declared_binding(
3015 &mut bindings,
3016 AdapterFamily::History,
3017 spec.capabilities.adapters.history.as_ref(),
3018 )?;
3019 push_declared_binding(
3020 &mut bindings,
3021 AdapterFamily::Resume,
3022 spec.capabilities.adapters.resume.as_ref(),
3023 )?;
3024 push_declared_binding(
3025 &mut bindings,
3026 AdapterFamily::SessionOptions,
3027 spec.capabilities.adapters.session_options.as_ref(),
3028 )?;
3029 push_declared_binding(
3030 &mut bindings,
3031 AdapterFamily::CapabilityProbe,
3032 spec.capabilities.adapters.capability_probe.as_ref(),
3033 )?;
3034 Ok(bindings)
3035}
3036
3037fn push_declared_binding<'a>(
3038 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3039 family: AdapterFamily,
3040 binding: Option<&'a AdapterBinding>,
3041) -> Result<(), NodeServerError> {
3042 let Some(binding) = binding else {
3043 return Ok(());
3044 };
3045 if let Some((_, existing)) = bindings
3046 .iter()
3047 .find(|(existing_family, _)| *existing_family == family)
3048 {
3049 if *existing == binding {
3050 return Ok(());
3051 }
3052 return Err(NodeServerError::ProviderContractManifest(format!(
3053 "provider declares conflicting {family:?} adapter bindings"
3054 )));
3055 }
3056 bindings.push((family, binding));
3057 Ok(())
3058}
3059
3060fn merge_durable_state(
3061 configured: &[WorkspaceConfig],
3062 mut loaded: LoadedNodeState,
3063) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3064 let mut workspaces = configured
3065 .iter()
3066 .cloned()
3067 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3068 .collect::<BTreeMap<_, _>>();
3069 let mut roots = workspaces
3070 .values()
3071 .map(|workspace| {
3072 (
3073 platform::root_identity(&workspace.canonical_root),
3074 workspace.workspace_id.clone(),
3075 )
3076 })
3077 .collect::<BTreeMap<_, _>>();
3078 let mut warning = loaded
3079 .warning
3080 .take()
3081 .map(|message| session_registry::sanitized_persistence_summary(&message));
3082
3083 for (workspace_id, canonical_root) in loaded.workspaces {
3084 if let Some(existing) = workspaces.get(&workspace_id) {
3085 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3086 return Err(NodeServerError::DurableState(io::Error::new(
3087 io::ErrorKind::InvalidData,
3088 DURABLE_STATE_CONFLICT_ERROR,
3089 )));
3090 }
3091 continue;
3092 }
3093 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3094 return Err(NodeServerError::DurableState(io::Error::new(
3095 io::ErrorKind::InvalidData,
3096 DURABLE_STATE_CONFLICT_ERROR,
3097 )));
3098 }
3099 if !Path::new(&canonical_root).is_dir() {
3100 warning.get_or_insert_with(|| {
3101 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3102 });
3103 continue;
3104 }
3105 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3106 NodeServerError::DurableState(io::Error::new(
3107 io::ErrorKind::InvalidData,
3108 DURABLE_STATE_CONFLICT_ERROR,
3109 ))
3110 })?;
3111 roots.insert(
3112 platform::root_identity(&workspace.canonical_root),
3113 workspace.workspace_id.clone(),
3114 );
3115 workspaces.insert(workspace_id, workspace);
3116 }
3117
3118 for record in &mut loaded.records {
3119 match workspaces.get(&record.workspace_id) {
3120 Some(workspace)
3121 if platform::roots_equal(
3122 &workspace.canonical_root,
3123 windows_path_text(&record.canonical_root),
3124 ) => {}
3125 Some(_) => {
3126 return Err(NodeServerError::DurableState(io::Error::new(
3127 io::ErrorKind::InvalidData,
3128 DURABLE_STATE_CONFLICT_ERROR,
3129 )));
3130 }
3131 None => {
3132 record.state = ManagedSessionState::Unavailable;
3133 record.active_session = None;
3134 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3135 }
3136 }
3137 }
3138 Ok((workspaces.into_values().collect(), loaded.records, warning))
3139}
3140
3141struct RejectedCommandReason {
3144 message: String,
3145 unsupported_transport: bool,
3152 expires_at: Instant,
3153}
3154
3155struct NodeShared {
3156 handle: Gate4AgentHandle,
3157 access_token: String,
3158 node_id: NodeId,
3159 incarnation_id: NodeIncarnationId,
3160 started_at_unix_ms: u64,
3161 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3162 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3163 managed_worktree_profiles:
3164 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3165 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3166 enabled_providers: Vec<AgentId>,
3167 provider_runtime_statuses: ProviderRuntimeStatuses,
3168 provider_runtime_status_updates:
3169 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3170 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3171 provider_contracts: Vec<ProviderContractSupport>,
3172 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3173 spawn_profiles: SpawnProfileRegistry,
3174 environment_profiles:
3175 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3176 environment_materialization_profiles:
3177 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3178 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3186 #[cfg(feature = "dig2-station-probe")]
3190 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3191 bundle_catalog: RwLock<BundleCatalog>,
3192 delivery_store: Mutex<Option<DeliveryStore>>,
3193 context_pack_store: Mutex<Option<ContextPackStore>>,
3194 context_catalog: RwLock<ContextPackCatalog>,
3195 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3196 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3197 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3198 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3199 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3201 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3203 #[cfg(unix)]
3206 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3207 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3208 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3209 managed_spawn_replays:
3210 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3211 controller: Mutex<Option<ControllerLease>>,
3212 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3215 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3217 history: Mutex<NodeEventHistory>,
3218 event_tx: broadcast::Sender<NodeEventEnvelope>,
3219 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3220 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3228 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3237 terminal_frame_watermarks:
3238 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3239 pty_input_wake: tokio::sync::Notify,
3241 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3244 next_connection_id: AtomicU64,
3245 next_instance_id: AtomicU64,
3246 next_command_id: AtomicU64,
3247 shutdown: AtomicBool,
3248 shutdown_notify: Notify,
3249 preauth_slots: Arc<Semaphore>,
3250 authenticated_slots: Arc<Semaphore>,
3251 inspection_slots: Arc<Semaphore>,
3252 mutation_gate: AsyncMutex<()>,
3253 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3254 session_records: Mutex<SessionRecords>,
3255 state_path: Option<PathBuf>,
3256 persistence_error: RwLock<Option<String>>,
3257 state_transaction: Mutex<()>,
3258 input_settle_timeout_ms: u64,
3259 drive_loop_profile: Mutex<DriveLoopProfiler>,
3263 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3269 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3281 native_session_gauge: AtomicUsize,
3288 connection_loop_iterations: AtomicU64,
3299 connection_events_sent: AtomicU64,
3300 rejected_commands_total: AtomicU64,
3309 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3329 drive_loop_iterations_total: AtomicU64,
3330 drive_loop_iterations_idle: AtomicU64,
3331 session_record_retention: SessionRecordRetentionConfig,
3335 retired_records_total: AtomicU64,
3341 #[cfg(feature = "fixture")]
3342 fixture_semantic_hook_policy: bool,
3343 #[cfg(feature = "fixture")]
3344 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3345}
3346
3347impl Drop for NodeShared {
3348 fn drop(&mut self) {
3349 SessionLifecycleHook::on_shutdown(self);
3350 }
3351}
3352
3353#[derive(Clone, Debug, Eq, PartialEq)]
3354struct SessionBinding {
3355 workspace_id: WorkspaceId,
3356 generation: SessionGeneration,
3357 runtime_policy: ProviderRuntimePolicy,
3358 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3359 record_id: Option<SessionRecordId>,
3360 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3361 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3362 bundle: Option<ResolvedBundleReceipt>,
3363 context: Option<ResolvedContextPackReceipt>,
3364 materialization_id: Option<MaterializationId>,
3365}
3366
3367#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3368enum SpawnRecordPolicy {
3369 ProviderIdentityOnly,
3370 Always,
3371}
3372
3373struct NativeEnvironmentSelectionGuard {
3374 control: Option<NativeLaunchProfileControl>,
3375 instance_id: AgentInstanceId,
3376}
3377
3378struct NativeInstanceOverlayGuard {
3379 control: Option<NativeLaunchProfileControl>,
3380 instance_id: AgentInstanceId,
3381}
3382
3383struct NativeHarnessMcpOverlayGuard {
3384 control: NativeLaunchProfileControl,
3385 instance_id: AgentInstanceId,
3386}
3387
3388impl NativeHarnessMcpOverlayGuard {
3389 fn retain(self) { std::mem::forget(self); }
3390}
3391
3392impl Drop for NativeHarnessMcpOverlayGuard {
3393 fn drop(&mut self) {
3394 self.control
3395 .clear_native_mcp_server_launch_overlay(self.instance_id);
3396 }
3397}
3398
3399impl NativeInstanceOverlayGuard {
3400 fn retain(mut self) {
3401 self.control = None;
3402 }
3403}
3404
3405impl Drop for NativeInstanceOverlayGuard {
3406 fn drop(&mut self) {
3407 if let Some(control) = self.control.as_ref() {
3408 control.clear_native_instance_launch_overlay(self.instance_id);
3409 }
3410 }
3411}
3412
3413enum PreparedNativeLaunchOverlay {
3414 Environment(NativeLaunchEnvironmentOverlay),
3415 Instance(NativeInstanceLaunchOverlay),
3416}
3417
3418impl NativeEnvironmentSelectionGuard {
3419 fn retain(mut self) {
3420 self.control = None;
3421 }
3422}
3423
3424impl Drop for NativeEnvironmentSelectionGuard {
3425 fn drop(&mut self) {
3426 if let Some(control) = self.control.as_ref() {
3427 control.clear_native_launch_profile_selection(self.instance_id);
3428 }
3429 }
3430}
3431
3432struct SessionMaterializationGuard<'a> {
3433 shared: &'a NodeShared,
3434 id: Option<MaterializationId>,
3435 provider_home: Option<PathBuf>,
3438}
3439
3440impl SessionMaterializationGuard<'_> {
3441 fn id(&self) -> Option<&MaterializationId> {
3442 self.id.as_ref()
3443 }
3444
3445 fn provider_home(&self) -> Option<&Path> {
3446 self.provider_home.as_deref()
3447 }
3448
3449 fn retain(mut self) {
3450 self.id = None;
3451 }
3452}
3453
3454impl Drop for SessionMaterializationGuard<'_> {
3455 fn drop(&mut self) {
3456 if let Some(id) = self.id.take() {
3457 let _ = self.shared.cleanup_materialization(&id);
3458 }
3459 }
3460}
3461
3462
3463trait SessionLifecycleHook {
3466 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3468 fn on_session_ended(&self, address: &SessionAddress);
3470 fn on_shutdown(&self);
3472 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3474 fn cleanup_session_materialization(
3476 &self,
3477 address: &SessionAddress,
3478 binding: &SessionBinding,
3479 ) -> Result<(), NodeFailure>;
3480}
3481
3482impl SessionLifecycleHook for NodeShared {
3483 fn on_session_ended(&self, address: &SessionAddress) {
3484 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3485 registry.revoke_session(address);
3486 }
3487 if let Some(control) = self.native_launch_profile_control.as_ref() {
3488 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3489 }
3490 }
3491
3492 fn on_shutdown(&self) {
3493 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3494 let instances = registry.shutdown();
3495 if let Some(control) = self.native_launch_profile_control.as_ref() {
3496 for instance_id in instances {
3497 control.clear_native_mcp_server_launch_overlay(instance_id);
3498 }
3499 }
3500 }
3501 }
3502
3503 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3504 if let Some(control) = self.native_launch_profile_control.as_ref() {
3505 control.clear_native_mcp_server_launch_overlay(instance_id);
3506 }
3507 }
3508
3509 fn cleanup_session_materialization(
3510 &self,
3511 address: &SessionAddress,
3512 binding: &SessionBinding,
3513 ) -> Result<(), NodeFailure> {
3514 self.cleanup_session_owned_materialization(address, binding)
3515 }
3516
3517 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3518 #[cfg(feature = "dig2-station-probe")]
3521 {
3522 crate::dig2_station_lease::release_lease_for_holder(
3523 &self.browser_station_leases,
3524 address.session.instance_id,
3525 );
3526 }
3527 if let Some(control) = self.native_launch_profile_control.as_ref() {
3528 control.clear_native_mcp_server_launch_overlay(
3529 address.session.instance_id,
3530 );
3531 }
3532 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3533 registry.revoke_session(address);
3534 }
3535 if removed
3536 .is_some_and(|binding| {
3537 binding.environment_profile.is_some()
3538 || binding.bundle.is_some()
3539 || binding.context.is_some()
3540 })
3541 {
3542 if let Some(control) = self.native_launch_profile_control.as_ref() {
3543 control.clear_native_launch_profile_selection(
3544 address.session.instance_id,
3545 );
3546 control.clear_native_instance_launch_overlay(
3547 address.session.instance_id,
3548 );
3549 }
3550 }
3551 }
3552}
3553
3554struct SpawnPrepareRequest<'r> {
3555 instance_id: AgentInstanceId,
3556 address: &'r SessionAddress,
3557 provider: &'r AgentId,
3558 mode: SessionMode,
3559 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3560 bundle: Option<&'r ResolvedBundleReceipt>,
3561 context: Option<&'r ResolvedContextPackReceipt>,
3562 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3563 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3564 approval_level: ApprovalLevel,
3565 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3566 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3567}
3568
3569struct PreparedSpawn<'a> {
3573 instance_overlay: Option<NativeInstanceOverlayGuard>,
3574 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3575 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3576 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3577 #[cfg(feature = "dig2-station-probe")]
3578 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3579 materialization_id: Option<MaterializationId>,
3580}
3581
3582impl PreparedSpawn<'_> {
3583 fn retain_session_guards(&mut self) {
3585 if let Some(selection) = self.environment_selection.take() {
3586 selection.retain();
3587 }
3588 if let Some(overlay) = self.instance_overlay.take() {
3589 overlay.retain();
3590 }
3591 if let Some(guard) = self.materialization_guard.take() {
3592 guard.retain();
3593 }
3594 }
3595
3596 fn retain_launch_guards(&mut self) {
3598 if let Some(overlay) = self.harness_mcp_overlay.take() {
3599 overlay.retain();
3600 }
3601 #[cfg(feature = "dig2-station-probe")]
3602 if let Some(guard) = self.browser_station_lease_guard.take() {
3603 guard.retain();
3604 }
3605 }
3606}
3607
3608trait SpawnPreparer {
3612 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3613 fn select_environment(
3615 &self,
3616 instance_id: AgentInstanceId,
3617 provider: &AgentId,
3618 mode: SessionMode,
3619 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3620 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3621 fn install_launch_overlay(
3623 &self,
3624 instance_id: AgentInstanceId,
3625 overlay: PreparedNativeLaunchOverlay,
3626 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3627}
3628
3629impl SpawnPreparer for NodeShared {
3630 fn select_environment(
3631 &self,
3632 instance_id: AgentInstanceId,
3633 provider: &AgentId,
3634 mode: SessionMode,
3635 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3636 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3637 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3638 }
3639
3640 fn install_launch_overlay(
3641 &self,
3642 instance_id: AgentInstanceId,
3643 overlay: PreparedNativeLaunchOverlay,
3644 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3645 self.install_prepared_launch_overlay(instance_id, overlay)
3646 }
3647
3648 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3649 let SpawnPrepareRequest {
3650 instance_id,
3651 address,
3652 provider,
3653 mode,
3654 environment_profile,
3655 bundle,
3656 context,
3657 managed_authority,
3658 harness_mcp,
3659 approval_level,
3660 network_allowlist,
3661 browser_profile_id,
3662 } = request;
3663 #[cfg(feature = "dig2-station-probe")]
3668 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3669 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3670 match acquire_lease(
3671 &self.browser_station_leases,
3672 profile_id,
3673 instance_id,
3674 unix_time_ms(),
3675 ) {
3676 Ok(guard) => Some(guard),
3677 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3678 return Err(failure(
3679 NodeFailureCode::BrowserStationProfileBusy,
3680 "dig2browser station profile is already leased by another session",
3681 ));
3682 }
3683 Err(BrowserStationLeaseError::CapacityExceeded) => {
3684 return Err(failure(
3685 NodeFailureCode::BrowserStationProfileBusy,
3686 "dig2browser station profile lease capacity exhausted",
3687 ));
3688 }
3689 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3690 return Err(failure(
3691 NodeFailureCode::BrowserStationProfileBusy,
3692 "session already holds a dig2browser station profile lease",
3693 ));
3694 }
3695 }
3696 } else {
3697 None
3698 };
3699 #[cfg(not(feature = "dig2-station-probe"))]
3700 let _ = browser_profile_id;
3701 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3702 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3711 {
3712 return Err(failure(
3713 NodeFailureCode::BindingMismatch,
3714 "harness MCP reservation does not match the exact PTY/ACP provider",
3715 ));
3716 }
3717 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3718 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3719 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3720 })?;
3721 let launch = prepared.launch();
3726 let mut env_entries = vec![
3727 (
3728 OsString::from(launch.endpoint_env.as_str()),
3729 prepared.endpoint().as_os_str().to_os_string(),
3730 ),
3731 (
3732 OsString::from(launch.token_env.as_str()),
3733 prepared.token().expose().into(),
3734 ),
3735 (
3736 OsString::from(launch.program_env.as_str()),
3737 prepared.helper_program().as_os_str().to_os_string(),
3738 ),
3739 ];
3740 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3741 env_entries.push(trace_entry);
3742 }
3743 if let Some(door) = crate::door::door_listen_addr() {
3744 env_entries.push((
3745 OsString::from("HATCHERY_NODE_DOOR"),
3746 OsString::from(door.to_string()),
3747 ));
3748 let session_key = format!(
3752 "pty-{}-{}-{}",
3753 self.incarnation_id,
3754 address.workspace_id.as_str(),
3755 address.session.instance_id.0
3756 );
3757 env_entries.push((
3758 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3759 OsString::from(session_key),
3760 ));
3761 }
3762 let overlay = NativeMcpServerLaunchOverlay::new(
3763 provider.clone(),
3764 launch.server_name.as_str(),
3765 prepared.helper_program().as_os_str().to_os_string(),
3766 launch.args.iter().map(OsString::from).collect(),
3767 env_entries,
3768 launch.scrub_env.iter().map(OsString::from).collect(),
3769 ).map_err(|_| failure(
3770 NodeFailureCode::HarnessMcpUnavailable,
3771 "harness MCP launch overlay is unavailable",
3772 ))?;
3773 control
3774 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3775 .map_err(|_| failure(
3776 NodeFailureCode::HarnessMcpUnavailable,
3777 "harness MCP launch overlay could not be installed",
3778 ))?;
3779 tracing::info!(
3785 node_id = %self.node_id,
3786 instance_id = ?instance_id,
3787 provider = %provider,
3788 mode = ?mode,
3789 "harness MCP overlay installed for spawn",
3790 );
3791 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3792 } else {
3793 None
3794 };
3795 let prepared_materialization = self.prepare_session_materialization(
3796 &address,
3797 &provider,
3798 mode,
3799 environment_profile,
3800 bundle,
3801 context,
3802 managed_authority.cloned(),
3803 )?;
3804 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3805 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3806 None => (None, None),
3807 };
3808 let materialization_id = materialization_guard
3809 .as_ref()
3810 .and_then(SessionMaterializationGuard::id)
3811 .cloned();
3812 let mut environment_selection = SpawnPreparer::select_environment(self,
3813 instance_id,
3814 &provider,
3815 mode,
3816 environment_profile,
3817 )?;
3818 let provider_home = materialization_guard
3821 .as_ref()
3822 .and_then(SessionMaterializationGuard::provider_home);
3823 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3824 &provider,
3825 mode,
3826 approval_level,
3827 network_allowlist,
3828 provider_home,
3829 )?;
3830 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3831 (prepared, args) if args.is_empty() => prepared,
3832 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3833 overlay.append_extra_args(args).map_err(|_| {
3834 failure(
3835 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3836 "network allowlist overlay could not merge with instance launch args",
3837 )
3838 })?;
3839 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3840 }
3841 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3842 Some(PreparedNativeLaunchOverlay::Instance(
3843 overlay
3844 .into_instance_with_extra_args(args)
3845 .map_err(|_| {
3846 failure(
3847 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3848 "network allowlist overlay could not merge with environment overlay",
3849 )
3850 })?,
3851 ))
3852 }
3853 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3854 NativeInstanceLaunchOverlay::new(
3855 provider.clone(),
3856 match mode {
3857 SessionMode::Pty => TransportKind::Pty,
3858 SessionMode::Acp => TransportKind::Acp,
3859 SessionMode::Inline => TransportKind::Pipe,
3860 },
3861 Vec::new(),
3862 args,
3863 )
3864 .map_err(|_| {
3865 failure(
3866 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3867 "network allowlist overlay is invalid for this transport",
3868 )
3869 })?,
3870 )),
3871 };
3872 #[cfg(unix)]
3875 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
3877 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
3878 });
3879 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
3880 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
3881 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3882 let uid = unsafe { libc_geteuid() };
3883 let handle = proxy.reserve(&session_key, m4a_provider, uid);
3884 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
3885 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
3886 Some(vec![
3887 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
3888 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
3889 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
3890 ])
3891 }
3892 _ => None,
3893 };
3894 #[cfg(not(unix))]
3895 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
3896 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
3897 (SessionMode::Pty, _, _, Some(identity)) => {
3898 let merged = match environment_overlay {
3899 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
3900 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3901 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3902 }
3903 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3904 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3905 }
3906 }
3907 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
3908 Some(merged)
3909 }
3910 (SessionMode::Pty, Some(accounts), Some(m4a_provider), None) => {
3911 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3912 match accounts.provision(m4a_provider, &session_key) {
3913 Ok(account) => {
3914 let identity = account.env_mutations();
3915 let merged = match environment_overlay {
3916 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3917 .map(PreparedNativeLaunchOverlay::Instance),
3918 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3919 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3920 }
3921 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3922 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3923 }
3924 }
3925 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3926 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3927 self.m4a_live
3928 .lock()
3929 .unwrap_or_else(|p| p.into_inner())
3930 .insert(account.nick.clone(), (instance_id, provider.clone()));
3931 Some(merged)
3932 }
3933 Err(error) => {
3934 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3935 environment_overlay
3936 }
3937 }
3938 }
3939 (SessionMode::Pty, Some(_), None, _) => {
3940 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3941 environment_overlay
3942 }
3943 _ => environment_overlay,
3944 };
3945 let mut instance_overlay = environment_overlay
3946 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3947 .transpose()?
3948 .flatten();
3949 Ok(PreparedSpawn {
3950 instance_overlay,
3951 environment_selection,
3952 materialization_guard,
3953 harness_mcp_overlay,
3954 #[cfg(feature = "dig2-station-probe")]
3955 browser_station_lease_guard,
3956 materialization_id,
3957 })
3958 }
3959}
3960
3961struct SessionRecords {
3962 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3963}
3964
3965struct SpawnIdempotencyCache {
3966 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3967}
3968
3969struct SpawnIdempotencyEntry {
3970 value: SpawnIdempotencyValue,
3971 expires_at: Instant,
3972}
3973
3974enum SpawnIdempotencyValue {
3975 Standard {
3976 spec: SpawnSpec,
3977 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3978 },
3979 Managed {
3980 request: ManagedWorktreeSpawnRequest,
3981 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3982 },
3983 HarnessMcp,
3984}
3985
3986enum DurableManagedSpawnReplayDecision {
3987 Reserved,
3988 PendingLinked,
3989 Committed(ManagedWorktreeSpawnReceipt),
3990}
3991
3992impl SpawnIdempotencyValue {
3993 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3994 match self {
3995 Self::Standard { result, .. } => result
3996 .as_ref()
3997 .ok()
3998 .and_then(|receipt| receipt.context.as_ref()),
3999 Self::Managed { result, .. } => result
4000 .as_ref()
4001 .ok()
4002 .and_then(|receipt| receipt.spawn.context.as_ref()),
4003 Self::HarnessMcp => None,
4004 }
4005 .is_some_and(|receipt| &receipt.id == context_id)
4006 }
4007}
4008
4009#[derive(Clone, Copy)]
4010struct ControllerLease {
4011 connection_id: u64,
4012 expires_at: Instant,
4013}
4014
4015struct NodeEventHistory {
4016 last_sequence: u64,
4017 replay_floor_sequence: u64,
4018 events: VecDeque<NodeEventEnvelope>,
4019 record_providers: BTreeMap<SessionRecordId, AgentId>,
4020 removed_record_providers: BTreeMap<u64, AgentId>,
4021}
4022
4023impl NodeEventHistory {
4024 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4025 Self {
4026 last_sequence: 0,
4027 replay_floor_sequence: 1,
4028 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4029 record_providers,
4030 removed_record_providers: BTreeMap::new(),
4031 }
4032 }
4033}
4034
4035impl NodeShared {
4036 fn new_with_incarnation(
4037 handle: Gate4AgentHandle,
4038 access_token: String,
4039 node_id: NodeId,
4040 incarnation_id: NodeIncarnationId,
4041 workspaces: Vec<WorkspaceConfig>,
4042 enabled_providers: Vec<AgentId>,
4043 provider_runtime_statuses: ProviderRuntimeStatuses,
4044 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4045 provider_contracts: Vec<ProviderContractSupport>,
4046 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4047 spawn_profiles: SpawnProfileRegistry,
4048 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4049 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4050 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4051 state_path: Option<PathBuf>,
4052 records: Vec<ManagedSessionRecord>,
4053 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4054 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4055 materializations: Vec<MaterializationOwnershipRecord>,
4056 persistence_warning: Option<String>,
4057 input_settle_timeout_ms: u64,
4058 ) -> Self {
4059 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4060 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4061 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4062 let (harness_mcp_proxy_event_tx, _) =
4063 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4064 let record_providers = records
4065 .iter()
4066 .map(|record| (record.record_id.clone(), record.provider.clone()))
4067 .collect();
4068 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4069 .iter()
4070 .map(|workspace| {
4071 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4072 })
4073 .collect();
4074 for lease in &managed_worktrees {
4075 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4076 }
4077 let managed_worktree_profiles = workspaces.iter()
4078 .map(|workspace| (
4079 workspace.workspace_id.clone(),
4080 workspace.managed_worktree_profiles.clone(),
4081 ))
4082 .collect();
4083 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4084 managed_worktrees,
4085 managed_worktree_tombstones,
4086 ).expect("durable managed worktree registry was validated while loading");
4087 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4088 managed_worktrees.reattach_record_holders(
4089 &managed_worktree_record_holders(&records),
4090 unix_time_ms(),
4091 );
4092 Self {
4093 handle,
4094 access_token,
4095 node_id,
4096 incarnation_id,
4097 started_at_unix_ms: std::time::SystemTime::now()
4098 .duration_since(std::time::UNIX_EPOCH)
4099 .unwrap_or_default()
4100 .as_millis()
4101 .min(u64::MAX as u128) as u64,
4102 workspaces: RwLock::new(
4103 workspaces
4104 .into_iter()
4105 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4106 .collect(),
4107 ),
4108 worktree_service_modes,
4109 managed_worktree_profiles,
4110 managed_worktrees: Mutex::new(managed_worktrees),
4111 enabled_providers,
4112 provider_runtime_statuses,
4113 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4114 provider_runtime_monitor,
4115 provider_contracts,
4116 provider_adapter_contracts,
4117 spawn_profiles,
4118 environment_profiles: RwLock::new(BTreeMap::new()),
4119 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4120 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4121 #[cfg(feature = "dig2-station-probe")]
4122 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4123 bundle_catalog: RwLock::new(BundleCatalog::default()),
4124 delivery_store: Mutex::new(None),
4125 context_pack_store: Mutex::new(None),
4126 context_catalog: RwLock::new(ContextPackCatalog::default()),
4127 native_launch_profile_control,
4128 harness_mcp_registry: None,
4129 native_session_catalog,
4130 session_environment_materializer,
4131 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4132 m4a_live: Mutex::new(BTreeMap::new()),
4133 #[cfg(unix)]
4134 signer_proxy: std::sync::OnceLock::new(),
4135 materializations: Mutex::new(
4136 materializations
4137 .into_iter()
4138 .map(|record| (record.id().clone(), record))
4139 .collect(),
4140 ),
4141 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4142 entries: BTreeMap::new(),
4143 }),
4144 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4145 controller: Mutex::new(None),
4146 provisional_spawns: Mutex::new(BTreeMap::new()),
4147 pending_acp_options: Mutex::new(BTreeMap::new()),
4148 history: Mutex::new(NodeEventHistory::new(record_providers)),
4149 event_tx,
4150 terminal_event_tx,
4151 agent_stream_event_tx,
4152 harness_mcp_proxy_event_tx,
4153 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4154 pty_input_wake: tokio::sync::Notify::new(),
4155 pty_input_marks: Mutex::new(BTreeMap::new()),
4156 next_connection_id: AtomicU64::new(1),
4157 next_instance_id: AtomicU64::new(1),
4158 next_command_id: AtomicU64::new(1),
4159 shutdown: AtomicBool::new(false),
4160 shutdown_notify: Notify::new(),
4161 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4162 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4163 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4164 mutation_gate: AsyncMutex::new(()),
4165 session_bindings: Mutex::new(BTreeMap::new()),
4166 session_records: Mutex::new(SessionRecords {
4167 records: records
4168 .into_iter()
4169 .map(|record| (record.record_id.clone(), record))
4170 .collect(),
4171 }),
4172 state_path,
4173 persistence_error: RwLock::new(
4174 persistence_warning
4175 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4176 ),
4177 state_transaction: Mutex::new(()),
4178 input_settle_timeout_ms,
4179 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4180 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4181 shell_efficiency_profile: OnceLock::new(),
4182 native_session_gauge: AtomicUsize::new(0),
4183 connection_loop_iterations: AtomicU64::new(0),
4184 connection_events_sent: AtomicU64::new(0),
4185 rejected_commands_total: AtomicU64::new(0),
4186 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4187 drive_loop_iterations_total: AtomicU64::new(0),
4188 drive_loop_iterations_idle: AtomicU64::new(0),
4189 session_record_retention: SessionRecordRetentionConfig::default(),
4190 retired_records_total: AtomicU64::new(0),
4191 #[cfg(feature = "fixture")]
4192 fixture_semantic_hook_policy: false,
4193 #[cfg(feature = "fixture")]
4194 fixture_spawn_managed_worktree_v2_failure_probe:
4195 SpawnManagedWorktreeV2FailureProbe::default(),
4196 }
4197 }
4198
4199 #[cfg(test)]
4200 fn new(
4201 handle: Gate4AgentHandle,
4202 access_token: String,
4203 node_id: NodeId,
4204 workspaces: Vec<WorkspaceConfig>,
4205 enabled_providers: Vec<AgentId>,
4206 ) -> Self {
4207 Self::new_with_incarnation(
4208 handle,
4209 access_token,
4210 node_id,
4211 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4212 workspaces,
4213 enabled_providers,
4214 ProviderRuntimeStatuses::default(),
4215 None,
4216 Vec::new(),
4217 Vec::new(),
4218 SpawnProfileRegistry::default(),
4219 None,
4220 None,
4221 None,
4222 None,
4223 Vec::new(),
4224 Vec::new(),
4225 Vec::new(),
4226 Vec::new(),
4227 None,
4228 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4229 )
4230 }
4231
4232 fn persistence_error(&self) -> Option<String> {
4233 self.persistence_error
4234 .read()
4235 .unwrap_or_else(|poisoned| poisoned.into_inner())
4236 .clone()
4237 }
4238
4239 async fn admit_provider_runtime(
4240 &self,
4241 provider: &AgentId,
4242 requirement: ProviderRuntimeRequirement,
4243 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4244 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4245 let pipe_transport = monitor.supports_pipe_transport(provider);
4246 let acp_transport = monitor.supports_acp_transport(provider);
4247 let provider = provider.clone();
4248 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4249 let (status, admission) = timeout(
4250 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4251 refresh,
4252 )
4253 .await
4254 .map_err(|_| failure(
4255 NodeFailureCode::BackendBusy,
4256 "provider runtime probe exceeded its bounded deadline",
4257 ))?
4258 .map_err(|_| failure(
4259 NodeFailureCode::BackendOperationFailed,
4260 "provider runtime probe task failed",
4261 ))?;
4262 if let Some(status) = status {
4263 self
4264 .provider_runtime_status_updates
4265 .write()
4266 .unwrap_or_else(|poisoned| poisoned.into_inner())
4267 .insert(status.provider().clone(), status);
4268 }
4269 admission.and_then(|policy| {
4270 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4271 })
4272 } else {
4273 crate::provider_runtime::admit_status(
4279 &self.provider_runtime_statuses,
4280 provider,
4281 false,
4282 false,
4283 requirement,
4284 )
4285 };
4286 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4287 admission.map_err(|error| match error {
4288 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4289 NodeFailureCode::BackendOperationFailed,
4290 "provider launcher is unavailable",
4291 ),
4292 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4293 NodeFailureCode::UnsupportedCapability,
4294 "provider semantic capability is not verified",
4295 ),
4296 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4297 NodeFailureCode::BackendBusy,
4298 "provider runtime probe is already in progress",
4299 ),
4300 })
4301 }
4302
4303 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4309 match self.provider_runtime_monitor.as_deref() {
4310 Some(monitor) => monitor.supports_pipe_transport(provider),
4311 None => false,
4312 }
4313 }
4314
4315 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4321 match self.provider_runtime_monitor.as_deref() {
4322 Some(monitor) => monitor.supports_acp_transport(provider),
4323 None => false,
4324 }
4325 }
4326
4327 fn admit_declared_hook_semantics(
4343 &self,
4344 provider: &AgentId,
4345 policy: ProviderRuntimePolicy,
4346 ) -> ProviderRuntimePolicy {
4347 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4348 return policy;
4349 }
4350 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4351 contract.provider == *provider && contract.family == AdapterFamily::Hook
4352 });
4353 if !declares_hook {
4354 return policy;
4355 }
4356 ProviderRuntimePolicy::new(
4357 policy.raw_pty_lifecycle,
4358 policy.semantic_readiness,
4359 policy.structured_prompt,
4360 policy.provider_session_identity,
4361 policy.semantic_resume,
4362 true,
4363 )
4364 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4365 }
4366
4367 fn resolve_network_allowlist_overlay_args(
4382 &self,
4383 provider: &AgentId,
4384 mode: SessionMode,
4385 approval_level: ApprovalLevel,
4386 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4387 provider_home: Option<&Path>,
4388 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4389 let Some(allowlist_id) = network_allowlist else {
4390 return Ok(Vec::new());
4391 };
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
4403 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4405 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4406 |message| {
4407 failure(
4408 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4409 &message,
4410 )
4411 },
4412 )?;
4413 if !matches!(mode, SessionMode::Pty) {
4414 return Err(failure(
4415 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4416 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4417 ));
4418 }
4419 let Some(home) = provider_home else {
4420 return Err(failure(
4421 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4422 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4423 ));
4424 };
4425 let domains =
4426 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4427 if domains.is_empty() {
4428 return Err(failure(
4429 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4430 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4431 ));
4432 }
4433 let settings_path = home.join(
4434 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4435 );
4436 let json =
4437 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4438 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4439 |_| {
4440 failure(
4441 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4442 "Claude station network settings sidecar could not be written under ProviderHome",
4443 )
4444 },
4445 )?;
4446 let overlay =
4447 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4448 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4449 }
4450
4451 let Some(native) = entry.provider_native.as_ref() else {
4452 return Ok(Vec::new());
4453 };
4454 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4455 provider.as_str(),
4456 approval_level,
4457 native,
4458 )
4459 .map_err(|message| {
4460 failure(
4461 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4462 &message,
4463 )
4464 })?;
4465 if overlay.is_empty() {
4466 return Ok(Vec::new());
4467 }
4468 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4469 return Err(failure(
4470 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4471 "Codex network_access -c overlay requires PTY or ACP transport",
4472 ));
4473 }
4474 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4475 }
4476
4477 fn resolve_codex_network_access_overlay_args(
4479 &self,
4480 provider: &AgentId,
4481 mode: SessionMode,
4482 approval_level: ApprovalLevel,
4483 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4484 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4485 self.resolve_network_allowlist_overlay_args(
4486 provider,
4487 mode,
4488 approval_level,
4489 network_allowlist,
4490 None,
4491 )
4492 }
4493
4494 fn resolve_environment_profile(
4495 &self,
4496 resolved: &ResolvedSpawnSpec,
4497 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4498 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4504 let catalog = self
4505 .network_allowlist_catalog
4506 .read()
4507 .unwrap_or_else(|poisoned| poisoned.into_inner());
4508 let Some(entry) = catalog.get(allowlist_id) else {
4509 return Err(failure(
4510 NodeFailureCode::UnknownNetworkAllowlist,
4511 "spawn network allowlist is unavailable on this node",
4512 ));
4513 };
4514 if let Some(native) = entry.provider_native.as_ref() {
4517 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4518 resolved.provider.as_str(),
4519 resolved.approval_level,
4520 native,
4521 ) {
4522 return Err(failure(
4523 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4524 &message,
4525 ));
4526 }
4527 }
4528 }
4529 #[cfg(feature = "dig2-station-probe")]
4538 if resolved.browser_profile_id.is_some() {
4539 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4540 match probe_station_reachable() {
4541 Ok(()) => {}
4542 Err(StationProbeError::UnsupportedPlatform)
4543 | Err(StationProbeError::InvalidPipeSuffix) => {
4544 return Err(failure(
4545 NodeFailureCode::BrowserStationProbeUnavailable,
4546 "dig2browser station probe unavailable on this platform",
4547 ));
4548 }
4549 Err(StationProbeError::Unreachable) => {
4550 return Err(failure(
4551 NodeFailureCode::BrowserStationUnreachable,
4552 "dig2browser station named-pipe is unreachable",
4553 ));
4554 }
4555 }
4556 }
4557 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4558 return Ok(None);
4559 };
4560 let profiles = self
4561 .environment_profiles
4562 .read()
4563 .unwrap_or_else(|poisoned| poisoned.into_inner());
4564 let binding = profiles.get(profile_id).ok_or_else(|| {
4565 failure(
4566 NodeFailureCode::UnknownEnvironmentProfile,
4567 "spawn environment profile is unavailable on this node",
4568 )
4569 })?;
4570 if binding.provider != resolved.provider
4571 || binding.native_profile_id(resolved.mode).is_none()
4572 {
4573 return Err(failure(
4574 NodeFailureCode::EnvironmentProfileBindingMismatch,
4575 "spawn environment profile does not match the provider and mode",
4576 ));
4577 }
4578 Ok(Some(ResolvedEnvironmentProfileReceipt {
4582 profile_id: binding.id.clone(),
4583 profile_revision: binding.revision.clone(),
4584 network_allowlist: resolved.network_allowlist.clone(),
4585 browser_profile_id: resolved.browser_profile_id.clone(),
4586 }))
4587 }
4588
4589 fn resolve_bundle(
4590 &self,
4591 resolved: &ResolvedSpawnSpec,
4592 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4593 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4594 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4595 return Ok(None);
4596 };
4597 let catalog = self.bundle_catalog
4598 .read()
4599 .unwrap_or_else(|poisoned| poisoned.into_inner());
4600 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4601 failure(
4602 NodeFailureCode::UnknownBundle,
4603 "spawn bundle is unavailable on this node",
4604 )
4605 })?;
4606 self.bundle_layout(
4607 &resolved.provider,
4608 resolved.mode,
4609 environment_profile,
4610 bundle,
4611 )?;
4612 Ok(Some(bundle.receipt()))
4613 }
4614
4615 fn begin_delivery_stage(
4616 &self,
4617 manifest: DeliveryBundleManifestV2,
4618 ) -> Result<NodeResponse, NodeFailure> {
4619 let mut store = self
4620 .delivery_store
4621 .lock()
4622 .unwrap_or_else(|poisoned| poisoned.into_inner());
4623 let store = store.as_mut().ok_or_else(|| {
4624 failure(
4625 NodeFailureCode::DeliveryStageStorageFailed,
4626 "node delivery store is unavailable",
4627 )
4628 })?;
4629 let begun = store.begin(manifest).map_err(delivery_failure)?;
4630 Ok(NodeResponse::DeliveryStageBegun {
4631 stage_id: begun.stage_id,
4632 manifest_digest: begun.manifest_digest,
4633 missing_blobs: begun.missing_blobs,
4634 })
4635 }
4636
4637 fn put_delivery_blob_chunk(
4638 &self,
4639 stage_id: DeliveryStageId,
4640 blob_digest: DeliveryBlobDigestV1,
4641 offset: u64,
4642 chunk_hex: DeliveryBlobChunkHexV1,
4643 ) -> Result<NodeResponse, NodeFailure> {
4644 let mut store = self
4645 .delivery_store
4646 .lock()
4647 .unwrap_or_else(|poisoned| poisoned.into_inner());
4648 let store = store.as_mut().ok_or_else(|| {
4649 failure(
4650 NodeFailureCode::DeliveryStageStorageFailed,
4651 "node delivery store is unavailable",
4652 )
4653 })?;
4654 let accepted = store
4655 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4656 .map_err(delivery_failure)?;
4657 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4658 stage_id: accepted.stage_id,
4659 blob_digest: accepted.blob_digest,
4660 next_offset: accepted.next_offset,
4661 })
4662 }
4663
4664 fn commit_delivery_stage(
4665 &self,
4666 stage_id: DeliveryStageId,
4667 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4668 let mut store = self
4669 .delivery_store
4670 .lock()
4671 .unwrap_or_else(|poisoned| poisoned.into_inner());
4672 let store = store.as_mut().ok_or_else(|| {
4673 failure(
4674 NodeFailureCode::DeliveryStageStorageFailed,
4675 "node delivery store is unavailable",
4676 )
4677 })?;
4678 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4679 let mut catalog = self
4680 .bundle_catalog
4681 .write()
4682 .unwrap_or_else(|poisoned| poisoned.into_inner());
4683 let mut next_catalog = catalog.clone();
4684 next_catalog
4685 .insert_idempotent(prepared.bundle().clone())
4686 .map_err(|_| {
4687 failure(
4688 NodeFailureCode::DeliveryStageConflict,
4689 "delivery bundle identity conflicts with installed content",
4690 )
4691 })?;
4692 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4693 *catalog = next_catalog;
4694 Ok(receipt)
4695 }
4696
4697 fn abort_delivery_stage(
4698 &self,
4699 stage_id: &DeliveryStageId,
4700 ) -> Result<(), NodeFailure> {
4701 let mut store = self
4702 .delivery_store
4703 .lock()
4704 .unwrap_or_else(|poisoned| poisoned.into_inner());
4705 let store = store.as_mut().ok_or_else(|| {
4706 failure(
4707 NodeFailureCode::DeliveryStageStorageFailed,
4708 "node delivery store is unavailable",
4709 )
4710 })?;
4711 store.abort(stage_id).map_err(delivery_failure)
4712 }
4713
4714 fn resolve_context(
4715 &self,
4716 resolved: &ResolvedSpawnSpec,
4717 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4718 let Some(context_id) = resolved.context_id.as_ref() else {
4719 return Ok(None);
4720 };
4721 self.context_catalog
4722 .read()
4723 .unwrap_or_else(|poisoned| poisoned.into_inner())
4724 .get(context_id)
4725 .map(|context| context.receipt().clone())
4726 .ok_or_else(|| {
4727 failure(
4728 NodeFailureCode::UnknownContextPack,
4729 "spawn context pack is unavailable on this node",
4730 )
4731 })
4732 .map(Some)
4733 }
4734
4735 fn bundle_layout(
4736 &self,
4737 provider: &AgentId,
4738 mode: SessionMode,
4739 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4740 bundle: &NodeBundle,
4741 ) -> Result<BundleProviderLayout, NodeFailure> {
4742 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4743 failure(
4744 NodeFailureCode::BundleBindingMismatch,
4745 "spawn bundle does not match the provider and mode",
4746 )
4747 })?;
4748 if layout != BundleProviderLayout::Codex {
4749 return Ok(layout);
4750 }
4751 let profile = environment_profile.and_then(|receipt| {
4752 let profiles = self
4753 .environment_profiles
4754 .read()
4755 .unwrap_or_else(|poisoned| poisoned.into_inner());
4756 let binding = profiles.get(&receipt.profile_id)?;
4757 if binding.revision != receipt.profile_revision
4758 || &binding.provider != provider
4759 || binding.native_profile_id(mode).is_none()
4760 {
4761 return None;
4762 }
4763 self.environment_materialization_profiles
4764 .read()
4765 .unwrap_or_else(|poisoned| poisoned.into_inner())
4766 .get(&receipt.profile_id)
4767 .cloned()
4768 });
4769 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4770 return Err(failure(
4771 NodeFailureCode::BundleBindingMismatch,
4772 "Codex bundle requires an exact isolated CODEX_HOME profile",
4773 ));
4774 }
4775 Ok(layout)
4776 }
4777
4778 fn select_environment_profile(
4779 &self,
4780 instance_id: AgentInstanceId,
4781 provider: &AgentId,
4782 mode: SessionMode,
4783 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4784 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4785 let Some(environment_profile) = environment_profile else {
4786 return Ok(None);
4787 };
4788 let native_profile_id = {
4789 let profiles = self
4790 .environment_profiles
4791 .read()
4792 .unwrap_or_else(|poisoned| poisoned.into_inner());
4793 let binding = profiles
4794 .get(&environment_profile.profile_id)
4795 .ok_or_else(|| {
4796 failure(
4797 NodeFailureCode::UnknownEnvironmentProfile,
4798 "session environment profile is unavailable on this node",
4799 )
4800 })?;
4801 if &binding.revision != &environment_profile.profile_revision
4802 || &binding.provider != provider
4803 {
4804 return Err(failure(
4805 NodeFailureCode::EnvironmentProfileBindingMismatch,
4806 "session environment profile revision or provider changed",
4807 ));
4808 }
4809 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4810 failure(
4811 NodeFailureCode::EnvironmentProfileBindingMismatch,
4812 "session environment profile does not support this mode",
4813 )
4814 })?
4815 };
4816 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4817 failure(
4818 NodeFailureCode::BackendOperationFailed,
4819 "native environment profile controller is unavailable",
4820 )
4821 })?;
4822 control
4823 .select_native_launch_profile(instance_id, native_profile_id)
4824 .map_err(|_| {
4825 failure(
4826 NodeFailureCode::BackendOperationFailed,
4827 "native environment profile selection failed",
4828 )
4829 })?;
4830 Ok(Some(NativeEnvironmentSelectionGuard {
4831 control: Some(control),
4832 instance_id,
4833 }))
4834 }
4835
4836 fn install_prepared_launch_overlay(
4837 &self,
4838 instance_id: AgentInstanceId,
4839 overlay: PreparedNativeLaunchOverlay,
4840 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4841 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4842 failure(
4843 NodeFailureCode::BackendOperationFailed,
4844 "native launch profile controller is unavailable",
4845 )
4846 })?;
4847 match overlay {
4848 PreparedNativeLaunchOverlay::Environment(overlay) => {
4849 control
4850 .install_native_launch_environment_overlay(instance_id, overlay)
4851 .map_err(|_| failure(
4852 NodeFailureCode::BackendOperationFailed,
4853 "session environment overlay installation failed",
4854 ))?;
4855 Ok(None)
4856 }
4857 PreparedNativeLaunchOverlay::Instance(overlay) => {
4858 control
4859 .install_native_instance_launch_overlay(instance_id, overlay)
4860 .map_err(|_| failure(
4861 NodeFailureCode::BundleBindingMismatch,
4862 "session bundle launch overlay installation failed",
4863 ))?;
4864 Ok(Some(NativeInstanceOverlayGuard {
4865 control: Some(control),
4866 instance_id,
4867 }))
4868 }
4869 }
4870 }
4871
4872 fn environment_materialization_profile(
4873 &self,
4874 environment_profile: &ResolvedEnvironmentProfileReceipt,
4875 ) -> Option<NodeSessionMaterializationProfile> {
4876 self.environment_materialization_profiles
4877 .read()
4878 .unwrap_or_else(|poisoned| poisoned.into_inner())
4879 .get(&environment_profile.profile_id)
4880 .cloned()
4881 }
4882
4883 fn materialization_profile(
4884 &self,
4885 provider: &AgentId,
4886 mode: SessionMode,
4887 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4888 bundle: Option<&ResolvedBundleReceipt>,
4889 context: Option<&ResolvedContextPackReceipt>,
4890 ) -> Result<Option<(
4891 NodeSessionMaterializationProfile,
4892 bool,
4893 Option<BundleProviderLayout>,
4894 )>, NodeFailure> {
4895 let environment = environment_profile
4896 .and_then(|receipt| self.environment_materialization_profile(receipt));
4897 let (bundle, bundle_layout) = match bundle {
4898 Some(receipt) => {
4899 let catalog = self.bundle_catalog.read()
4900 .unwrap_or_else(|poisoned| poisoned.into_inner());
4901 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4902 NodeFailureCode::UnknownBundle,
4903 "session bundle is unavailable on this node",
4904 ))?;
4905 if installed.receipt() != *receipt {
4906 return Err(failure(
4907 NodeFailureCode::BundleBindingMismatch,
4908 "session bundle revision or digest changed",
4909 ));
4910 }
4911 let layout = self.bundle_layout(
4912 provider,
4913 mode,
4914 environment_profile,
4915 installed,
4916 )?;
4917 (Some(installed.clone()), Some(layout))
4918 }
4919 None => (None, None),
4920 };
4921 let has_environment_overlay = environment.is_some() || context.is_some();
4922 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4923 (None, None, None) => None,
4924 (Some(profile), None, None) => Some(Ok(profile)),
4925 (None, Some(bundle), Some(layout)) => {
4926 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4927 }
4928 (Some(profile), Some(bundle), Some(layout)) => {
4929 Some(profile.with_bundle(bundle, layout))
4930 }
4931 _ => {
4932 return Err(failure(
4933 NodeFailureCode::BundleBindingMismatch,
4934 "session bundle provider layout is unavailable",
4935 ));
4936 }
4937 };
4938 let profile = profile
4939 .transpose()
4940 .map_err(|_| failure(
4941 NodeFailureCode::BundleMaterializationFailed,
4942 "session bundle materialization profile is invalid",
4943 ))?;
4944 let profile = match (profile, context) {
4945 (Some(profile), Some(receipt)) => {
4946 let catalog = self.context_catalog
4947 .read()
4948 .unwrap_or_else(|poisoned| poisoned.into_inner());
4949 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4950 NodeFailureCode::UnknownContextPack,
4951 "session context pack is unavailable on this node",
4952 ))?;
4953 if pack.receipt() != receipt {
4954 return Err(failure(
4955 NodeFailureCode::ContextPackMaterializationFailed,
4956 "session context pack identity changed",
4957 ));
4958 }
4959 profile.with_context(pack)
4960 }
4961 (None, Some(receipt)) => {
4962 let catalog = self.context_catalog
4963 .read()
4964 .unwrap_or_else(|poisoned| poisoned.into_inner());
4965 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4966 NodeFailureCode::UnknownContextPack,
4967 "session context pack is unavailable on this node",
4968 ))?;
4969 if pack.receipt() != receipt {
4970 return Err(failure(
4971 NodeFailureCode::ContextPackMaterializationFailed,
4972 "session context pack identity changed",
4973 ));
4974 }
4975 NodeSessionMaterializationProfile::from_context(pack)
4976 }
4977 (Some(profile), None) => Ok(profile),
4978 (None, None) => return Ok(None),
4979 }
4980 .map_err(|_| failure(
4981 NodeFailureCode::ContextPackMaterializationFailed,
4982 "session context pack materialization profile is invalid",
4983 ))?;
4984 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4985 }
4986
4987 fn prepare_session_materialization<'a>(
4988 &'a self,
4989 address: &SessionAddress,
4990 provider: &AgentId,
4991 mode: SessionMode,
4992 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4993 bundle: Option<&ResolvedBundleReceipt>,
4994 context: Option<&ResolvedContextPackReceipt>,
4995 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4996 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4997 let Some((profile, has_environment_overlay, bundle_layout)) =
4998 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4999 else {
5000 return Ok(None);
5001 };
5002 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5003 failure(
5004 NodeFailureCode::BackendOperationFailed,
5005 "session environment materializer is unavailable",
5006 )
5007 })?;
5008 let materialization_failure_code = if context.is_some() {
5009 NodeFailureCode::ContextPackMaterializationFailed
5010 } else if bundle.is_some() {
5011 NodeFailureCode::BundleMaterializationFailed
5012 } else {
5013 NodeFailureCode::BackendOperationFailed
5014 };
5015 let id = MaterializationId::new(format!(
5016 "mat-{}-{}-{}",
5017 self.incarnation_id,
5018 address.session.instance_id.0,
5019 address.session.generation.0,
5020 ))
5021 .map_err(|_| {
5022 failure(
5023 NodeFailureCode::BackendOperationFailed,
5024 "session environment materialization identity failed",
5025 )
5026 })?;
5027 let owner = MaterializationOwner::Session {
5028 incarnation_id: self.incarnation_id,
5029 instance_id: address.session.instance_id,
5030 generation: address.session.generation,
5031 };
5032 let mut ownership = materializer
5033 .begin(
5034 id.clone(),
5035 environment_profile.cloned(),
5036 bundle.cloned(),
5037 context.cloned(),
5038 owner,
5039 managed_lease_id,
5040 &profile,
5041 unix_time_ms(),
5042 )
5043 .map_err(|_| {
5044 failure(
5045 materialization_failure_code,
5046 "session materialization preparation failed",
5047 )
5048 })?;
5049 {
5050 let _transaction = self
5051 .state_transaction
5052 .lock()
5053 .unwrap_or_else(|poisoned| poisoned.into_inner());
5054 let duplicate = self
5055 .materializations
5056 .lock()
5057 .unwrap_or_else(|poisoned| poisoned.into_inner())
5058 .insert(id.clone(), ownership.clone());
5059 if duplicate.is_some() {
5060 return Err(failure(
5061 NodeFailureCode::BackendOperationFailed,
5062 "session environment materialization identity conflict",
5063 ));
5064 }
5065 if let Err(error) = self.persist_state_locked() {
5066 self.materializations
5067 .lock()
5068 .unwrap_or_else(|poisoned| poisoned.into_inner())
5069 .remove(&id);
5070 return Err(persistence_failure(error));
5071 }
5072 }
5073 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5074 Ok(environment) => environment,
5075 Err(_) => {
5076 let transaction = self
5077 .state_transaction
5078 .lock()
5079 .unwrap_or_else(|poisoned| poisoned.into_inner());
5080 self.materializations
5081 .lock()
5082 .unwrap_or_else(|poisoned| poisoned.into_inner())
5083 .insert(id.clone(), ownership.clone());
5084 if self.persist_state_locked().is_err() {
5085 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5086 }
5087 drop(transaction);
5088 if ownership.state() == MaterializationState::CleanupRequired {
5089 let _ = self.cleanup_materialization(&id);
5090 }
5091 return Err(failure(
5092 materialization_failure_code,
5093 "session materialization failed",
5094 ));
5095 }
5096 };
5097 {
5098 let _transaction = self
5099 .state_transaction
5100 .lock()
5101 .unwrap_or_else(|poisoned| poisoned.into_inner());
5102 self.materializations
5103 .lock()
5104 .unwrap_or_else(|poisoned| poisoned.into_inner())
5105 .insert(id.clone(), ownership.clone());
5106 if let Err(error) = self.persist_state_locked() {
5107 drop(_transaction);
5108 let _ = self.cleanup_materialization(&id);
5109 return Err(persistence_failure(error));
5110 }
5111 }
5112 let guard = SessionMaterializationGuard {
5113 shared: self,
5114 id: Some(id),
5115 provider_home: Some(ownership.provider_home().to_path_buf()),
5116 };
5117 let bundle_arguments = match bundle {
5118 Some(receipt) => {
5119 let catalog = self.bundle_catalog.read()
5120 .unwrap_or_else(|poisoned| poisoned.into_inner());
5121 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5122 NodeFailureCode::UnknownBundle,
5123 "session bundle is unavailable on this node",
5124 ))?;
5125 if installed.receipt() != *receipt {
5126 return Err(failure(
5127 NodeFailureCode::BundleBindingMismatch,
5128 "session bundle revision or digest changed",
5129 ));
5130 }
5131 let layout = bundle_layout.ok_or_else(|| failure(
5132 NodeFailureCode::BundleBindingMismatch,
5133 "materialized bundle provider layout is unavailable",
5134 ))?;
5135 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5136 failure(
5137 NodeFailureCode::BundleMaterializationFailed,
5138 "session bundle byte revalidation failed",
5139 )
5140 })?;
5141 Some(bundle_launch_arguments(
5142 layout,
5143 ownership.bundle_root(),
5144 ownership.provider_home(),
5145 ).map_err(|_| failure(
5146 NodeFailureCode::BundleBindingMismatch,
5147 "session bundle launch binding is invalid",
5148 ))?)
5149 }
5150 None => None,
5151 };
5152 if let Some(receipt) = context {
5153 materializer
5154 .revalidate_context(&ownership, receipt)
5155 .map_err(|_| failure(
5156 NodeFailureCode::ContextPackMaterializationFailed,
5157 "session context pack byte revalidation failed",
5158 ))?;
5159 }
5160 let transport = match mode {
5161 SessionMode::Pty => TransportKind::Pty,
5162 SessionMode::Inline => TransportKind::Pipe,
5163 SessionMode::Acp => TransportKind::Acp,
5164 };
5165 let overlay = if let Some(extra_args) = bundle_arguments {
5166 Some(PreparedNativeLaunchOverlay::Instance(
5167 NativeInstanceLaunchOverlay::new(
5168 provider.clone(),
5169 transport,
5170 if has_environment_overlay {
5171 environment
5172 } else {
5173 Vec::new()
5174 },
5175 extra_args,
5176 )
5177 .map_err(|_| {
5178 failure(
5179 NodeFailureCode::BundleBindingMismatch,
5180 "session bundle launch overlay is invalid",
5181 )
5182 })?,
5183 ))
5184 } else if has_environment_overlay {
5185 let context_only = environment_profile.is_none()
5186 && bundle.is_none()
5187 && context.is_some();
5188 let overlay = if context_only {
5189 NativeLaunchEnvironmentOverlay::new_context_root(
5190 provider.clone(),
5191 transport,
5192 environment,
5193 )
5194 .map_err(|_| {
5195 failure(
5196 NodeFailureCode::ContextPackMaterializationFailed,
5197 "context-only launch overlay is invalid",
5198 )
5199 })?
5200 } else {
5201 NativeLaunchEnvironmentOverlay::new(
5202 provider.clone(),
5203 transport,
5204 environment,
5205 )
5206 .map_err(|_| {
5207 failure(
5208 NodeFailureCode::BackendOperationFailed,
5209 "session environment overlay is invalid",
5210 )
5211 })?
5212 };
5213 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5214 } else {
5215 None
5216 };
5217 Ok(Some((overlay, guard)))
5218 }
5219
5220 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5221 let Some(mut ownership) = self
5222 .materializations
5223 .lock()
5224 .unwrap_or_else(|poisoned| poisoned.into_inner())
5225 .get(id)
5226 .cloned()
5227 else {
5228 return Ok(());
5229 };
5230 if ownership.state() != MaterializationState::CleanupRequired {
5231 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5232 failure(
5233 NodeFailureCode::BackendOperationFailed,
5234 "session environment cleanup state is invalid",
5235 )
5236 })?;
5237 }
5238 {
5239 let _transaction = self
5240 .state_transaction
5241 .lock()
5242 .unwrap_or_else(|poisoned| poisoned.into_inner());
5243 let previous = self
5244 .materializations
5245 .lock()
5246 .unwrap_or_else(|poisoned| poisoned.into_inner())
5247 .insert(id.clone(), ownership.clone());
5248 if let Err(error) = self.persist_state_locked() {
5249 let mut materializations = self
5250 .materializations
5251 .lock()
5252 .unwrap_or_else(|poisoned| poisoned.into_inner());
5253 if let Some(previous) = previous {
5254 materializations.insert(id.clone(), previous);
5255 }
5256 return Err(persistence_failure(error));
5257 }
5258 }
5259 let cleanup = self
5260 .session_environment_materializer
5261 .as_ref()
5262 .ok_or(())
5263 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5264 if cleanup.is_err() {
5265 let _ = ownership.mark_recovery_required(unix_time_ms());
5266 self.materializations
5267 .lock()
5268 .unwrap_or_else(|poisoned| poisoned.into_inner())
5269 .insert(id.clone(), ownership);
5270 if self.persist_state().is_err() {
5271 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5272 }
5273 return Err(failure(
5274 NodeFailureCode::BackendOperationFailed,
5275 "session environment cleanup requires recovery",
5276 ));
5277 }
5278 let _transaction = self
5279 .state_transaction
5280 .lock()
5281 .unwrap_or_else(|poisoned| poisoned.into_inner());
5282 self.materializations
5283 .lock()
5284 .unwrap_or_else(|poisoned| poisoned.into_inner())
5285 .remove(id);
5286 if let Err(error) = self.persist_state_locked() {
5287 self.materializations
5288 .lock()
5289 .unwrap_or_else(|poisoned| poisoned.into_inner())
5290 .insert(id.clone(), ownership);
5291 return Err(persistence_failure(error));
5292 }
5293 Ok(())
5294 }
5295
5296 fn resolve_record_materialization(
5297 &self,
5298 record_id: &SessionRecordId,
5299 provider: &AgentId,
5300 mode: SessionMode,
5301 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5302 bundle: Option<&ResolvedBundleReceipt>,
5303 context: Option<&ResolvedContextPackReceipt>,
5304 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5305 let ownership = {
5306 let materializations = self
5307 .materializations
5308 .lock()
5309 .unwrap_or_else(|poisoned| poisoned.into_inner());
5310 let mut matches = materializations.values().filter(|record| {
5311 record.owner()
5312 == &MaterializationOwner::Record {
5313 record_id: record_id.clone(),
5314 }
5315 });
5316 let ownership = matches.next().cloned();
5317 if matches.next().is_some() {
5318 return Err(failure(
5319 NodeFailureCode::BackendOperationFailed,
5320 "managed session has conflicting environment ownership",
5321 ));
5322 }
5323 ownership
5324 };
5325 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5326 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5327 failure(
5328 NodeFailureCode::ContextPackMaterializationFailed,
5329 "session context materializer is unavailable",
5330 )
5331 })?;
5332 let pack = materializer
5333 .revalidate_context(ownership, receipt)
5334 .map_err(|_| {
5335 self.mark_materialization_recovery_required(ownership.id());
5336 failure(
5337 NodeFailureCode::ContextPackMaterializationFailed,
5338 "managed session context pack revalidation failed",
5339 )
5340 })?;
5341 self.context_catalog
5342 .write()
5343 .unwrap_or_else(|poisoned| poisoned.into_inner())
5344 .insert(pack)
5345 .map_err(|_| failure(
5346 NodeFailureCode::ContextPackMaterializationFailed,
5347 "managed session context pack catalog restore failed",
5348 ))?;
5349 }
5350 let materialization = self.materialization_profile(
5351 provider,
5352 mode,
5353 environment_profile,
5354 bundle,
5355 context,
5356 )?;
5357 match (ownership, materialization) {
5358 (None, None) => Ok(None),
5359 (None, Some(_)) | (Some(_), None) => Err(failure(
5360 NodeFailureCode::BackendOperationFailed,
5361 "managed session materialization is unavailable",
5362 )),
5363 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5364 if ownership.environment_profile() != environment_profile
5365 || ownership.bundle() != bundle
5366 || ownership.context() != context
5367 || ownership.state() != MaterializationState::Ready
5368 {
5369 self.mark_materialization_recovery_required(ownership.id());
5370 return Err(failure(
5371 NodeFailureCode::BackendOperationFailed,
5372 "managed session environment ownership requires recovery",
5373 ));
5374 }
5375 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5376 failure(
5377 NodeFailureCode::BackendOperationFailed,
5378 "session environment materializer is unavailable",
5379 )
5380 })?;
5381 if materializer.revalidate(&ownership).is_err() {
5382 self.mark_materialization_recovery_required(ownership.id());
5383 return Err(failure(
5384 NodeFailureCode::BackendOperationFailed,
5385 "managed session environment revalidation failed",
5386 ));
5387 }
5388 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5389 let catalog = self.bundle_catalog.read()
5390 .unwrap_or_else(|poisoned| poisoned.into_inner());
5391 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5392 return Err(failure(
5393 NodeFailureCode::UnknownBundle,
5394 "managed session bundle is unavailable",
5395 ));
5396 };
5397 let layout = bundle_layout.ok_or_else(|| failure(
5398 NodeFailureCode::BundleBindingMismatch,
5399 "record bundle provider layout is unavailable",
5400 ))?;
5401 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5402 drop(catalog);
5403 self.mark_materialization_recovery_required(ownership.id());
5404 return Err(failure(
5405 NodeFailureCode::BundleMaterializationFailed,
5406 "managed session bundle revalidation failed",
5407 ));
5408 }
5409 Some(bundle_launch_arguments(
5410 layout,
5411 ownership.bundle_root(),
5412 ownership.provider_home(),
5413 ).map_err(|_| failure(
5414 NodeFailureCode::BundleBindingMismatch,
5415 "managed session bundle launch binding is invalid",
5416 ))?)
5417 } else {
5418 None
5419 };
5420 if !has_environment_materialization {
5421 let overlay = bundle_arguments.map(|extra_args| {
5422 NativeInstanceLaunchOverlay::new(
5423 provider.clone(),
5424 TransportKind::Pty,
5425 Vec::new(),
5426 extra_args,
5427 )
5428 .map(PreparedNativeLaunchOverlay::Instance)
5429 .map_err(|_| failure(
5430 NodeFailureCode::BundleBindingMismatch,
5431 "managed session bundle launch overlay is invalid",
5432 ))
5433 }).transpose()?;
5434 return Ok(Some((ownership.id().clone(), overlay)));
5435 }
5436 let environment = match materializer.resolve_environment(&ownership, &profile) {
5437 Ok(environment) => environment,
5438 Err(
5439 SessionEnvironmentMaterializeError::SecretUnavailable
5440 | SessionEnvironmentMaterializeError::SecretDenied
5441 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5442 ) => {
5443 return Err(failure(
5444 NodeFailureCode::BackendOperationFailed,
5445 "managed session environment resolution failed",
5446 ));
5447 }
5448 Err(
5449 SessionEnvironmentMaterializeError::InvalidRoot
5450 | SessionEnvironmentMaterializeError::OwnershipMismatch
5451 | SessionEnvironmentMaterializeError::Filesystem(_)
5452 | SessionEnvironmentMaterializeError::Record(_),
5453 ) => {
5454 self.mark_materialization_recovery_required(ownership.id());
5455 return Err(failure(
5456 NodeFailureCode::BackendOperationFailed,
5457 "managed session environment resolution failed",
5458 ));
5459 }
5460 };
5461 let transport = match mode {
5462 SessionMode::Pty => TransportKind::Pty,
5463 SessionMode::Inline => TransportKind::Pipe,
5464 SessionMode::Acp => TransportKind::Acp,
5465 };
5466 let overlay = match bundle_arguments {
5467 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5468 NativeInstanceLaunchOverlay::new(
5469 provider.clone(),
5470 transport,
5471 environment,
5472 extra_args,
5473 )
5474 .map_err(|_| failure(
5475 NodeFailureCode::BundleBindingMismatch,
5476 "managed session bundle launch overlay is invalid",
5477 ))?,
5478 ),
5479 None => PreparedNativeLaunchOverlay::Environment(
5480 NativeLaunchEnvironmentOverlay::new(
5481 provider.clone(),
5482 transport,
5483 environment,
5484 )
5485 .map_err(|_| {
5486 failure(
5487 NodeFailureCode::BackendOperationFailed,
5488 "managed session environment overlay is invalid",
5489 )
5490 })?,
5491 ),
5492 };
5493 Ok(Some((ownership.id().clone(), Some(overlay))))
5494 }
5495 }
5496 }
5497
5498 fn destroy_record_materialization(
5499 &self,
5500 record_id: &SessionRecordId,
5501 ) -> Result<bool, NodeFailure> {
5502 let ids = self
5503 .materializations
5504 .lock()
5505 .unwrap_or_else(|poisoned| poisoned.into_inner())
5506 .values()
5507 .filter_map(|record| {
5508 (record.owner()
5509 == &MaterializationOwner::Record {
5510 record_id: record_id.clone(),
5511 })
5512 .then(|| record.id().clone())
5513 })
5514 .collect::<Vec<_>>();
5515 if ids.len() > 1 {
5516 return Err(failure(
5517 NodeFailureCode::BackendOperationFailed,
5518 "managed session has conflicting environment ownership",
5519 ));
5520 }
5521 let Some(id) = ids.first().cloned() else {
5522 return Ok(false);
5523 };
5524 let (terminal_record, mut cleanup_ownership) = {
5525 let _transaction = self
5526 .state_transaction
5527 .lock()
5528 .unwrap_or_else(|poisoned| poisoned.into_inner());
5529 let original_record = self.record(record_id)?;
5530 let mut terminal_record = original_record.clone();
5531 terminal_record.provider_session = None;
5532 terminal_record.active_session = None;
5533 terminal_record.state = ManagedSessionState::Unavailable;
5534 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5535 terminal_record.updated_at_unix_ms = unix_time_ms();
5536 let original_ownership = self
5537 .materializations
5538 .lock()
5539 .unwrap_or_else(|poisoned| poisoned.into_inner())
5540 .get(&id)
5541 .cloned()
5542 .ok_or_else(|| {
5543 failure(
5544 NodeFailureCode::BackendOperationFailed,
5545 "managed session environment ownership disappeared during cleanup",
5546 )
5547 })?;
5548 let mut cleanup_ownership = original_ownership.clone();
5549 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5550 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5551 failure(
5552 NodeFailureCode::BackendOperationFailed,
5553 "managed session environment cleanup state is invalid",
5554 )
5555 })?;
5556 }
5557 self.session_records
5558 .lock()
5559 .unwrap_or_else(|poisoned| poisoned.into_inner())
5560 .records
5561 .insert(record_id.clone(), terminal_record.clone());
5562 self.materializations
5563 .lock()
5564 .unwrap_or_else(|poisoned| poisoned.into_inner())
5565 .insert(id.clone(), cleanup_ownership.clone());
5566 if let Err(error) = self.persist_state_locked() {
5567 self.session_records
5568 .lock()
5569 .unwrap_or_else(|poisoned| poisoned.into_inner())
5570 .records
5571 .insert(record_id.clone(), original_record);
5572 self.materializations
5573 .lock()
5574 .unwrap_or_else(|poisoned| poisoned.into_inner())
5575 .insert(id.clone(), original_ownership);
5576 return Err(persistence_failure(error));
5577 }
5578 (terminal_record, cleanup_ownership)
5579 };
5580 let cleanup = self
5581 .session_environment_materializer
5582 .as_ref()
5583 .ok_or(())
5584 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5585 if cleanup.is_err() {
5586 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5587 self.materializations
5588 .lock()
5589 .unwrap_or_else(|poisoned| poisoned.into_inner())
5590 .insert(id.clone(), cleanup_ownership);
5591 if self.persist_state().is_err() {
5592 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5593 }
5594 return Err(failure(
5595 NodeFailureCode::BackendOperationFailed,
5596 "managed session environment cleanup requires recovery",
5597 ));
5598 }
5599 let _transaction = self
5600 .state_transaction
5601 .lock()
5602 .unwrap_or_else(|poisoned| poisoned.into_inner());
5603 self.remove_record_memory(record_id);
5604 self.materializations
5605 .lock()
5606 .unwrap_or_else(|poisoned| poisoned.into_inner())
5607 .remove(&id);
5608 if let Err(error) = self.persist_state_locked() {
5609 self.session_records
5610 .lock()
5611 .unwrap_or_else(|poisoned| poisoned.into_inner())
5612 .records
5613 .insert(record_id.clone(), terminal_record);
5614 self.materializations
5615 .lock()
5616 .unwrap_or_else(|poisoned| poisoned.into_inner())
5617 .insert(id, cleanup_ownership);
5618 return Err(persistence_failure(error));
5619 }
5620 Ok(true)
5621 }
5622
5623 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5624 let changed = {
5625 let mut materializations = self
5626 .materializations
5627 .lock()
5628 .unwrap_or_else(|poisoned| poisoned.into_inner());
5629 let Some(ownership) = materializations.get_mut(id) else {
5630 return;
5631 };
5632 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5633 };
5634 if changed && self.persist_state().is_err() {
5635 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5636 }
5637 }
5638
5639 fn reconcile_materializations(&self) {
5640 let records = self
5641 .materializations
5642 .lock()
5643 .unwrap_or_else(|poisoned| poisoned.into_inner())
5644 .values()
5645 .cloned()
5646 .collect::<Vec<_>>();
5647 for mut ownership in records {
5648 let result = match (ownership.state(), ownership.owner()) {
5649 (MaterializationState::Preparing, _)
5650 | (MaterializationState::CleanupRequired, _) => {
5651 self.cleanup_materialization(ownership.id())
5652 }
5653 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5654 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5655 self.materializations
5656 .lock()
5657 .unwrap_or_else(|poisoned| poisoned.into_inner())
5658 .insert(ownership.id().clone(), ownership.clone());
5659 self.persist_state().map_err(persistence_failure)
5660 } else {
5661 Err(failure(
5662 NodeFailureCode::BackendOperationFailed,
5663 "session environment reconciliation failed",
5664 ))
5665 }
5666 }
5667 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5668 .session_environment_materializer
5669 .as_ref()
5670 .ok_or_else(|| {
5671 failure(
5672 NodeFailureCode::BackendOperationFailed,
5673 "session environment materializer is unavailable",
5674 )
5675 })
5676 .and_then(|materializer| {
5677 materializer.revalidate(&ownership).map_err(|_| failure(
5678 NodeFailureCode::BackendOperationFailed,
5679 "session materialization revalidation failed",
5680 ))?;
5681 let record = self.record(record_id)?;
5682 if record.environment_profile.as_ref() != ownership.environment_profile()
5683 || record.bundle.as_ref() != ownership.bundle()
5684 || record.context.as_ref() != ownership.context()
5685 {
5686 return Err(failure(
5687 NodeFailureCode::BackendOperationFailed,
5688 "session materialization ownership changed after restart",
5689 ));
5690 }
5691 if let Some(receipt) = ownership.bundle() {
5692 let catalog = self.bundle_catalog.read()
5693 .unwrap_or_else(|poisoned| poisoned.into_inner());
5694 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5695 NodeFailureCode::UnknownBundle,
5696 "session bundle is unavailable after restart",
5697 ))?;
5698 if bundle.receipt() != *receipt {
5699 return Err(failure(
5700 NodeFailureCode::BundleBindingMismatch,
5701 "session bundle changed after restart",
5702 ));
5703 }
5704 let layout = self.bundle_layout(
5705 &record.provider,
5706 record.mode,
5707 record.environment_profile.as_ref(),
5708 bundle,
5709 )?;
5710 materializer
5711 .revalidate_bundle(&ownership, bundle, layout)
5712 .map_err(|_| failure(
5713 NodeFailureCode::BundleMaterializationFailed,
5714 "session bundle revalidation failed",
5715 ))?;
5716 }
5717 if let Some(receipt) = ownership.context() {
5718 let pack = materializer
5719 .revalidate_context(&ownership, receipt)
5720 .map_err(|_| {
5721 failure(
5722 NodeFailureCode::ContextPackMaterializationFailed,
5723 "session context revalidation failed",
5724 )
5725 })?;
5726 self.context_catalog
5727 .write()
5728 .unwrap_or_else(|poisoned| poisoned.into_inner())
5729 .insert(pack)
5730 .map_err(|_| {
5731 failure(
5732 NodeFailureCode::ContextPackMaterializationFailed,
5733 "session context catalog restore failed",
5734 )
5735 })?;
5736 }
5737 Ok(())
5738 }),
5739 (MaterializationState::RecoveryRequired, _) => Ok(()),
5740 };
5741 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5742 let _ = ownership.mark_recovery_required(unix_time_ms());
5743 self.materializations
5744 .lock()
5745 .unwrap_or_else(|poisoned| poisoned.into_inner())
5746 .insert(ownership.id().clone(), ownership);
5747 if self.persist_state().is_err() {
5748 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5749 }
5750 }
5751 }
5752 let materialized_profiles = self
5753 .environment_materialization_profiles
5754 .read()
5755 .unwrap_or_else(|poisoned| poisoned.into_inner())
5756 .keys()
5757 .cloned()
5758 .collect::<Vec<_>>();
5759 let ownership = self
5760 .materializations
5761 .lock()
5762 .unwrap_or_else(|poisoned| poisoned.into_inner())
5763 .values()
5764 .cloned()
5765 .collect::<Vec<_>>();
5766 let changed = {
5767 let mut session_records = self
5768 .session_records
5769 .lock()
5770 .unwrap_or_else(|poisoned| poisoned.into_inner());
5771 let mut changed = false;
5772 for record in session_records.records.values_mut() {
5773 let environment_profile = record.environment_profile.as_ref();
5774 if environment_profile.is_none()
5775 && record.bundle.is_none()
5776 && record.context.is_none()
5777 {
5778 continue;
5779 }
5780 if record.bundle.is_none()
5781 && environment_profile.is_some_and(|profile| {
5782 !materialized_profiles.contains(&profile.profile_id)
5783 })
5784 {
5785 continue;
5786 }
5787 let has_exact_ownership = ownership.iter().any(|candidate| {
5788 candidate.state() == MaterializationState::Ready
5789 && candidate.environment_profile() == environment_profile
5790 && candidate.bundle() == record.bundle.as_ref()
5791 && candidate.context() == record.context.as_ref()
5792 && candidate.owner()
5793 == &MaterializationOwner::Record {
5794 record_id: record.record_id.clone(),
5795 }
5796 });
5797 if !has_exact_ownership {
5798 record.provider_session = None;
5799 record.active_session = None;
5800 record.state = ManagedSessionState::Unavailable;
5801 record.last_error = Some(if record.bundle.is_some() {
5802 "bundle-unavailable".to_owned()
5803 } else if record.context.is_some() {
5804 "context-unavailable".to_owned()
5805 } else {
5806 "environment-profile-unavailable".to_owned()
5807 });
5808 record.updated_at_unix_ms = unix_time_ms();
5809 changed = true;
5810 }
5811 }
5812 changed
5813 };
5814 if changed && self.persist_state().is_err() {
5815 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5816 }
5817 }
5818
5819 fn replay_spawn_spec(
5820 &self,
5821 spec: &SpawnSpec,
5822 now: Instant,
5823 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5824 let mut cache = self
5825 .spawn_idempotency
5826 .lock()
5827 .unwrap_or_else(|poisoned| poisoned.into_inner());
5828 cache.entries.retain(|_, entry| entry.expires_at > now);
5829 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5830 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5831 return Err(failure(
5832 NodeFailureCode::SpawnIdempotencyConflict,
5833 "spawn idempotency key was reused across spawn kinds",
5834 ));
5835 };
5836 if existing != spec {
5837 return Err(failure(
5838 NodeFailureCode::SpawnIdempotencyConflict,
5839 "spawn idempotency key was reused with a different specification",
5840 ));
5841 }
5842 return Ok(Some(result.clone()));
5843 }
5844 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5845 return Err(failure(
5846 NodeFailureCode::SpawnIdempotencyCapacity,
5847 "spawn idempotency cache reached its bounded capacity",
5848 ));
5849 }
5850 Ok(None)
5851 }
5852
5853 fn remember_spawn_spec(
5854 &self,
5855 spec: SpawnSpec,
5856 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5857 accepted_at: Instant,
5858 ) {
5859 self
5860 .spawn_idempotency
5861 .lock()
5862 .unwrap_or_else(|poisoned| poisoned.into_inner())
5863 .entries
5864 .insert(
5865 spec.idempotency_key.clone(),
5866 SpawnIdempotencyEntry {
5867 value: SpawnIdempotencyValue::Standard { spec, result },
5868 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5869 },
5870 );
5871 }
5872
5873 fn claim_harness_mcp_spawn(
5874 &self,
5875 _reservation_id: &HarnessMcpReservationId,
5876 _activation_digest: &HarnessMcpActivationDigest,
5877 spec: &SpawnSpec,
5878 now: Instant,
5879 ) -> Result<(), NodeFailure> {
5880 let mut cache = self.spawn_idempotency.lock()
5881 .unwrap_or_else(|poisoned| poisoned.into_inner());
5882 cache.entries.retain(|_, entry| entry.expires_at > now);
5883 if cache.entries.contains_key(&spec.idempotency_key) {
5884 return Err(failure(
5885 NodeFailureCode::SpawnIdempotencyConflict,
5886 "spawn idempotency key was reused across spawn kinds",
5887 ));
5888 }
5889 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5890 return Err(failure(
5891 NodeFailureCode::SpawnIdempotencyCapacity,
5892 "spawn idempotency cache reached its bounded capacity",
5893 ));
5894 }
5895 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5896 value: SpawnIdempotencyValue::HarnessMcp,
5897 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5898 });
5899 Ok(())
5900 }
5901
5902 fn replay_managed_spawn(
5903 &self,
5904 request: &ManagedWorktreeSpawnRequest,
5905 now: Instant,
5906 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5907 let mut cache = self.spawn_idempotency.lock()
5908 .unwrap_or_else(|poisoned| poisoned.into_inner());
5909 cache.entries.retain(|_, entry| entry.expires_at > now);
5910 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5911 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5912 return Err(failure(
5913 NodeFailureCode::SpawnIdempotencyConflict,
5914 "spawn idempotency key was reused across spawn kinds",
5915 ));
5916 };
5917 if existing != request {
5918 return Err(failure(
5919 NodeFailureCode::SpawnIdempotencyConflict,
5920 "spawn idempotency key was reused with a different managed specification",
5921 ));
5922 }
5923 return Ok(Some(result.clone()));
5924 }
5925 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5926 return Err(failure(
5927 NodeFailureCode::SpawnIdempotencyCapacity,
5928 "spawn idempotency cache reached its bounded capacity",
5929 ));
5930 }
5931 Ok(None)
5932 }
5933
5934 fn remember_managed_spawn(
5935 &self,
5936 request: ManagedWorktreeSpawnRequest,
5937 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5938 accepted_at: Instant,
5939 ) {
5940 self.spawn_idempotency.lock()
5941 .unwrap_or_else(|poisoned| poisoned.into_inner())
5942 .entries
5943 .insert(
5944 request.spawn_spec.idempotency_key.clone(),
5945 SpawnIdempotencyEntry {
5946 value: SpawnIdempotencyValue::Managed { request, result },
5947 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5948 },
5949 );
5950 }
5951
5952 fn remember_managed_spawn_attempt(
5953 &self,
5954 durable_v2_key: Option<&SpawnIdempotencyKey>,
5955 request: ManagedWorktreeSpawnRequest,
5956 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5957 accepted_at: Instant,
5958 ) {
5959 if durable_v2_key.is_none() {
5960 self.remember_managed_spawn(request, result, accepted_at);
5961 }
5962 }
5963
5964 fn acquire_durable_managed_spawn_v2(
5965 &self,
5966 request: &ManagedWorktreeSpawnRequestV2,
5967 request_digest: String,
5968 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5969 if self.state_path.is_none() {
5970 return Err(failure(
5971 NodeFailureCode::BackendOperationFailed,
5972 "managed worktree V2 requires durable node state",
5973 ));
5974 }
5975 let _transaction = self.state_transaction
5976 .lock()
5977 .unwrap_or_else(|poisoned| poisoned.into_inner());
5978 let key = request.spawn_spec.idempotency_key.clone();
5979 let mut previous = None;
5980 {
5981 let mut replays = self.managed_spawn_replays
5982 .lock()
5983 .unwrap_or_else(|poisoned| poisoned.into_inner());
5984 if let Some(record) = replays.get_mut(&key) {
5985 if record.request_digest != request_digest {
5986 return Err(failure(
5987 NodeFailureCode::SpawnIdempotencyConflict,
5988 "managed worktree V2 idempotency key was reused with a different request",
5989 ));
5990 }
5991 match &record.state {
5992 ManagedWorktreeSpawnReplayStateV10::Pending {
5993 owner_incarnation_id,
5994 lease_id: None,
5995 } if owner_incarnation_id != &self.incarnation_id => {
5996 previous = Some(record.clone());
5997 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5998 owner_incarnation_id: self.incarnation_id,
5999 lease_id: None,
6000 };
6001 record.updated_at_unix_ms = unix_time_ms();
6002 }
6003 ManagedWorktreeSpawnReplayStateV10::Pending {
6004 lease_id: None,
6005 ..
6006 } => {
6007 return Err(failure(
6008 NodeFailureCode::BackendBusy,
6009 "managed worktree V2 reservation is already active",
6010 ));
6011 }
6012 ManagedWorktreeSpawnReplayStateV10::Pending {
6013 lease_id: Some(_),
6014 ..
6015 } => {
6016 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6017 }
6018 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6019 return Ok(DurableManagedSpawnReplayDecision::Committed(
6020 receipt.clone(),
6021 ));
6022 }
6023 }
6024 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6025 return Err(failure(
6026 NodeFailureCode::SpawnIdempotencyCapacity,
6027 "durable managed worktree replay ledger reached its bounded capacity",
6028 ));
6029 } else {
6030 let now = unix_time_ms();
6031 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6032 idempotency_key: key.clone(),
6033 request_digest,
6034 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6035 profile_id: request.worktree_profile_id.clone(),
6036 expected_profile_revision: request.expected_profile_revision.clone(),
6037 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6038 owner_incarnation_id: self.incarnation_id,
6039 lease_id: None,
6040 },
6041 created_at_unix_ms: now,
6042 updated_at_unix_ms: now,
6043 });
6044 }
6045 }
6046 if let Err(error) = self.persist_state_locked() {
6047 let mut replays = self.managed_spawn_replays
6048 .lock()
6049 .unwrap_or_else(|poisoned| poisoned.into_inner());
6050 match previous {
6051 Some(previous) => {
6052 replays.insert(key, previous);
6053 }
6054 None => {
6055 replays.remove(&key);
6056 }
6057 }
6058 return Err(persistence_failure(error));
6059 }
6060 Ok(DurableManagedSpawnReplayDecision::Reserved)
6061 }
6062
6063 fn link_durable_managed_spawn_v2_lease_locked(
6064 &self,
6065 key: &SpawnIdempotencyKey,
6066 lease_id: &ManagedWorktreeLeaseId,
6067 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6068 let mut replays = self.managed_spawn_replays
6069 .lock()
6070 .unwrap_or_else(|poisoned| poisoned.into_inner());
6071 let record = replays.get_mut(key).ok_or_else(|| {
6072 failure(
6073 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6074 "managed worktree V2 reservation disappeared before allocation",
6075 )
6076 })?;
6077 let previous = record.clone();
6078 match &mut record.state {
6079 ManagedWorktreeSpawnReplayStateV10::Pending {
6080 lease_id: current,
6081 ..
6082 }
6083 if current.is_none() =>
6084 {
6085 *current = Some(lease_id.clone());
6086 record.updated_at_unix_ms = unix_time_ms();
6087 Ok(previous)
6088 }
6089 _ => Err(failure(
6090 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6091 "managed worktree V2 reservation is not allocation-ready",
6092 )),
6093 }
6094 }
6095
6096 fn restore_durable_managed_spawn_v2_locked(
6097 &self,
6098 key: &SpawnIdempotencyKey,
6099 previous: ManagedWorktreeSpawnReplayRecordV10,
6100 ) {
6101 self.managed_spawn_replays
6102 .lock()
6103 .unwrap_or_else(|poisoned| poisoned.into_inner())
6104 .insert(key.clone(), previous);
6105 }
6106
6107 fn commit_durable_managed_spawn_v2(
6108 &self,
6109 request: &ManagedWorktreeSpawnRequestV2,
6110 receipt: ManagedWorktreeSpawnReceipt,
6111 ) -> Result<(), NodeFailure> {
6112 let _transaction = self.state_transaction
6113 .lock()
6114 .unwrap_or_else(|poisoned| poisoned.into_inner());
6115 let key = &request.spawn_spec.idempotency_key;
6116 let previous = {
6117 let mut replays = self.managed_spawn_replays
6118 .lock()
6119 .unwrap_or_else(|poisoned| poisoned.into_inner());
6120 let record = replays.get_mut(key).ok_or_else(|| {
6121 failure(
6122 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6123 "managed worktree V2 reservation disappeared before commit",
6124 )
6125 })?;
6126 let previous = record.clone();
6127 match &record.state {
6128 ManagedWorktreeSpawnReplayStateV10::Pending {
6129 lease_id: Some(lease_id),
6130 ..
6131 } if lease_id == &receipt.lease.lease_id => {}
6132 _ => {
6133 return Err(failure(
6134 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6135 "managed worktree V2 reservation is not linked to the spawned lease",
6136 ));
6137 }
6138 }
6139 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6140 record.updated_at_unix_ms = unix_time_ms();
6141 previous
6142 };
6143 if let Err(error) = self.persist_state_locked() {
6144 self.restore_durable_managed_spawn_v2_locked(key, previous);
6145 return Err(persistence_failure(error));
6146 }
6147 Ok(())
6148 }
6149
6150 fn remove_unallocated_durable_managed_spawn_v2(
6151 &self,
6152 request: &ManagedWorktreeSpawnRequestV2,
6153 ) -> Result<(), NodeFailure> {
6154 let _transaction = self.state_transaction
6155 .lock()
6156 .unwrap_or_else(|poisoned| poisoned.into_inner());
6157 let key = &request.spawn_spec.idempotency_key;
6158 let removed = {
6159 let mut replays = self.managed_spawn_replays
6160 .lock()
6161 .unwrap_or_else(|poisoned| poisoned.into_inner());
6162 match replays.get(key).map(|record| &record.state) {
6163 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6164 lease_id: None,
6165 ..
6166 }) => {
6167 replays.remove(key)
6168 }
6169 _ => None,
6170 }
6171 };
6172 let Some(removed) = removed else {
6173 return Ok(());
6174 };
6175 if let Err(error) = self.persist_state_locked() {
6176 self.managed_spawn_replays
6177 .lock()
6178 .unwrap_or_else(|poisoned| poisoned.into_inner())
6179 .insert(key.clone(), removed);
6180 return Err(persistence_failure(error));
6181 }
6182 Ok(())
6183 }
6184
6185 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6186 if spec.target.node_id != self.node_id {
6187 return Err(failure(
6188 NodeFailureCode::SpawnTargetMismatch,
6189 "spawn target does not match this node",
6190 ));
6191 }
6192 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6193 failure(
6194 NodeFailureCode::UnknownSpawnProfile,
6195 "spawn profile is unavailable on this node",
6196 )
6197 })?;
6198 let resolved = spec.resolve(defaults).map_err(|error| match error {
6199 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6200 NodeFailureCode::SpawnProfileRevisionMismatch,
6201 "spawn profile revision does not match the loaded profile",
6202 ),
6203 _ => failure(
6204 NodeFailureCode::InvalidRequest,
6205 "spawn specification could not be resolved",
6206 ),
6207 })?;
6208 if resolved.target.worktree_id.is_some() {
6209 self.require_worktree_service(&resolved.target.workspace_id)?;
6210 }
6211 let environment_profile = self.resolve_environment_profile(&resolved)?;
6212 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6213 self.resolve_context(&resolved)?;
6214 Ok(resolved)
6215 }
6216
6217 async fn resolve_spawn_workspace(
6218 &self,
6219 resolved: &ResolvedSpawnSpec,
6220 deadline: Instant,
6221 ) -> Result<WorkspaceId, NodeFailure> {
6222 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6223 return Ok(resolved.target.workspace_id.clone());
6224 };
6225 if self.managed_worktrees.lock()
6226 .unwrap_or_else(|poisoned| poisoned.into_inner())
6227 .lease_for_workspace(worktree_id)
6228 .is_some()
6229 {
6230 return Err(failure(
6231 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6232 "managed worktree targets may only be spawned through their lease request",
6233 ));
6234 }
6235 self.require_worktree_service(&resolved.target.workspace_id)?;
6236 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6237 let selected_root = self.workspace_root(worktree_id)?;
6238 let remaining = spawn_deadline_remaining(deadline)?;
6239 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6240 .await
6241 .map_err(|_| {
6242 failure(
6243 NodeFailureCode::SpawnDeadlineExceeded,
6244 "Git worktree selection exceeded the spawn deadline",
6245 )
6246 })?
6247 .map_err(git_worktree_failure)?;
6248 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6249 Ok(worktree_id.clone())
6250 }
6251
6252 async fn spawn_from_spec(
6253 &self,
6254 spec: SpawnSpec,
6255 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6256 self.spawn_from_spec_with_proxy(spec, None).await
6257 }
6258
6259 async fn spawn_from_spec_with_proxy(
6260 &self,
6261 spec: SpawnSpec,
6262 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6263 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6264 let accepted_at = Instant::now();
6265 if harness_mcp.is_none() {
6266 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6267 return replayed;
6268 }
6269 }
6270 let resolved = self.resolve_spawn_spec(&spec)?;
6271 let environment_profile = self.resolve_environment_profile(&resolved)?;
6272 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6273 let context = self.resolve_context(&resolved)?;
6274 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6275 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6276 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6277 let spawn_workspace_id = match spawn_workspace_id {
6278 Ok(workspace_id) => workspace_id,
6279 Err(error) => {
6280 let result = Err(error);
6281 if harness_mcp.is_none() {
6282 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6283 }
6284 return result;
6285 }
6286 };
6287 if self.managed_worktrees.lock()
6288 .unwrap_or_else(|poisoned| poisoned.into_inner())
6289 .lease_for_workspace(&spawn_workspace_id)
6290 .is_some()
6291 {
6292 let result = Err(failure(
6293 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6294 "managed worktree targets may only be spawned through their lease request",
6295 ));
6296 if harness_mcp.is_none() {
6297 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6298 }
6299 return result;
6300 }
6301 let result = self
6302 .spawn_session_with_deadline(
6303 spawn_workspace_id,
6304 resolved.provider.clone(),
6305 resolved.mode,
6306 resolved.terminal_size,
6307 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6308 environment_profile.clone(),
6309 bundle.clone(),
6310 context.clone(),
6311 None,
6312 None,
6313 SpawnRecordPolicy::Always,
6314 Some(deadline),
6315 &required_capabilities,
6316 harness_mcp,
6317 resolved.approval_level,
6318 resolved.network_allowlist.clone(),
6319 resolved.browser_profile_id.clone(),
6320 )
6321 .await
6322 .map(|(session, _runtime_policy)| {
6323 resolved.receipt_with_materialization(
6324 self.incarnation_id,
6325 session,
6326 environment_profile,
6327 bundle,
6328 context,
6329 )
6330 });
6331 if harness_mcp.is_none() {
6332 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6333 }
6334 result
6335 }
6336
6337 async fn spawn_from_spec_with_harness_mcp(
6338 &self,
6339 reservation_id: HarnessMcpReservationId,
6340 activation_digest: HarnessMcpActivationDigest,
6341 spec: SpawnSpec,
6342 deadline_unix_ms: u64,
6343 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6344 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6345 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6346 })?;
6347 if let Some(receipt) = registry
6348 .replay_spawn(&reservation_id, &activation_digest, &spec)
6349 .map_err(harness_mcp_failure)?
6350 {
6351 return Ok(receipt);
6352 }
6353 let resolved = self.resolve_spawn_spec(&spec)?;
6354 let prepared = registry
6355 .prepare_spawn(
6356 &reservation_id,
6357 &activation_digest,
6358 &spec,
6359 deadline_unix_ms,
6360 resolved.provider.clone(),
6361 )
6362 .map_err(harness_mcp_failure)?;
6363 self.claim_harness_mcp_spawn(
6364 &reservation_id,
6365 &activation_digest,
6366 &spec,
6367 Instant::now(),
6368 )?;
6369 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6370 let mut receipt = match spawn {
6371 Ok(receipt) => receipt,
6372 Err(error) => {
6373 let _ = registry.abort(&reservation_id, &activation_digest);
6374 return Err(error);
6375 }
6376 };
6377 let record_id = self
6378 .session_bindings
6379 .lock()
6380 .unwrap_or_else(|poisoned| poisoned.into_inner())
6381 .get(&receipt.session.session.instance_id)
6382 .and_then(|binding| binding.record_id.clone());
6383 let record_id = match record_id {
6384 Some(record_id) => record_id,
6385 None => {
6386 let rollback = self.remove_session(&receipt.session).await;
6387 let _ = registry.abort(&reservation_id, &activation_digest);
6388 if let Err(rollback_error) = rollback {
6389 return Err(rollback_error);
6390 }
6391 return Err(failure(
6392 NodeFailureCode::BindingMismatch,
6393 "harness MCP spawn did not retain its exact managed record",
6394 ));
6395 }
6396 };
6397 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6398 reservation_id: reservation_id.clone(),
6399 activation_digest: activation_digest.clone(),
6400 });
6401 if let Err(error) = registry.mark_spawned(
6402 &prepared,
6403 receipt.session.clone(),
6404 record_id,
6405 receipt.clone(),
6406 ) {
6407 let rollback = self.remove_session(&receipt.session).await;
6408 let _ = registry.abort(&reservation_id, &activation_digest);
6409 if let Err(rollback_error) = rollback {
6410 return Err(rollback_error);
6411 }
6412 return Err(harness_mcp_failure(error));
6413 }
6414 Ok(receipt)
6415 }
6416
6417 async fn spawn_managed_worktree(
6418 &self,
6419 request: ManagedWorktreeSpawnRequest,
6420 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6421 self.spawn_managed_worktree_inner(request, None).await
6422 }
6423
6424 async fn spawn_managed_worktree_inner(
6425 &self,
6426 request: ManagedWorktreeSpawnRequest,
6427 durable_v2_key: Option<&SpawnIdempotencyKey>,
6428 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6429 let accepted_at = Instant::now();
6430 if durable_v2_key.is_none() {
6431 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6432 return replayed;
6433 }
6434 }
6435 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6436 if request.spawn_spec.target.worktree_id.is_some() {
6437 let result = Err(failure(
6438 NodeFailureCode::InvalidRequest,
6439 "managed worktree spawn must not select a caller-provided worktree",
6440 ));
6441 self.remember_managed_spawn_attempt(
6442 durable_v2_key,
6443 request,
6444 result.clone(),
6445 accepted_at,
6446 );
6447 return result;
6448 }
6449 let environment_profile = self.resolve_environment_profile(&resolved)?;
6450 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6451 let context = self.resolve_context(&resolved)?;
6452 let source_workspace_id = resolved.target.workspace_id.clone();
6453 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6454 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6455 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6456 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6457 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6458 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6459 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6460 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6461 };
6462 let runtime_policy = timeout(
6463 spawn_deadline_remaining(deadline)?,
6464 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6465 ).await.map_err(|_| failure(
6466 NodeFailureCode::SpawnDeadlineExceeded,
6467 "provider admission exceeded the managed spawn deadline",
6468 ))??;
6469 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6470 return Err(failure(
6471 NodeFailureCode::UnsupportedSpawnCapability,
6472 "provider runtime does not admit a required spawn capability",
6473 ));
6474 }
6475 self.ensure_binding_capacity()?;
6476 let source_root = self.workspace_root(&source_workspace_id)?;
6477 profile.validate_for_workspace(&source_root)
6478 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6479 let base_timeout_ms = u64::try_from(
6480 spawn_deadline_remaining(deadline)?.as_millis(),
6481 ).unwrap_or(u64::MAX).max(1);
6482 let base_commit = resolve_base_commit_with_timeout(
6483 &source_root,
6484 profile.base(),
6485 base_timeout_ms,
6486 ).await.map_err(|error| {
6487 if Instant::now() >= deadline || error.message.contains("timed out") {
6488 failure(
6489 NodeFailureCode::SpawnDeadlineExceeded,
6490 "managed worktree base resolution exceeded the spawn deadline",
6491 )
6492 } else {
6493 managed_git_worktree_failure(error)
6494 }
6495 })?;
6496
6497 let lease = {
6498 let _transaction = self.state_transaction.lock()
6499 .unwrap_or_else(|poisoned| poisoned.into_inner());
6500 let previous = self.managed_worktrees.lock()
6501 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6502 let lease = self.managed_worktrees.lock()
6503 .unwrap_or_else(|poisoned| poisoned.into_inner())
6504 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6505 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6506 let previous_replay = match durable_v2_key
6507 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6508 .transpose()
6509 {
6510 Ok(previous) => previous,
6511 Err(error) => {
6512 *self.managed_worktrees.lock()
6513 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6514 return Err(error);
6515 }
6516 };
6517 if let Err(error) = self.persist_state_locked() {
6518 *self.managed_worktrees.lock()
6519 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6520 if let (Some(key), Some(previous_replay)) =
6521 (durable_v2_key, previous_replay)
6522 {
6523 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6524 }
6525 return Err(persistence_failure(error));
6526 }
6527 lease
6528 };
6529 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6530 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6531 self.managed_worktrees.lock()
6532 .unwrap_or_else(|poisoned| poisoned.into_inner())
6533 .remove_unmutated(&lease.lease_id);
6534 if self.persist_state().is_err() {
6535 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6536 }
6537 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6538 self.remember_managed_spawn_attempt(
6539 durable_v2_key,
6540 request,
6541 result.clone(),
6542 accepted_at,
6543 );
6544 return result;
6545 }
6546 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6547 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6548 Err(error) => {
6549 self.reconcile_failed_allocation(&lease, &source_root).await;
6550 let result = Err(error);
6551 self.remember_managed_spawn_attempt(
6552 durable_v2_key,
6553 request,
6554 result.clone(),
6555 accepted_at,
6556 );
6557 return result;
6558 }
6559 };
6560 let created = match create_git_worktree_with_timeout(
6561 &source_root,
6562 &lease.target_root,
6563 &lease.branch,
6564 Some(&base_commit),
6565 mutation_timeout_ms,
6566 ).await {
6567 Ok(created) => created,
6568 Err(error) => {
6569 self.reconcile_failed_allocation(&lease, &source_root).await;
6570 let result = Err(if Instant::now() >= deadline
6571 || error.message.contains("timed out")
6572 || error.message.contains("deadline")
6573 {
6574 failure(
6575 NodeFailureCode::SpawnDeadlineExceeded,
6576 "managed worktree creation exceeded the spawn deadline",
6577 )
6578 } else {
6579 managed_git_worktree_failure(error)
6580 });
6581 self.remember_managed_spawn_attempt(
6582 durable_v2_key,
6583 request,
6584 result.clone(),
6585 accepted_at,
6586 );
6587 return result;
6588 }
6589 };
6590 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6591 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6592 let result = Err(failure(
6593 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6594 "created worktree identity did not match its durable lease",
6595 ));
6596 self.remember_managed_spawn_attempt(
6597 durable_v2_key,
6598 request,
6599 result.clone(),
6600 accepted_at,
6601 );
6602 return result;
6603 }
6604 {
6605 let mut registry = self.managed_worktrees.lock()
6606 .unwrap_or_else(|poisoned| poisoned.into_inner());
6607 let current = registry.get_mut(&lease.lease_id)
6608 .expect("allocated managed worktree lease remains present");
6609 current.expected_head = Some(created.head.clone());
6610 current.state = ManagedWorktreeLeaseState::Ready;
6611 current.updated_at_unix_ms = unix_time_ms();
6612 }
6613 if let Err(error) = self.persist_state() {
6614 self.mark_managed_recovery_required(
6615 &lease.lease_id,
6616 ManagedWorktreeCleanupFailure::Backend,
6617 );
6618 let result = Err(persistence_failure(error));
6619 self.remember_managed_spawn_attempt(
6620 durable_v2_key,
6621 request,
6622 result.clone(),
6623 accepted_at,
6624 );
6625 return result;
6626 }
6627 let ready = self.managed_worktrees.lock()
6628 .unwrap_or_else(|poisoned| poisoned.into_inner())
6629 .get(&lease.lease_id)
6630 .expect("ready managed worktree lease remains present")
6631 .snapshot();
6632 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6633 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6634 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6635 let result = Err(error);
6636 self.remember_managed_spawn_attempt(
6637 durable_v2_key,
6638 request,
6639 result.clone(),
6640 accepted_at,
6641 );
6642 return result;
6643 }
6644 let spawn = self.spawn_session_with_deadline(
6645 lease.workspace_id.clone(),
6646 resolved.provider.clone(),
6647 resolved.mode,
6648 resolved.terminal_size,
6649 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6650 environment_profile.clone(),
6651 bundle.clone(),
6652 context.clone(),
6653 Some(lease.lease_id.clone()),
6654 Some(runtime_policy),
6655 SpawnRecordPolicy::Always,
6656 Some(deadline),
6657 &required_capabilities,
6658 None,
6659 resolved.approval_level,
6660 resolved.network_allowlist.clone(),
6661 resolved.browser_profile_id.clone(),
6662 ).await;
6663 let (session, effective_runtime_policy) = match spawn {
6664 Ok(spawned) => spawned,
6665 Err(error) => {
6666 if self
6667 .cleanup_managed_worktree(&lease.lease_id, true)
6668 .await
6669 .is_err()
6670 {
6671 self.mark_managed_recovery_required(
6672 &lease.lease_id,
6673 ManagedWorktreeCleanupFailure::Backend,
6674 );
6675 }
6676 let result = Err(error);
6677 self.remember_managed_spawn_attempt(
6678 durable_v2_key,
6679 request,
6680 result.clone(),
6681 accepted_at,
6682 );
6683 return result;
6684 }
6685 };
6686 let (record_id, raw_inventory_record_id) = {
6687 let mut bindings = self.session_bindings.lock()
6688 .unwrap_or_else(|poisoned| poisoned.into_inner());
6689 let binding = bindings.get_mut(&session.session.instance_id)
6690 .expect("successful spawn retains its session binding");
6691 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6692 managed_spawn_record_ownership(binding, effective_runtime_policy)
6693 };
6694 let bound = self.managed_worktrees.lock()
6695 .unwrap_or_else(|poisoned| poisoned.into_inner())
6696 .bind_session(
6697 &lease.lease_id,
6698 ManagedWorktreeSessionHolder {
6699 incarnation_id: self.incarnation_id,
6700 instance_id: session.session.instance_id,
6701 generation: session.session.generation,
6702 },
6703 record_id,
6704 unix_time_ms(),
6705 )
6706 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6707 let snapshot = match bound {
6708 Ok(snapshot) => snapshot,
6709 Err(error) => {
6710 if raw_inventory_record_id
6711 .as_ref()
6712 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6713 {
6714 self.mark_managed_recovery_required(
6715 &lease.lease_id,
6716 ManagedWorktreeCleanupFailure::Backend,
6717 );
6718 }
6719 let _ = self.remove_session(&session).await;
6720 self.mark_managed_recovery_required(
6721 &lease.lease_id,
6722 ManagedWorktreeCleanupFailure::Backend,
6723 );
6724 let result = Err(error);
6725 self.remember_managed_spawn_attempt(
6726 durable_v2_key,
6727 request,
6728 result.clone(),
6729 accepted_at,
6730 );
6731 return result;
6732 }
6733 };
6734 if let Err(error) = self.persist_state() {
6735 if raw_inventory_record_id
6736 .as_ref()
6737 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6738 {
6739 self.mark_managed_recovery_required(
6740 &lease.lease_id,
6741 ManagedWorktreeCleanupFailure::Backend,
6742 );
6743 }
6744 let _ = self.remove_session(&session).await;
6745 if self
6746 .cleanup_managed_worktree(&lease.lease_id, true)
6747 .await
6748 .is_err()
6749 {
6750 self.mark_managed_recovery_required(
6751 &lease.lease_id,
6752 ManagedWorktreeCleanupFailure::Backend,
6753 );
6754 }
6755 let result = Err(persistence_failure(error));
6756 self.remember_managed_spawn_attempt(
6757 durable_v2_key,
6758 request,
6759 result.clone(),
6760 accepted_at,
6761 );
6762 return result;
6763 }
6764 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6765 let receipt = managed_spawn_receipt(
6766 &resolved,
6767 self.incarnation_id,
6768 session,
6769 snapshot,
6770 environment_profile,
6771 bundle,
6772 context,
6773 );
6774 let result = Ok(receipt);
6775 self.remember_managed_spawn_attempt(
6776 durable_v2_key,
6777 request,
6778 result.clone(),
6779 accepted_at,
6780 );
6781 result
6782 }
6783
6784 async fn spawn_managed_worktree_v2(
6785 &self,
6786 request: ManagedWorktreeSpawnRequestV2,
6787 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6788 if request.spawn_spec.target.node_id != self.node_id {
6789 return Err(failure(
6790 NodeFailureCode::SpawnTargetMismatch,
6791 "spawn target does not match this node",
6792 ));
6793 }
6794 if request.spawn_spec.target.worktree_id.is_some() {
6795 return Err(failure(
6796 NodeFailureCode::InvalidRequest,
6797 "managed worktree spawn must not select a caller-provided worktree",
6798 ));
6799 }
6800 let request_digest = managed_spawn_request_digest_v2(&request)?;
6801 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6802 match replay {
6803 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6804 DurableManagedSpawnReplayDecision::PendingLinked => {
6805 return Err(failure(
6806 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6807 "managed worktree V2 reservation has an incomplete durable allocation",
6808 ));
6809 }
6810 DurableManagedSpawnReplayDecision::Reserved => {}
6811 }
6812 let validated = (|| {
6813 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6814 let profile = self.managed_profile(
6815 &resolved.target.workspace_id,
6816 &request.worktree_profile_id,
6817 )?;
6818 if profile.revision() != &request.expected_profile_revision {
6819 return Err(failure(
6820 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6821 "managed worktree profile revision does not match the loaded profile",
6822 ));
6823 }
6824 Ok(())
6825 })();
6826 match validated {
6827 Ok(()) => {}
6828 Err(error) => {
6829 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6830 return Err(error);
6831 }
6832 }
6833 let legacy = ManagedWorktreeSpawnRequest {
6834 spawn_spec: request.spawn_spec.clone(),
6835 worktree_profile_id: request.worktree_profile_id.clone(),
6836 };
6837 match self
6838 .spawn_managed_worktree_inner(
6839 legacy,
6840 Some(&request.spawn_spec.idempotency_key),
6841 )
6842 .await
6843 {
6844 Ok(receipt) => {
6845 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6846 Ok(receipt)
6847 }
6848 Err(error) => {
6849 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6850 Err(error)
6851 }
6852 }
6853 }
6854
6855 fn managed_profile(
6856 &self,
6857 workspace_id: &WorkspaceId,
6858 profile_id: &WorktreeProfileId,
6859 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6860 if self.worktree_service_modes.get(workspace_id).copied()
6861 != Some(WorktreeServiceMode::Managed)
6862 {
6863 return Err(failure(
6864 NodeFailureCode::UnsupportedSpawnCapability,
6865 "workspace does not enable managed worktrees",
6866 ));
6867 }
6868 self.managed_worktree_profiles.get(workspace_id)
6869 .and_then(|profiles| profiles.get(profile_id))
6870 .cloned()
6871 .ok_or_else(|| failure(
6872 NodeFailureCode::InvalidRequest,
6873 "managed worktree profile is unavailable for this workspace",
6874 ))
6875 }
6876
6877 async fn reconcile_failed_allocation(
6878 &self,
6879 lease: &ManagedWorktreeLeaseRecord,
6880 source_root: &str,
6881 ) {
6882 match list_git_worktrees(source_root).await {
6883 Ok(worktrees) => {
6884 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6885 None => {
6886 match std::fs::symlink_metadata(&lease.target_root) {
6887 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6888 self.managed_worktrees.lock()
6889 .unwrap_or_else(|poisoned| poisoned.into_inner())
6890 .remove_unmutated(&lease.lease_id);
6891 let _ = self.persist_state();
6892 }
6893 _ => self.mark_managed_recovery_required(
6894 &lease.lease_id,
6895 ManagedWorktreeCleanupFailure::OwnershipConflict,
6896 ),
6897 }
6898 }
6899 Some(item) if exact_owned_worktree(
6900 lease,
6901 &item.path,
6902 item.branch.as_deref(),
6903 &item.head,
6904 ) => {
6905 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6906 }
6907 Some(_) => self.mark_managed_recovery_required(
6908 &lease.lease_id,
6909 ManagedWorktreeCleanupFailure::OwnershipConflict,
6910 ),
6911 }
6912 }
6913 Err(_) => self.mark_managed_recovery_required(
6914 &lease.lease_id,
6915 ManagedWorktreeCleanupFailure::Backend,
6916 ),
6917 }
6918 }
6919
6920 async fn reconcile_managed_worktrees(&self) {
6921 let leases = self.managed_worktrees.lock()
6922 .unwrap_or_else(|poisoned| poisoned.into_inner())
6923 .active_records()
6924 .cloned()
6925 .collect::<Vec<_>>();
6926 for lease in leases {
6927 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6928 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6929 _ => {
6930 self.mark_managed_recovery_required(
6931 &lease.lease_id,
6932 ManagedWorktreeCleanupFailure::OwnershipConflict,
6933 );
6934 continue;
6935 }
6936 };
6937 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6938 self.mark_managed_recovery_required(
6939 &lease.lease_id,
6940 ManagedWorktreeCleanupFailure::OwnershipConflict,
6941 );
6942 continue;
6943 }
6944 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6945 Ok(root) => root,
6946 Err(_) => {
6947 self.mark_managed_recovery_required(
6948 &lease.lease_id,
6949 ManagedWorktreeCleanupFailure::OwnershipConflict,
6950 );
6951 continue;
6952 }
6953 };
6954 let worktrees = match list_git_worktrees(&source_root).await {
6955 Ok(worktrees) => worktrees,
6956 Err(_) => {
6957 self.mark_managed_recovery_required(
6958 &lease.lease_id,
6959 ManagedWorktreeCleanupFailure::Backend,
6960 );
6961 continue;
6962 }
6963 };
6964 let listed = worktrees.iter()
6965 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6966 let Some(listed) = listed else {
6967 match std::fs::symlink_metadata(&lease.target_root) {
6968 Err(error) if error.kind() == io::ErrorKind::NotFound
6969 && lease.state == ManagedWorktreeLeaseState::Allocating
6970 && !lease.has_holders() => {
6971 let removed = self.managed_worktrees.lock()
6972 .unwrap_or_else(|poisoned| poisoned.into_inner())
6973 .tombstone(&lease.lease_id, unix_time_ms());
6974 if self.persist_state().is_ok() && removed.is_some() {
6975 self.publish(NodeEvent::ManagedWorktreeRemoved {
6976 lease_id: lease.lease_id.clone(),
6977 });
6978 }
6979 }
6980 _ => self.mark_managed_recovery_required(
6981 &lease.lease_id,
6982 ManagedWorktreeCleanupFailure::OwnershipConflict,
6983 ),
6984 }
6985 continue;
6986 };
6987 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6988 &lease,
6989 &listed.path,
6990 listed.branch.as_deref(),
6991 &listed.head,
6992 ) {
6993 self.mark_managed_recovery_required(
6994 &lease.lease_id,
6995 ManagedWorktreeCleanupFailure::OwnershipConflict,
6996 );
6997 continue;
6998 }
6999 if !self.workspaces.read()
7000 .unwrap_or_else(|poisoned| poisoned.into_inner())
7001 .contains_key(&lease.workspace_id)
7002 {
7003 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7004 self.mark_managed_recovery_required(
7005 &lease.lease_id,
7006 ManagedWorktreeCleanupFailure::Backend,
7007 );
7008 continue;
7009 }
7010 }
7011 let snapshot = {
7012 let mut registry = self.managed_worktrees.lock()
7013 .unwrap_or_else(|poisoned| poisoned.into_inner());
7014 let current = registry.get_mut(&lease.lease_id)
7015 .expect("reconciled managed lease remains present");
7016 current.expected_head = Some(listed.head.clone());
7017 current.cleanup_failure = None;
7018 current.state = if current.has_holders() {
7019 ManagedWorktreeLeaseState::InUse
7020 } else if current.retention == ManagedWorktreeRetention::Retain {
7021 ManagedWorktreeLeaseState::Retained
7022 } else {
7023 ManagedWorktreeLeaseState::Ready
7024 };
7025 current.updated_at_unix_ms = unix_time_ms();
7026 current.snapshot()
7027 };
7028 if self.persist_state().is_err() {
7029 self.mark_managed_recovery_required(
7030 &lease.lease_id,
7031 ManagedWorktreeCleanupFailure::Backend,
7032 );
7033 continue;
7034 }
7035 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7036 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7037 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7038 }
7039 }
7040 }
7041
7042 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7062 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7063 .session_records
7064 .lock()
7065 .unwrap_or_else(|poisoned| poisoned.into_inner())
7066 .records
7067 .values()
7068 .filter(|record| {
7069 record.state == ManagedSessionState::Live
7070 && record.exported_context.is_none()
7071 && record.provider_session.is_some()
7072 })
7073 .filter_map(|record| {
7074 record
7075 .active_session
7076 .clone()
7077 .map(|session| (record.record_id.clone(), session))
7078 })
7079 .collect();
7080 for (record_id, session) in candidates {
7081 let shared = Arc::clone(self);
7082 tokio::spawn(async move {
7083 shared
7084 .reconcile_context_pack_export_for_record(&record_id, session)
7085 .await;
7086 });
7087 }
7088 }
7089
7090 async fn reconcile_context_pack_export_for_record(
7109 &self,
7110 record_id: &SessionRecordId,
7111 session: SessionAddress,
7112 ) {
7113 let Ok(record) = self.record(record_id) else {
7114 return;
7115 };
7116 if !matches!(
7117 record.state,
7118 ManagedSessionState::Live | ManagedSessionState::Dormant
7119 ) || record.exported_context.is_some()
7120 || record.provider_session.is_none()
7121 {
7122 return;
7123 }
7124 let Ok(receipt) = self
7131 .export_context_pack_for_session_record(&record.record_id, &session, true)
7132 .await
7133 else {
7134 return;
7135 };
7136 self.set_exported_context(&record.record_id, receipt);
7137 }
7138
7139 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7144 let transaction = self
7145 .state_transaction
7146 .lock()
7147 .unwrap_or_else(|poisoned| poisoned.into_inner());
7148 let (previous, updated) = {
7149 let mut records = self
7150 .session_records
7151 .lock()
7152 .unwrap_or_else(|poisoned| poisoned.into_inner());
7153 let Some(record) = records.records.get_mut(record_id) else {
7154 return;
7155 };
7156 if record.exported_context.is_some() {
7157 return;
7158 }
7159 let previous = record.clone();
7160 record.exported_context = Some(receipt);
7161 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7162 (previous, record.clone())
7163 };
7164 if self.persist_state_locked().is_err() {
7165 self.session_records
7166 .lock()
7167 .unwrap_or_else(|poisoned| poisoned.into_inner())
7168 .records
7169 .insert(record_id.clone(), previous);
7170 return;
7171 }
7172 drop(transaction);
7173 self.publish_record(updated);
7174 }
7175
7176 fn managed_record_export_target_for_instance(
7186 &self,
7187 instance_id: AgentInstanceId,
7188 generation: SessionGeneration,
7189 ) -> Option<(SessionRecordId, SessionAddress)> {
7190 let bindings = self
7191 .session_bindings
7192 .lock()
7193 .unwrap_or_else(|poisoned| poisoned.into_inner());
7194 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7195 let record_id = binding.record_id.clone()?;
7196 Some((
7197 record_id,
7198 SessionAddress {
7199 workspace_id: binding.workspace_id.clone(),
7200 session: SessionKey { instance_id, generation },
7201 },
7202 ))
7203 }
7204
7205 fn mark_managed_recovery_required(
7206 &self,
7207 lease_id: &ManagedWorktreeLeaseId,
7208 failure_kind: ManagedWorktreeCleanupFailure,
7209 ) {
7210 let snapshot = {
7211 let mut registry = self.managed_worktrees.lock()
7212 .unwrap_or_else(|poisoned| poisoned.into_inner());
7213 let Some(lease) = registry.get_mut(lease_id) else { return };
7214 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7215 lease.cleanup_failure = Some(failure_kind);
7216 lease.updated_at_unix_ms = unix_time_ms();
7217 lease.snapshot()
7218 };
7219 let _ = self.persist_state();
7220 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7221 }
7222
7223 fn set_persistence_error(&self, error: Option<String>) {
7224 *self
7225 .persistence_error
7226 .write()
7227 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7228 .map(|message| session_registry::sanitized_persistence_summary(&message));
7229 }
7230
7231 fn persist_state(&self) -> io::Result<()> {
7232 let _transaction = self
7233 .state_transaction
7234 .lock()
7235 .unwrap_or_else(|poisoned| poisoned.into_inner());
7236 self.persist_state_locked()
7237 }
7238
7239 fn persist_state_locked(&self) -> io::Result<()> {
7240 let workspaces = self
7241 .workspaces
7242 .read()
7243 .unwrap_or_else(|poisoned| poisoned.into_inner())
7244 .clone();
7245 let records = self
7246 .session_records
7247 .lock()
7248 .unwrap_or_else(|poisoned| poisoned.into_inner())
7249 .records
7250 .values()
7251 .cloned()
7252 .collect::<Vec<_>>();
7253 let mut managed = self.managed_worktrees
7254 .lock()
7255 .unwrap_or_else(|poisoned| poisoned.into_inner());
7256 managed.reattach_record_holders(
7257 &managed_worktree_record_holders(&records),
7258 unix_time_ms(),
7259 );
7260 let materializations = self
7261 .materializations
7262 .lock()
7263 .unwrap_or_else(|poisoned| poisoned.into_inner())
7264 .values()
7265 .cloned()
7266 .collect::<Vec<_>>();
7267 let managed_spawn_replays = self.managed_spawn_replays
7268 .lock()
7269 .unwrap_or_else(|poisoned| poisoned.into_inner())
7270 .values()
7271 .cloned()
7272 .collect::<Vec<_>>();
7273 let result = session_registry::save_v10(
7274 self.state_path.as_deref(),
7275 &self.node_id,
7276 &workspaces,
7277 &records,
7278 &managed.records(),
7279 &managed.tombstones(),
7280 &materializations,
7281 &managed_spawn_replays,
7282 );
7283 match &result {
7284 Ok(warning) => self.set_persistence_error(warning.clone()),
7285 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7286 }
7287 result.map(|_| ())
7288 }
7289
7290 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7291 let _mutation_guard = self.mutation_gate.lock().await;
7292 self.begin_shutdown_locked().await
7293 }
7294
7295 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7296 if self.shutdown.swap(true, Ordering::AcqRel) {
7297 return Ok(());
7298 }
7299 SessionLifecycleHook::on_shutdown(self);
7300 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7301 let mut first_error = None;
7302 for session in &self.handle.snapshot().sessions {
7303 let remaining = deadline.saturating_duration_since(Instant::now());
7304 if remaining.is_zero() {
7305 first_error.get_or_insert_with(|| {
7306 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7307 });
7308 break;
7309 }
7310 if let Err(error) = self
7311 .dispatch_bounded(
7312 ControlCommand::Stop {
7313 instance_id: session.instance_id,
7314 force: true,
7315 },
7316 remaining,
7317 )
7318 .await
7319 {
7320 first_error.get_or_insert(error);
7321 }
7322 }
7323 self.shutdown_notify.notify_waiters();
7324 match first_error {
7325 Some(error) => Err(error),
7326 None => Ok(()),
7327 }
7328 }
7329
7330 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7331 let bindings = self
7332 .session_bindings
7333 .lock()
7334 .unwrap_or_else(|poisoned| poisoned.into_inner());
7335 if bindings.len() >= CONTROL_SESSIONS_MAX {
7336 Err(failure(
7337 NodeFailureCode::BackendBusy,
7338 "node session-binding capacity is full; remove an existing session first",
7339 ))
7340 } else {
7341 Ok(())
7342 }
7343 }
7344
7345 fn ensure_workspace_environment_profile_compatible(
7352 &self,
7353 workspace_id: &WorkspaceId,
7354 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7355 ) -> Result<(), NodeFailure> {
7356 let bindings = self
7357 .session_bindings
7358 .lock()
7359 .unwrap_or_else(|poisoned| poisoned.into_inner());
7360 for binding in bindings.values() {
7361 if &binding.workspace_id != workspace_id {
7362 continue;
7363 }
7364 if binding.environment_profile.as_ref() != environment_profile {
7365 return Err(failure(
7366 NodeFailureCode::WorkspaceBusy,
7367 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7368 ));
7369 }
7370 }
7371 Ok(())
7372 }
7373
7374 #[cfg(test)]
7375 fn bind_session(
7376 &self,
7377 instance_id: AgentInstanceId,
7378 workspace_id: WorkspaceId,
7379 generation: SessionGeneration,
7380 ) {
7381 self.bind_session_with_policy(
7382 &SessionAddress {
7383 workspace_id,
7384 session: SessionKey { instance_id, generation },
7385 },
7386 ProviderRuntimePolicy::raw_pty(),
7387 None,
7388 );
7389 }
7390
7391 #[cfg(test)]
7392 fn bind_session_with_policy(
7393 &self,
7394 address: &SessionAddress,
7395 runtime_policy: ProviderRuntimePolicy,
7396 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7397 ) {
7398 self.bind_session_with_materialization(
7399 address,
7400 runtime_policy,
7401 environment_profile,
7402 None,
7403 None,
7404 None,
7405 );
7406 }
7407
7408 fn bind_session_with_materialization(
7409 &self,
7410 address: &SessionAddress,
7411 runtime_policy: ProviderRuntimePolicy,
7412 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7413 bundle: Option<ResolvedBundleReceipt>,
7414 context: Option<ResolvedContextPackReceipt>,
7415 materialization_id: Option<MaterializationId>,
7416 ) {
7417 let mut bindings = self
7418 .session_bindings
7419 .lock()
7420 .unwrap_or_else(|poisoned| poisoned.into_inner());
7421 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7422 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7423 bindings.insert(address.session.instance_id, SessionBinding {
7424 workspace_id: address.workspace_id.clone(),
7425 generation: address.session.generation,
7426 runtime_policy,
7427 pending_resume: None,
7428 record_id: None,
7429 managed_worktree_lease_id: None,
7430 environment_profile,
7431 bundle,
7432 context,
7433 materialization_id,
7434 });
7435 }
7436
7437 #[cfg(test)]
7438 fn bind_managed_session(
7439 &self,
7440 address: &SessionAddress,
7441 record_id: SessionRecordId,
7442 runtime_policy: ProviderRuntimePolicy,
7443 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7444 ) {
7445 self.bind_managed_session_with_materialization(
7446 address,
7447 record_id,
7448 runtime_policy,
7449 environment_profile,
7450 None,
7451 None,
7452 None,
7453 );
7454 }
7455
7456 fn bind_managed_session_with_materialization(
7457 &self,
7458 address: &SessionAddress,
7459 record_id: SessionRecordId,
7460 runtime_policy: ProviderRuntimePolicy,
7461 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7462 bundle: Option<ResolvedBundleReceipt>,
7463 context: Option<ResolvedContextPackReceipt>,
7464 materialization_id: Option<MaterializationId>,
7465 ) {
7466 let mut bindings = self
7467 .session_bindings
7468 .lock()
7469 .unwrap_or_else(|poisoned| poisoned.into_inner());
7470 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7471 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7472 bindings.insert(
7473 address.session.instance_id,
7474 SessionBinding {
7475 workspace_id: address.workspace_id.clone(),
7476 generation: address.session.generation,
7477 runtime_policy,
7478 pending_resume: None,
7479 record_id: Some(record_id),
7480 managed_worktree_lease_id: None,
7481 environment_profile,
7482 bundle,
7483 context,
7484 materialization_id,
7485 },
7486 );
7487 }
7488
7489 #[cfg(test)]
7490 fn bind_spawn_session(
7491 &self,
7492 address: &SessionAddress,
7493 provider: AgentId,
7494 mode: SessionMode,
7495 runtime_policy: ProviderRuntimePolicy,
7496 record_policy: SpawnRecordPolicy,
7497 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7498 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7499 self.bind_spawn_session_with_materialization(
7500 address,
7501 provider,
7502 mode,
7503 runtime_policy,
7504 record_policy,
7505 environment_profile,
7506 None,
7507 None,
7508 None,
7509 )
7510 }
7511
7512 fn bind_spawn_session_with_materialization(
7513 &self,
7514 address: &SessionAddress,
7515 provider: AgentId,
7516 mode: SessionMode,
7517 runtime_policy: ProviderRuntimePolicy,
7518 record_policy: SpawnRecordPolicy,
7519 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7520 bundle: Option<ResolvedBundleReceipt>,
7521 context: Option<ResolvedContextPackReceipt>,
7522 materialization_id: Option<MaterializationId>,
7523 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7524 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7525 && !runtime_policy.provider_session_identity
7526 {
7527 self.bind_session_with_materialization(
7528 address,
7529 runtime_policy,
7530 environment_profile,
7531 bundle,
7532 context,
7533 materialization_id,
7534 );
7535 return Ok(None);
7536 }
7537 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7538 let record = self.new_record(
7539 address,
7540 provider,
7541 mode,
7542 active_record_state(runtime_policy.provider_session_identity, false),
7543 None,
7544 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7545 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7546 record_owns_lifecycle.then(|| context.clone()).flatten(),
7547 )?;
7548 let record_id = record.record_id.clone();
7549 let transaction = self
7550 .state_transaction
7551 .lock()
7552 .unwrap_or_else(|poisoned| poisoned.into_inner());
7553 self.insert_record(record.clone())?;
7554 let previous_materialization = if record_owns_lifecycle {
7555 if let Some(materialization_id) = materialization_id.as_ref() {
7556 let mut materializations = self
7557 .materializations
7558 .lock()
7559 .unwrap_or_else(|poisoned| poisoned.into_inner());
7560 let Some(ownership) = materializations.get_mut(materialization_id) else {
7561 drop(materializations);
7562 self.remove_record_memory(&record_id);
7563 return Err(failure(
7564 NodeFailureCode::BackendOperationFailed,
7565 "session environment ownership disappeared before binding",
7566 ));
7567 };
7568 let previous = ownership.clone();
7569 if ownership
7570 .transfer_to_record(record_id.clone(), unix_time_ms())
7571 .is_err()
7572 {
7573 drop(materializations);
7574 self.remove_record_memory(&record_id);
7575 return Err(failure(
7576 NodeFailureCode::BackendOperationFailed,
7577 "session environment ownership transfer failed",
7578 ));
7579 }
7580 Some(previous)
7581 } else {
7582 None
7583 }
7584 } else {
7585 None
7586 };
7587 self.bind_managed_session_with_materialization(
7588 address,
7589 record_id.clone(),
7590 runtime_policy,
7591 environment_profile,
7592 bundle,
7593 context,
7594 materialization_id.clone(),
7595 );
7596 if let Err(error) = self.persist_state_locked() {
7597 self.remove_binding(address);
7598 self.remove_record_memory(&record_id);
7599 if let (Some(materialization_id), Some(previous)) =
7600 (materialization_id.as_ref(), previous_materialization)
7601 {
7602 self.materializations
7603 .lock()
7604 .unwrap_or_else(|poisoned| poisoned.into_inner())
7605 .insert(materialization_id.clone(), previous);
7606 }
7607 return Err(persistence_failure(error));
7608 }
7609 drop(transaction);
7610 self.publish_record(record);
7611 Ok(Some(record_id))
7612 }
7613
7614 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7615 for _ in 0..8 {
7616 let bytes = random_nonce().map_err(|error| {
7617 failure(
7618 NodeFailureCode::BackendOperationFailed,
7619 &format!("session record identity generation failed: {error}"),
7620 )
7621 })?;
7622 let mut value = String::with_capacity(27);
7623 value.push_str("sr-");
7624 for byte in &bytes[..12] {
7625 use std::fmt::Write as _;
7626 let _ = write!(&mut value, "{byte:02x}");
7627 }
7628 let record_id = SessionRecordId::new(value).map_err(|error| {
7629 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7630 })?;
7631 let records = self
7632 .session_records
7633 .lock()
7634 .unwrap_or_else(|poisoned| poisoned.into_inner());
7635 if !records.records.contains_key(&record_id) {
7636 return Ok(record_id);
7637 }
7638 }
7639 Err(failure(
7640 NodeFailureCode::BackendBusy,
7641 "could not allocate a unique session record ID",
7642 ))
7643 }
7644
7645 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7646 for _ in 0..8 {
7647 let bytes = random_nonce().map_err(|error| {
7648 failure(
7649 NodeFailureCode::BackendOperationFailed,
7650 &format!("task identity generation failed: {error}"),
7651 )
7652 })?;
7653 let mut nonce = [0_u8; 12];
7654 nonce.copy_from_slice(&bytes[..12]);
7655 let task_id = TaskId::from_nonce(nonce);
7656 let records = self
7657 .session_records
7658 .lock()
7659 .unwrap_or_else(|poisoned| poisoned.into_inner());
7660 if !records.records.values().any(|record| {
7661 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7662 == Some(&task_id)
7663 }) {
7664 return Ok(task_id);
7665 }
7666 }
7667 Err(failure(
7668 NodeFailureCode::BackendBusy,
7669 "could not allocate a unique task ID",
7670 ))
7671 }
7672
7673 fn default_record_name(&self, provider: &AgentId) -> String {
7674 let records = self
7675 .session_records
7676 .lock()
7677 .unwrap_or_else(|poisoned| poisoned.into_inner());
7678 let ordinal = records
7679 .records
7680 .values()
7681 .filter(|record| &record.provider == provider)
7682 .count()
7683 .saturating_add(1);
7684 format!("{} #{ordinal}", provider.as_str())
7685 }
7686
7687 fn new_record(
7688 &self,
7689 address: &SessionAddress,
7690 provider: AgentId,
7691 mode: SessionMode,
7692 state: ManagedSessionState,
7693 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7694 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7695 bundle: Option<ResolvedBundleReceipt>,
7696 context: Option<ResolvedContextPackReceipt>,
7697 ) -> Result<ManagedSessionRecord, NodeFailure> {
7698 let canonical_root = self.workspace_root(&address.workspace_id)?;
7699 let now = unix_time_ms();
7700 Ok(ManagedSessionRecord {
7701 record_id: self.allocate_record_id()?,
7702 display_name: self.default_record_name(&provider),
7703 provider,
7704 mode,
7705 state,
7706 workspace_id: address.workspace_id.clone(),
7707 canonical_root: opaque_windows_path(canonical_root),
7708 provider_session,
7709 active_session: Some(address.clone()),
7710 environment_profile,
7711 bundle,
7712 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7713 context,
7714 exported_context: None,
7715 task_binding: None,
7716 created_at_unix_ms: now,
7717 updated_at_unix_ms: now,
7718 last_error: None,
7719 })
7720 }
7721
7722 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7723 let mut records = self
7724 .session_records
7725 .lock()
7726 .unwrap_or_else(|poisoned| poisoned.into_inner());
7727 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7728 return Err(failure(
7729 NodeFailureCode::BackendBusy,
7730 "managed session record capacity is full; forget an inactive record first",
7731 ));
7732 }
7733 if let Some(identity) = record.provider_session.as_ref() {
7734 if records.records.values().any(|candidate| {
7735 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7736 session_registry::same_provider_session(
7737 &candidate.provider,
7738 candidate_identity,
7739 &record.provider,
7740 identity,
7741 )
7742 })
7743 }) {
7744 return Err(failure(
7745 NodeFailureCode::SessionRecordConflict,
7746 "provider session is already represented by another managed record",
7747 ));
7748 }
7749 }
7750 match records.records.entry(record.record_id.clone()) {
7751 std::collections::btree_map::Entry::Vacant(entry) => {
7752 entry.insert(record);
7753 }
7754 std::collections::btree_map::Entry::Occupied(_) => {
7755 return Err(failure(
7756 NodeFailureCode::SessionRecordConflict,
7757 "session record ID collision",
7758 ));
7759 }
7760 }
7761 Ok(())
7762 }
7763
7764 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7765 self.session_records
7766 .lock()
7767 .unwrap_or_else(|poisoned| poisoned.into_inner())
7768 .records
7769 .get(record_id)
7770 .cloned()
7771 .ok_or_else(|| {
7772 failure(
7773 NodeFailureCode::UnknownSessionRecord,
7774 "managed session record does not exist",
7775 )
7776 })
7777 }
7778
7779 fn revalidate_session_record_preview(
7780 &self,
7781 expected: &ManagedSessionRecord,
7782 identity: &ProviderSessionIdentity,
7783 ) -> Result<(), NodeFailure> {
7784 let current = self.record(&expected.record_id)?;
7785 let current_root = self.workspace_root(¤t.workspace_id)?;
7786 if current.provider != expected.provider
7787 || current.workspace_id != expected.workspace_id
7788 || current.canonical_root != expected.canonical_root
7789 || current.provider_session.as_ref() != Some(identity)
7790 || !platform::roots_equal(
7791 ¤t_root,
7792 windows_path_text(¤t.canonical_root),
7793 )
7794 {
7795 return Err(failure(
7796 NodeFailureCode::SessionRecordConflict,
7797 "managed session changed while its preview was loading",
7798 ));
7799 }
7800 Ok(())
7801 }
7802
7803 fn revalidate_session_record_context_export(
7804 &self,
7805 expected: &ManagedSessionRecord,
7806 identity: Option<&ProviderSessionIdentity>,
7807 session: &SessionAddress,
7808 allow_clean_detachment: bool,
7809 ) -> Result<(), NodeFailure> {
7810 let current = self.record(&expected.record_id)?;
7811 let current_root = self.workspace_root(¤t.workspace_id)?;
7812 let snapshot = self.internal_session_snapshot(session)?;
7813 let binding = self
7814 .session_bindings
7815 .lock()
7816 .unwrap_or_else(|poisoned| poisoned.into_inner())
7817 .get(&session.session.instance_id)
7818 .cloned()
7819 .ok_or_else(|| {
7820 failure(
7821 NodeFailureCode::SessionRecordConflict,
7822 "managed session runtime binding disappeared during context export",
7823 )
7824 })?;
7825 if !session_record_context_export_binding_is_exact(
7826 expected,
7827 ¤t,
7828 identity,
7829 session,
7830 &binding,
7831 &snapshot.agent_id,
7832 ¤t_root,
7833 allow_clean_detachment,
7834 ) || !session_record_context_export_source_is_usable(
7835 ¤t,
7836 &snapshot.status,
7837 allow_clean_detachment,
7838 ) {
7839 return Err(failure(
7840 NodeFailureCode::SessionRecordConflict,
7841 "managed session binding changed during context export",
7842 ));
7843 }
7844 Ok(())
7845 }
7846
7847 fn revalidate_loaded_history(
7848 &self,
7849 session: &SessionAddress,
7850 candidate_id: &str,
7851 loaded: &HistorySessionRecord,
7852 ) -> Result<(), NodeFailure> {
7853 let snapshot = self.internal_session_snapshot(session)?;
7854 if snapshot.history.pending.is_some()
7855 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7856 || snapshot.history.loaded.as_ref() != Some(loaded)
7857 {
7858 return Err(failure(
7859 NodeFailureCode::SessionRecordConflict,
7860 "managed session history changed during context export",
7861 ));
7862 }
7863 Ok(())
7864 }
7865
7866 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7867 self.session_records
7868 .lock()
7869 .unwrap_or_else(|poisoned| poisoned.into_inner())
7870 .records
7871 .remove(record_id)
7872 }
7873
7874 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7875 if self.destroy_record_materialization(record_id)? {
7876 self.publish(NodeEvent::SessionRecordRemoved {
7877 record_id: record_id.clone(),
7878 });
7879 return Ok(());
7880 }
7881 let transaction = self
7882 .state_transaction
7883 .lock()
7884 .unwrap_or_else(|poisoned| poisoned.into_inner());
7885 let Some(removed) = self.remove_record_memory(record_id) else {
7886 return Ok(());
7887 };
7888 if let Err(error) = self.persist_state_locked() {
7889 let _ = self.insert_record(removed);
7890 return Err(persistence_failure(error));
7891 }
7892 drop(transaction);
7893 self.publish(NodeEvent::SessionRecordRemoved {
7894 record_id: record_id.clone(),
7895 });
7896 Ok(())
7897 }
7898
7899 fn publish_record(&self, record: ManagedSessionRecord) {
7900 self.publish(NodeEvent::SessionRecordUpserted { record });
7901 }
7902
7903 fn mark_record_error(
7904 &self,
7905 record_id: &SessionRecordId,
7906 message: &str,
7907 ) -> Result<(), NodeFailure> {
7908 let transaction = self
7909 .state_transaction
7910 .lock()
7911 .unwrap_or_else(|poisoned| poisoned.into_inner());
7912 let previous = {
7913 let mut records = self
7914 .session_records
7915 .lock()
7916 .unwrap_or_else(|poisoned| poisoned.into_inner());
7917 let Some(record) = records.records.get_mut(record_id) else {
7918 return Ok(());
7919 };
7920 let previous = record.clone();
7921 record.state = ManagedSessionState::Unavailable;
7922 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7923 record.updated_at_unix_ms = unix_time_ms();
7924 previous
7925 };
7926 if let Err(error) = self.persist_state_locked() {
7927 self.session_records
7928 .lock()
7929 .unwrap_or_else(|poisoned| poisoned.into_inner())
7930 .records
7931 .insert(record_id.clone(), previous);
7932 return Err(persistence_failure(error));
7933 }
7934 let record = self.record(record_id)?;
7935 drop(transaction);
7936 self.publish_record(record);
7937 Ok(())
7938 }
7939
7940 fn rename_session_record(
7941 &self,
7942 record_id: &SessionRecordId,
7943 display_name: String,
7944 ) -> Result<ManagedSessionRecord, NodeFailure> {
7945 validate_display_name(&display_name)
7946 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7947 let _transaction = self
7948 .state_transaction
7949 .lock()
7950 .unwrap_or_else(|poisoned| poisoned.into_inner());
7951 let previous = {
7952 let mut records = self
7953 .session_records
7954 .lock()
7955 .unwrap_or_else(|poisoned| poisoned.into_inner());
7956 let record = records.records.get_mut(record_id).ok_or_else(|| {
7957 failure(
7958 NodeFailureCode::UnknownSessionRecord,
7959 "managed session record does not exist",
7960 )
7961 })?;
7962 let previous = record.clone();
7963 record.display_name = display_name;
7964 record.updated_at_unix_ms = unix_time_ms();
7965 previous
7966 };
7967 if let Err(error) = self.persist_state_locked() {
7968 self.session_records
7969 .lock()
7970 .unwrap_or_else(|poisoned| poisoned.into_inner())
7971 .records
7972 .insert(record_id.clone(), previous);
7973 return Err(persistence_failure(error));
7974 }
7975 let updated = self.record(record_id)?;
7976 self.publish_record(updated.clone());
7977 Ok(updated)
7978 }
7979
7980 fn set_session_task(
7981 &self,
7982 record_id: &SessionRecordId,
7983 expected_revision: u64,
7984 target: SessionTaskTargetV1,
7985 ) -> Result<ManagedSessionRecord, NodeFailure> {
7986 let transaction = self
7987 .state_transaction
7988 .lock()
7989 .unwrap_or_else(|poisoned| poisoned.into_inner());
7990 let current = self.record(record_id)?;
7991 let current_task_id = current
7992 .task_binding
7993 .as_ref()
7994 .and_then(|binding| binding.task_id.as_ref());
7995 let current_revision = current
7996 .task_binding
7997 .as_ref()
7998 .map_or(0, |binding| binding.revision);
7999 if expected_revision != current_revision {
8000 return Err(failure(
8001 NodeFailureCode::SessionRecordConflict,
8002 "managed session task binding revision changed",
8003 ));
8004 }
8005 let exact = match &target {
8006 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8007 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8008 SessionTaskTargetV1::New => false,
8009 };
8010 if exact {
8011 return Ok(current);
8012 }
8013 let revision = current_revision.checked_add(1).ok_or_else(|| {
8014 failure(
8015 NodeFailureCode::SessionRecordConflict,
8016 "managed session task binding revision is exhausted",
8017 )
8018 })?;
8019 let task_id = match target {
8020 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8021 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8022 SessionTaskTargetV1::Clear => None,
8023 };
8024 let changed_at_unix_ms = unix_time_ms()
8025 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8026 failure(
8027 NodeFailureCode::SessionRecordConflict,
8028 "managed session record timestamp is exhausted",
8029 )
8030 })?);
8031 let updated = {
8032 let mut records = self
8033 .session_records
8034 .lock()
8035 .unwrap_or_else(|poisoned| poisoned.into_inner());
8036 let record = records.records.get_mut(record_id).ok_or_else(|| {
8037 failure(
8038 NodeFailureCode::UnknownSessionRecord,
8039 "managed session record does not exist",
8040 )
8041 })?;
8042 record.task_binding = Some(SessionTaskBindingV1 {
8043 revision,
8044 task_id,
8045 changed_at_unix_ms,
8046 });
8047 record.updated_at_unix_ms = changed_at_unix_ms;
8048 record.clone()
8049 };
8050 if let Err(error) = self.persist_state_locked() {
8051 self.session_records
8052 .lock()
8053 .unwrap_or_else(|poisoned| poisoned.into_inner())
8054 .records
8055 .insert(record_id.clone(), current);
8056 return Err(persistence_failure(error));
8057 }
8058 drop(transaction);
8059 self.publish_record(updated.clone());
8060 Ok(updated)
8061 }
8062
8063 fn index_provider_session(
8064 &self,
8065 workspace_id: WorkspaceId,
8066 provider: AgentId,
8067 identity: ProviderSessionIdentity,
8068 display_name: String,
8069 ) -> Result<ManagedSessionRecord, NodeFailure> {
8070 self.index_provider_session_with_policy(
8071 workspace_id,
8072 provider,
8073 identity,
8074 display_name,
8075 false,
8076 )
8077 }
8078
8079 fn index_provider_session_with_policy(
8080 &self,
8081 workspace_id: WorkspaceId,
8082 provider: AgentId,
8083 identity: ProviderSessionIdentity,
8084 display_name: String,
8085 allow_validated_transcript_path: bool,
8086 ) -> Result<ManagedSessionRecord, NodeFailure> {
8087 validate_display_name(&display_name)
8088 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8089 identity
8090 .validate()
8091 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8092 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8093 return Err(failure(
8094 NodeFailureCode::InvalidRequest,
8095 "provider session reference index v1 does not accept transcript paths",
8096 ));
8097 }
8098 let canonical_root = self.workspace_root(&workspace_id)?;
8099 if !self.enabled_providers.contains(&provider) {
8100 return Err(failure(
8101 NodeFailureCode::InvalidRequest,
8102 "provider is not enabled on this node",
8103 ));
8104 }
8105
8106 let transaction = self
8107 .state_transaction
8108 .lock()
8109 .unwrap_or_else(|poisoned| poisoned.into_inner());
8110 {
8111 let records = self
8112 .session_records
8113 .lock()
8114 .unwrap_or_else(|poisoned| poisoned.into_inner());
8115 if let Some(existing) = records.records.values().find(|candidate| {
8116 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8117 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8118 })
8119 }) {
8120 if existing.provider == provider && existing.workspace_id == workspace_id {
8121 return Ok(existing.clone());
8122 }
8123 return Err(failure(
8124 NodeFailureCode::SessionRecordConflict,
8125 "provider session reference conflicts with another provider or workspace",
8126 ));
8127 }
8128 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8129 return Err(failure(
8130 NodeFailureCode::BackendBusy,
8131 "managed session record capacity is full; forget an inactive record first",
8132 ));
8133 }
8134 }
8135
8136 let now = unix_time_ms();
8137 let record = ManagedSessionRecord {
8138 record_id: self.allocate_record_id()?,
8139 display_name,
8140 provider,
8141 mode: SessionMode::Pty,
8142 state: ManagedSessionState::Dormant,
8143 workspace_id,
8144 canonical_root: opaque_windows_path(canonical_root),
8145 provider_session: Some(identity),
8146 active_session: None,
8147 environment_profile: None,
8148 bundle: None,
8149 context_id: None,
8150 context: None,
8151 exported_context: None,
8152 task_binding: None,
8153 created_at_unix_ms: now,
8154 updated_at_unix_ms: now,
8155 last_error: None,
8156 };
8157 self.insert_record(record.clone())?;
8158 if let Err(error) = self.persist_state_locked() {
8159 self.remove_record_memory(&record.record_id);
8160 return Err(persistence_failure(error));
8161 }
8162 drop(transaction);
8163 self.publish_record(record.clone());
8164 Ok(record)
8165 }
8166
8167 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8168 self.session_bindings
8169 .lock()
8170 .unwrap_or_else(|poisoned| poisoned.into_inner())
8171 .iter()
8172 .find_map(|(instance_id, binding)| {
8173 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8174 workspace_id: binding.workspace_id.clone(),
8175 session: SessionKey {
8176 instance_id: *instance_id,
8177 generation: binding.generation,
8178 },
8179 })
8180 })
8181 }
8182
8183 async fn forget_session_record(
8184 &self,
8185 record_id: &SessionRecordId,
8186 ) -> Result<(), NodeFailure> {
8187 let record = self.record(record_id)?;
8188 if record.active_session.is_some()
8189 || matches!(
8190 record.state,
8191 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8192 )
8193 {
8194 return Err(failure(
8195 NodeFailureCode::SessionRecordBusy,
8196 "stop the managed session before forgetting it",
8197 ));
8198 }
8199 if let Some(address) = self.bound_address_for_record(record_id) {
8200 self.remove_session(&address).await?;
8201 }
8202 self.remove_managed_session_record(record_id, &record.workspace_id).await
8203 }
8204
8205 async fn remove_managed_session_record(
8220 &self,
8221 record_id: &SessionRecordId,
8222 workspace_id: &WorkspaceId,
8223 ) -> Result<(), NodeFailure> {
8224 let managed_lease_id = self.managed_worktrees.lock()
8225 .unwrap_or_else(|poisoned| poisoned.into_inner())
8226 .lease_for_workspace(workspace_id);
8227 if !self.destroy_record_materialization(record_id)? {
8228 let transaction = self
8229 .state_transaction
8230 .lock()
8231 .unwrap_or_else(|poisoned| poisoned.into_inner());
8232 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8233 failure(
8234 NodeFailureCode::UnknownSessionRecord,
8235 "managed session record does not exist",
8236 )
8237 })?;
8238 if let Err(error) = self.persist_state_locked() {
8239 let _ = self.insert_record(removed);
8240 return Err(persistence_failure(error));
8241 }
8242 drop(transaction);
8243 }
8244 self.publish(NodeEvent::SessionRecordRemoved {
8245 record_id: record_id.clone(),
8246 });
8247 if let Some(lease_id) = managed_lease_id {
8248 if let Some(snapshot) = self.managed_worktrees.lock()
8249 .unwrap_or_else(|poisoned| poisoned.into_inner())
8250 .get(&lease_id)
8251 .map(ManagedWorktreeLeaseRecord::snapshot)
8252 {
8253 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8254 }
8255 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8256 }
8257 Ok(())
8258 }
8259
8260 async fn retire_unavailable_session_records(&self) {
8273 let policy = self.session_record_retention;
8274 let age_enabled = policy.age_ms > 0;
8275 let keep_enabled = policy.keep_per_workspace > 0;
8276 if !age_enabled && !keep_enabled {
8277 return;
8278 }
8279 let now = unix_time_ms();
8280 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8281 self.session_records
8282 .lock()
8283 .unwrap_or_else(|poisoned| poisoned.into_inner())
8284 .records
8285 .values()
8286 .filter(|record| record.state == ManagedSessionState::Unavailable)
8287 .map(|record| {
8288 (
8289 record.record_id.clone(),
8290 record.workspace_id.clone(),
8291 record.provider.clone(),
8292 record.updated_at_unix_ms,
8293 )
8294 })
8295 .collect()
8296 };
8297 if candidates.is_empty() {
8298 return;
8299 }
8300 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8301 BTreeMap::new();
8302 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8303 by_workspace
8304 .entry(workspace_id)
8305 .or_default()
8306 .push((record_id, provider, updated_at_unix_ms));
8307 }
8308 let reason = if age_enabled && keep_enabled {
8309 "age+keep-n"
8310 } else if age_enabled {
8311 "age"
8312 } else {
8313 "keep-n"
8314 };
8315 let mut retired: u64 = 0;
8316 for (workspace_id, mut records) in by_workspace {
8317 records.sort_by(|left, right| right.2.cmp(&left.2));
8320 for (index, (record_id, provider, updated_at_unix_ms)) in
8321 records.into_iter().enumerate()
8322 {
8323 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8324 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8325 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8326 let should_retire = if age_enabled && keep_enabled {
8327 age_hit && keep_hit
8328 } else {
8329 age_hit || keep_hit
8330 };
8331 if !should_retire {
8332 continue;
8333 }
8334 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8335 Ok(()) => {
8336 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8337 retired += 1;
8338 tracing::info!(
8339 node_id = %self.node_id,
8340 record_id = %record_id,
8341 workspace_id = %workspace_id,
8342 provider = %provider,
8343 age_ms = record_age_ms,
8344 reason,
8345 "managed session record retired"
8346 );
8347 }
8348 Err(error) => tracing::warn!(
8349 node_id = %self.node_id,
8350 record_id = %record_id,
8351 workspace_id = %workspace_id,
8352 code = ?error.code,
8353 cause = %error.message,
8354 "session record retention sweep failed to retire a record"
8355 ),
8356 }
8357 }
8358 }
8359 if retired > 0 {
8360 let remaining_unavailable = self
8361 .session_records
8362 .lock()
8363 .unwrap_or_else(|poisoned| poisoned.into_inner())
8364 .records
8365 .values()
8366 .filter(|record| record.state == ManagedSessionState::Unavailable)
8367 .count();
8368 tracing::info!(
8369 node_id = %self.node_id,
8370 retired,
8371 remaining_unavailable,
8372 "session record retention sweep completed"
8373 );
8374 }
8375 }
8376
8377 async fn resume_session_record(
8378 &self,
8379 record_id: &SessionRecordId,
8380 terminal_size: gate4agent_types::TerminalSize,
8381 initial_prompt: Option<String>,
8382 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8383 let record = self.record(record_id)?;
8384 if record.active_session.is_some()
8385 || matches!(
8386 record.state,
8387 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8388 )
8389 {
8390 return Err(failure(
8391 NodeFailureCode::SessionRecordBusy,
8392 "managed session already has a live runtime binding",
8393 ));
8394 }
8395 let identity = record.provider_session.clone().ok_or_else(|| {
8396 failure(
8397 NodeFailureCode::SessionRecordNotResumable,
8398 "managed session has no verified provider session identity",
8399 )
8400 })?;
8401 let working_directory = self.workspace_root(&record.workspace_id)?;
8402 if !platform::roots_equal(
8403 &working_directory,
8404 windows_path_text(&record.canonical_root),
8405 ) {
8406 return Err(failure(
8407 NodeFailureCode::SessionRecordConflict,
8408 "managed session workspace root changed; refusing to resume in another directory",
8409 ));
8410 }
8411 {
8412 let records = self
8413 .session_records
8414 .lock()
8415 .unwrap_or_else(|poisoned| poisoned.into_inner());
8416 if records.records.values().any(|candidate| {
8417 candidate.record_id != *record_id
8418 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8419 session_registry::same_provider_session(
8420 &candidate.provider,
8421 candidate_identity,
8422 &record.provider,
8423 &identity,
8424 )
8425 })
8426 && (candidate.active_session.is_some()
8427 || matches!(
8428 candidate.state,
8429 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8430 ))
8431 }) {
8432 return Err(failure(
8433 NodeFailureCode::SessionRecordConflict,
8434 "provider session is already active through another managed record",
8435 ));
8436 }
8437 }
8438 let runtime_requirement = if initial_prompt.is_some() {
8439 ProviderRuntimeRequirement::ResumeWithPrompt
8440 } else {
8441 ProviderRuntimeRequirement::Resume
8442 };
8443 let runtime_policy = self.admit_provider_runtime(
8444 &record.provider,
8445 runtime_requirement,
8446 )
8447 .await?;
8448 let request = ResumeLaunchRequest {
8449 working_directory,
8450 terminal_size,
8451 initial_prompt,
8452 };
8453 request
8454 .validate()
8455 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8456 self.ensure_binding_capacity()?;
8457 let resolved_materialization = match self.resolve_record_materialization(
8458 record_id,
8459 &record.provider,
8460 record.mode,
8461 record.environment_profile.as_ref(),
8462 record.bundle.as_ref(),
8463 record.context.as_ref(),
8464 ) {
8465 Ok(materialization) => materialization,
8466 Err(error) => {
8467 let label = match error.code {
8468 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8469 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8470 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8471 _ => "environment-profile-unavailable",
8472 };
8473 self.mark_record_error(record_id, label)?;
8474 return Err(error);
8475 }
8476 };
8477 let (materialization_id, environment_overlay) = match resolved_materialization {
8478 Some((id, overlay)) => (Some(id), overlay),
8479 None => (None, None),
8480 };
8481 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8482 let mut environment_selection = match SpawnPreparer::select_environment(self,
8483 instance_id,
8484 &record.provider,
8485 record.mode,
8486 record.environment_profile.as_ref(),
8487 ) {
8488 Ok(selection) => selection,
8489 Err(error) => {
8490 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8491 return Err(error);
8492 }
8493 };
8494 let mut instance_overlay = environment_overlay
8495 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8496 .transpose()?
8497 .flatten();
8498 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8499 self.remove_session(&stale_address).await?;
8500 }
8501 let address = SessionAddress {
8502 workspace_id: record.workspace_id.clone(),
8503 session: SessionKey {
8504 instance_id,
8505 generation: SessionGeneration(1),
8506 },
8507 };
8508 let transaction = self
8509 .state_transaction
8510 .lock()
8511 .unwrap_or_else(|poisoned| poisoned.into_inner());
8512 let current = self.record(record_id)?;
8513 if current.active_session.is_some()
8514 || matches!(
8515 current.state,
8516 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8517 )
8518 {
8519 return Err(failure(
8520 NodeFailureCode::SessionRecordBusy,
8521 "managed session became active while resume was being prepared",
8522 ));
8523 }
8524 self.bind_managed_session_with_materialization(
8525 &address,
8526 record_id.clone(),
8527 runtime_policy,
8528 record.environment_profile.clone(),
8529 record.bundle.clone(),
8530 record.context.clone(),
8531 materialization_id,
8532 );
8533 if let Some(selection) = environment_selection.take() {
8534 selection.retain();
8535 }
8536 if let Some(overlay) = instance_overlay.take() {
8537 overlay.retain();
8538 }
8539 drop(transaction);
8540
8541 let transport = match record.mode {
8542 SessionMode::Pty => TransportKind::Pty,
8543 SessionMode::Inline => TransportKind::Pipe,
8544 SessionMode::Acp => TransportKind::Acp,
8545 };
8546 let agent_id = record.provider.clone();
8547 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8548 if let Err(error) = self
8549 .dispatch_bounded(
8550 ControlCommand::Register {
8551 instance_id,
8552 agent_id,
8553 transport,
8554 },
8555 dispatch_timeout,
8556 )
8557 .await
8558 {
8559 if let Some(binding) = self.remove_binding(&address) {
8560 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8561 }
8562 return Err(error);
8563 }
8564 let command = self.prepare_command(ControlCommand::Resume {
8565 instance_id,
8566 target: ResumeTarget::ProviderSession { identity },
8567 runtime_policy,
8568 request,
8569 });
8570 let started_after = self.current_sequence();
8571 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8572 self.rollback_spawn(&address, dispatch_timeout).await?;
8573 return Err(error);
8574 }
8575 if let Err(error) = self.dispatch_envelope(command) {
8576 self.clear_armed_resume(&address);
8577 self.rollback_spawn(&address, dispatch_timeout).await?;
8578 return Err(error);
8579 }
8580 match self
8581 .wait_for_record_resume(&address, started_after)
8582 .await
8583 {
8584 Ok(settled_address) => {
8585 let managed_lease_id = {
8586 self.managed_worktrees.lock()
8587 .unwrap_or_else(|poisoned| poisoned.into_inner())
8588 .lease_for_workspace(&record.workspace_id)
8589 };
8590 if let Some(lease_id) = managed_lease_id {
8591 {
8592 let mut bindings = self.session_bindings.lock()
8593 .unwrap_or_else(|poisoned| poisoned.into_inner());
8594 let binding = bindings.get_mut(&settled_address.session.instance_id)
8595 .ok_or_else(|| failure(
8596 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8597 "resumed managed session lost its exact binding",
8598 ))?;
8599 if binding.generation != settled_address.session.generation {
8600 return Err(failure(
8601 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8602 "resumed managed session generation diverged",
8603 ));
8604 }
8605 binding.managed_worktree_lease_id = Some(lease_id.clone());
8606 }
8607 let snapshot = self.managed_worktrees.lock()
8608 .unwrap_or_else(|poisoned| poisoned.into_inner())
8609 .bind_session(
8610 &lease_id,
8611 ManagedWorktreeSessionHolder {
8612 incarnation_id: self.incarnation_id,
8613 instance_id: settled_address.session.instance_id,
8614 generation: settled_address.session.generation,
8615 },
8616 Some(record_id.clone()),
8617 unix_time_ms(),
8618 ).map_err(|message| failure(
8619 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8620 &message,
8621 ))?;
8622 if let Err(error) = self.persist_state() {
8623 let _ = self.remove_session(&settled_address).await;
8624 return Err(persistence_failure(error));
8625 }
8626 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8627 }
8628 Ok((self.record(record_id)?, settled_address))
8629 }
8630 Err(error) => {
8631 if matches!(
8632 error.code,
8633 NodeFailureCode::SessionRecordNotResumable
8634 | NodeFailureCode::BackendOperationFailed
8635 ) {
8636 if let Some(bound) = self.bound_address_for_record(record_id) {
8637 let _ = self.remove_session(&bound).await;
8638 }
8639 }
8640 Err(error)
8641 }
8642 }
8643 }
8644
8645 async fn wait_for_record_resume(
8646 &self,
8647 initial_address: &SessionAddress,
8648 after_sequence: u64,
8649 ) -> Result<SessionAddress, NodeFailure> {
8650 let deadline = Instant::now()
8651 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8652 let mut scan_after = after_sequence;
8653 loop {
8654 let events = self
8655 .history
8656 .lock()
8657 .unwrap_or_else(|poisoned| poisoned.into_inner())
8658 .events
8659 .iter()
8660 .filter(|event| event.sequence > scan_after)
8661 .cloned()
8662 .collect::<Vec<_>>();
8663 for envelope in events {
8664 scan_after = scan_after.max(envelope.sequence);
8665 let NodeEvent::Control { address, event } = envelope.event else {
8666 continue;
8667 };
8668 if address.session.instance_id != initial_address.session.instance_id {
8669 continue;
8670 }
8671 match event.event {
8672 ControlEventKind::Resumed { .. } => return Ok(address),
8673 ControlEventKind::ResumeDenied { reason } => {
8674 return Err(failure(
8675 NodeFailureCode::SessionRecordNotResumable,
8676 &reason,
8677 ));
8678 }
8679 ControlEventKind::ResumeFailed { message }
8680 | ControlEventKind::CommandRejected { message } => {
8681 return Err(failure(
8682 NodeFailureCode::BackendOperationFailed,
8683 &message,
8684 ));
8685 }
8686 _ => {}
8687 }
8688 }
8689 if Instant::now() >= deadline {
8690 return Err(failure(
8691 NodeFailureCode::BackendBusy,
8692 "managed resume did not settle before the bounded deadline",
8693 ));
8694 }
8695 sleep(Duration::from_millis(5)).await;
8696 }
8697 }
8698
8699 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8700 self.workspaces
8701 .read()
8702 .unwrap_or_else(|poisoned| poisoned.into_inner())
8703 .get(workspace_id)
8704 .cloned()
8705 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8706 }
8707
8708 fn workspace_roots(&self) -> Vec<PathBuf> {
8709 self.workspaces
8710 .read()
8711 .unwrap_or_else(|poisoned| poisoned.into_inner())
8712 .values()
8713 .map(PathBuf::from)
8714 .collect()
8715 }
8716
8717 async fn inspect_workspace(
8718 &self,
8719 workspace_id: WorkspaceId,
8720 ) -> Result<WorkspaceInspection, NodeFailure> {
8721 let _permit = self
8722 .inspection_slots
8723 .clone()
8724 .try_acquire_owned()
8725 .map_err(|_| {
8726 tracing::warn!(
8727 workspace_id = %workspace_id,
8728 "workspace inspection rejected: inspection capacity is busy",
8729 );
8730 failure(
8731 NodeFailureCode::BackendBusy,
8732 "workspace inspection capacity is busy",
8733 )
8734 })?;
8735 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8736 tracing::warn!(
8737 workspace_id = %workspace_id,
8738 code = ?error.code,
8739 "workspace inspection rejected: workspace is not registered",
8740 );
8741 error
8742 })?;
8743 let tree_root = canonical_root.clone();
8744 let started_at = Instant::now();
8745 let time_budget_ms = workspace_inspection_time_budget_ms();
8746 let entry_cap = workspace_inspection_entry_cap();
8747 let deadline = started_at + Duration::from_millis(time_budget_ms);
8748 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8749 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8750 })
8751 .await
8752 .map_err(|error| {
8753 tracing::warn!(
8754 workspace_id = %workspace_id,
8755 error = %error,
8756 "workspace inspection rejected: walk task failed",
8757 );
8758 failure(
8759 NodeFailureCode::BackendOperationFailed,
8760 &format!("workspace inspection task failed: {error}"),
8761 )
8762 })?;
8763 let (mut git, git_time_budget_exceeded) =
8764 inspect_git_workspace(&canonical_root, deadline).await;
8765 if !git.worktrees.is_empty() {
8766 let registered = self
8767 .workspaces
8768 .read()
8769 .unwrap_or_else(|poisoned| poisoned.into_inner())
8770 .iter()
8771 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8772 .collect::<Vec<_>>();
8773 for worktree in &mut git.worktrees {
8774 worktree.workspace_id = registered
8775 .iter()
8776 .find(|(_, root)| {
8777 worktree_paths_equal(root, windows_path_text(&worktree.path))
8778 })
8779 .map(|(workspace_id, _)| workspace_id.clone());
8780 }
8781 }
8782 if git.is_repository {
8783 git.managed_worktree = self
8784 .managed_worktrees
8785 .lock()
8786 .unwrap_or_else(|poisoned| poisoned.into_inner())
8787 .git_scope(&workspace_id, git.branch.as_deref());
8788 }
8789 let truncation = workspace_inspection_truncation(
8790 &walk_budget,
8791 git_time_budget_exceeded,
8792 started_at.elapsed(),
8793 );
8794 if let Some(truncation) = &truncation {
8795 tracing::info!(
8796 workspace_id = %workspace_id,
8797 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8798 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8799 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8800 entries_visited = truncation.entries_visited,
8801 elapsed_ms = truncation.elapsed_ms,
8802 "workspace inspection truncated by its inner budget",
8803 );
8804 }
8805 Ok(WorkspaceInspection {
8806 workspace_id,
8807 entries,
8808 tree_truncated,
8809 git,
8810 truncation,
8811 })
8812 }
8813
8814 async fn browse_host_directories(
8815 &self,
8816 directory: Option<OpaqueHostPath>,
8817 after: Option<OpaqueHostPath>,
8818 ) -> Result<HostDirectoryListing, NodeFailure> {
8819 let permit = self
8820 .inspection_slots
8821 .clone()
8822 .try_acquire_owned()
8823 .map_err(|_| {
8824 failure(
8825 NodeFailureCode::BackendBusy,
8826 "host directory browse capacity is busy",
8827 )
8828 })?;
8829 let task = tokio::task::spawn_blocking(move || {
8830 let _permit = permit;
8831 browse_host_directories(directory, after)
8832 });
8833 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8834 .await
8835 .map_err(|_| {
8836 failure(
8837 NodeFailureCode::HostDirectoryReadTimedOut,
8838 "host directory browse exceeded its bounded deadline",
8839 )
8840 })?
8841 .map_err(|_| {
8842 failure(
8843 NodeFailureCode::HostDirectoryReadFailed,
8844 "host directory browse task failed",
8845 )
8846 })?
8847 .map_err(host_directory_failure)
8848 }
8849
8850 async fn read_workspace_file(
8851 &self,
8852 workspace_id: WorkspaceId,
8853 path: RepositoryPath,
8854 ) -> Result<WorkspaceFileRead, NodeFailure> {
8855 let canonical_root = self.workspace_root(&workspace_id)?;
8856 let permit = self
8857 .inspection_slots
8858 .clone()
8859 .try_acquire_owned()
8860 .map_err(|_| {
8861 failure(
8862 NodeFailureCode::BackendBusy,
8863 "workspace file read capacity is busy",
8864 )
8865 })?;
8866 let path_to_read = path.clone();
8867 let task = tokio::task::spawn_blocking(move || {
8868 let _permit = permit;
8869 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8870 });
8871 let content = timeout(
8872 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8873 task,
8874 )
8875 .await
8876 .map_err(|_| {
8877 failure(
8878 NodeFailureCode::RepositoryFileReadTimedOut,
8879 "repository file read exceeded its bounded deadline",
8880 )
8881 })?
8882 .map_err(|_| {
8883 failure(
8884 NodeFailureCode::RepositoryFileReadFailed,
8885 "repository file read task failed",
8886 )
8887 })?
8888 .map_err(workspace_file_failure)?;
8889 let content = match content {
8890 WorkspaceFileBytes::Utf8(text) => {
8891 let revision = Some(workspace_file_revision(text.as_bytes()));
8892 return Ok(WorkspaceFileRead {
8893 workspace_id,
8894 path,
8895 content: WorkspaceFileContent::Utf8 {
8896 byte_len: u32::try_from(text.len())
8897 .expect("bounded workspace text length must fit u32"),
8898 text,
8899 },
8900 revision,
8901 });
8902 }
8903 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8904 WorkspaceFileContent::NonUtf8 {
8905 byte_len: u32::try_from(byte_length)
8906 .expect("bounded workspace file length must fit u32"),
8907 }
8908 }
8909 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8910 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8911 },
8912 };
8913 Ok(WorkspaceFileRead {
8914 workspace_id,
8915 path,
8916 content,
8917 revision: None,
8918 })
8919 }
8920
8921 async fn write_workspace_file(
8922 &self,
8923 workspace_id: WorkspaceId,
8924 path: RepositoryPath,
8925 expected_revision: WorkspaceFileRevision,
8926 text: String,
8927 ) -> Result<WorkspaceFileRead, NodeFailure> {
8928 let canonical_root = self.workspace_root(&workspace_id)?;
8929 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8930 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8931 })?;
8932 let path_to_write = path.clone();
8933 let expected = expected_revision.as_str().to_owned();
8934 let text_to_write = text.clone();
8935 let task = tokio::task::spawn_blocking(move || {
8936 let _permit = permit;
8937 write_workspace_file_to_disk(
8938 Path::new(&canonical_root),
8939 &path_to_write,
8940 &expected,
8941 &text_to_write,
8942 )
8943 });
8944 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8945 .await
8946 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8947 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8948 .map_err(workspace_file_write_failure)?;
8949 Ok(WorkspaceFileRead {
8950 workspace_id,
8951 path,
8952 content: WorkspaceFileContent::Utf8 {
8953 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8954 text,
8955 },
8956 revision: Some(WorkspaceFileRevision::new(revision)
8957 .expect("workspace writer returns a valid SHA-256 revision")),
8958 })
8959 }
8960
8961 async fn create_workspace_file(
8962 &self,
8963 workspace_id: WorkspaceId,
8964 path: RepositoryPath,
8965 ) -> Result<WorkspaceFileRead, NodeFailure> {
8966 let canonical_root = self.workspace_root(&workspace_id)?;
8967 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8968 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8969 })?;
8970 let path_to_create = path.clone();
8971 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8972 let task_commit_state = Arc::clone(&commit_state);
8973 let task = tokio::task::spawn_blocking(move || {
8974 let _permit = permit;
8975 create_workspace_file_on_disk(
8976 Path::new(&canonical_root),
8977 &path_to_create,
8978 &task_commit_state,
8979 )
8980 });
8981 let revision = settle_workspace_entry_create(
8982 task,
8983 commit_state,
8984 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8985 "repository file create exceeded its bounded deadline",
8986 "repository file create task failed",
8987 )
8988 .await?;
8989 Ok(WorkspaceFileRead {
8990 workspace_id,
8991 path,
8992 content: WorkspaceFileContent::Utf8 {
8993 text: String::new(),
8994 byte_len: 0,
8995 },
8996 revision: Some(WorkspaceFileRevision::new(revision)
8997 .expect("workspace creator returns a valid SHA-256 revision")),
8998 })
8999 }
9000
9001 async fn create_workspace_directory(
9002 &self,
9003 workspace_id: WorkspaceId,
9004 path: RepositoryPath,
9005 ) -> Result<WorkspaceEntry, NodeFailure> {
9006 let canonical_root = self.workspace_root(&workspace_id)?;
9007 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9008 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9009 })?;
9010 let path_to_create = path.clone();
9011 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9012 let task_commit_state = Arc::clone(&commit_state);
9013 let task = tokio::task::spawn_blocking(move || {
9014 let _permit = permit;
9015 create_workspace_directory_on_disk(
9016 Path::new(&canonical_root),
9017 &path_to_create,
9018 &task_commit_state,
9019 )
9020 });
9021 settle_workspace_entry_create(
9022 task,
9023 commit_state,
9024 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9025 "repository directory create exceeded its bounded deadline",
9026 "repository directory create task failed",
9027 )
9028 .await?;
9029 Ok(WorkspaceEntry {
9030 relative_path: path,
9031 kind: WorkspaceEntryKind::Directory,
9032 })
9033 }
9034
9035 async fn read_git_history(
9036 &self,
9037 workspace_id: WorkspaceId,
9038 path: Option<RepositoryPath>,
9039 before: Option<GitObjectId>,
9040 limit: u16,
9041 ) -> Result<GitHistoryPage, NodeFailure> {
9042 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9043 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9044 })?;
9045 let root = self.workspace_root(&workspace_id)?;
9046 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9047 }
9048
9049 async fn read_git_diff(
9050 &self,
9051 workspace_id: WorkspaceId,
9052 request: GitDiffRequest,
9053 ) -> Result<GitDiff, NodeFailure> {
9054 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9055 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9056 })?;
9057 let root = self.workspace_root(&workspace_id)?;
9058 read_git_diff_bounded(&root, request).await
9059 }
9060
9061 fn native_session_route_root(
9062 &self,
9063 route: &NativeSessionCatalogRoute,
9064 ) -> Result<Option<String>, NodeFailure> {
9065 route
9066 .validate()
9067 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9068 match route.scope {
9069 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9070 .workspace_id
9071 .as_ref()
9072 .map(|workspace_id| self.workspace_root(workspace_id))
9073 .transpose(),
9074 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9075 }
9076 }
9077
9078 fn project_native_session_entries(
9079 &self,
9080 route: &NativeSessionCatalogRoute,
9081 entries: Vec<ScopedNativeSessionCatalogEntry>,
9082 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9083 let current_root = route
9084 .workspace_id
9085 .as_ref()
9086 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9087 let records = self
9088 .session_records
9089 .lock()
9090 .unwrap_or_else(|poisoned| poisoned.into_inner());
9091 let mut seen_records = std::collections::HashSet::new();
9092 entries
9093 .into_iter()
9094 .map(|entry| {
9095 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9096 let matches = records
9097 .records
9098 .values()
9099 .filter(|record| {
9100 record.provider == route.provider
9101 && record.workspace_id == *workspace_id
9102 && current_root.as_deref().is_some_and(|root| {
9103 platform::roots_equal(
9104 root,
9105 windows_path_text(&record.canonical_root),
9106 )
9107 })
9108 && record.provider_session.as_ref().is_some_and(|identity| {
9109 session_registry::same_provider_session(
9110 &record.provider,
9111 identity,
9112 &route.provider,
9113 &entry.provider_session,
9114 )
9115 })
9116 })
9117 .map(|record| record.record_id.clone())
9118 .collect::<Vec<_>>();
9119 (matches.len() == 1
9120 && seen_records.insert(matches[0].clone()))
9121 .then(|| matches[0].clone())
9122 });
9123 let metadata = entry.metadata;
9124 crate::protocol::NativeSessionCatalogEntry {
9125 selection_id: metadata.selection_id,
9126 title: metadata.title,
9127 modified_at_unix_ms: metadata.modified_at_unix_ms,
9128 model: metadata.model,
9129 message_count: metadata.message_count,
9130 completed_turn_count: metadata.completed_turn_count,
9131 external_group: entry.external_group,
9132 record_id,
9133 }
9134 })
9135 .collect()
9136 }
9137
9138 async fn catalog_native_sessions(
9139 &self,
9140 route: NativeSessionCatalogRoute,
9141 limit: u16,
9142 ) -> Result<(
9143 Vec<crate::protocol::NativeSessionCatalogEntry>,
9144 crate::protocol::NativeSessionCatalogSummary,
9145 ), NodeFailure> {
9146 let canonical_root = self.native_session_route_root(&route)?;
9147 let registered_workspace_roots = self.workspace_roots();
9148 let provider = route.provider.clone();
9149 let scope = route.scope;
9150 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9151 failure(
9152 NodeFailureCode::InvalidRequest,
9153 "native session catalog is not configured",
9154 )
9155 })?;
9156 let _permit = Arc::clone(&self.inspection_slots)
9157 .try_acquire_owned()
9158 .map_err(|_| {
9159 failure(
9160 NodeFailureCode::BackendBusy,
9161 "native session catalog capacity is busy",
9162 )
9163 })?;
9164 let page = run_native_session_catalog_operation(
9165 catalog,
9166 "native session catalog",
9167 move |catalog| {
9168 catalog.catalog_initial_for_scope(
9169 &provider,
9170 scope,
9171 canonical_root.as_deref().map(Path::new),
9172 ®istered_workspace_roots,
9173 limit,
9174 )
9175 },
9176 )
9177 .await?;
9178 let summary = crate::protocol::NativeSessionCatalogSummary {
9179 catalog_revision: page.revision,
9180 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9181 recent_total_count: page.recent_total_count,
9182 older_total_count: page.older_total_count,
9183 recent_next_after_selection_id: page.next_after_selection_id,
9184 recent_has_more: page.remaining_count > 0,
9185 };
9186 Ok((self.project_native_session_entries(&route, page.entries), summary))
9187 }
9188
9189 async fn page_native_sessions(
9190 &self,
9191 route: NativeSessionCatalogRoute,
9192 window: crate::protocol::NativeSessionCatalogWindow,
9193 catalog_revision: u64,
9194 recent_cutoff_unix_ms: u64,
9195 after_selection_id: Option<String>,
9196 limit: u16,
9197 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9198 let canonical_root = self.native_session_route_root(&route)?;
9199 let registered_workspace_roots = self.workspace_roots();
9200 let provider = route.provider.clone();
9201 let scope = route.scope;
9202 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9203 failure(
9204 NodeFailureCode::InvalidRequest,
9205 "native session catalog is not configured",
9206 )
9207 })?;
9208 let _permit = Arc::clone(&self.inspection_slots)
9209 .try_acquire_owned()
9210 .map_err(|_| {
9211 failure(
9212 NodeFailureCode::BackendBusy,
9213 "native session catalog capacity is busy",
9214 )
9215 })?;
9216 let page = run_native_session_catalog_operation(
9217 catalog,
9218 "native session catalog page",
9219 move |catalog| {
9220 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9221 &provider,
9222 scope,
9223 canonical_root.as_deref().map(Path::new),
9224 ®istered_workspace_roots,
9225 window,
9226 catalog_revision,
9227 recent_cutoff_unix_ms,
9228 after_selection_id.as_deref(),
9229 limit,
9230 ))
9231 },
9232 )
9233 .await?;
9234 let page = page.map_err(|error| match error {
9235 NativeSessionCatalogError::StaleCatalog => failure(
9236 NodeFailureCode::StaleNativeSessionCatalog,
9237 "native session catalog revision or cursor is stale",
9238 ),
9239 _ => failure(
9240 NodeFailureCode::BackendOperationFailed,
9241 "native session catalog page failed",
9242 ),
9243 })?;
9244 let entries = self.project_native_session_entries(&route, page.entries);
9245 Ok(crate::protocol::NativeSessionCatalogPage {
9246 window: page.window,
9247 revision: page.revision,
9248 entries,
9249 next_after_selection_id: page.next_after_selection_id,
9250 remaining_count: page.remaining_count,
9251 has_more: page.remaining_count > 0,
9252 })
9253 }
9254
9255 async fn preview_native_session(
9256 &self,
9257 selection: NativeSessionSelection,
9258 message_limit: u16,
9259 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9260 selection
9261 .validate()
9262 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9263 let canonical_root = self.native_session_route_root(&selection.route)?;
9264 let registered_workspace_roots = self.workspace_roots();
9265 let provider = selection.route.provider.clone();
9266 let scope = selection.route.scope;
9267 let catalog_revision = selection.catalog_revision;
9268 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9269 let selection_id = selection.selection_id.clone();
9270 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9271 failure(
9272 NodeFailureCode::InvalidRequest,
9273 "native session preview is not configured",
9274 )
9275 })?;
9276 let _permit = Arc::clone(&self.inspection_slots)
9277 .try_acquire_owned()
9278 .map_err(|_| {
9279 failure(
9280 NodeFailureCode::BackendBusy,
9281 "native session preview capacity is busy",
9282 )
9283 })?;
9284 let preview = run_native_session_catalog_operation(
9285 catalog,
9286 "native session preview",
9287 move |catalog| {
9288 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9289 &provider,
9290 scope,
9291 canonical_root.as_deref().map(Path::new),
9292 ®istered_workspace_roots,
9293 catalog_revision,
9294 recent_cutoff_unix_ms,
9295 &selection_id,
9296 message_limit,
9297 ))
9298 },
9299 )
9300 .await?;
9301 preview
9302 .map(Into::into)
9303 .map_err(native_session_preview_failure)
9304 }
9305
9306 async fn index_native_session(
9307 &self,
9308 selection: NativeSessionSelection,
9309 display_name: String,
9310 ) -> Result<ManagedSessionRecord, NodeFailure> {
9311 selection
9312 .validate()
9313 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9314 if selection.route.scope
9315 != gate4agent_types::NativeSessionCatalogScope::Workspace
9316 {
9317 return Err(failure(
9318 NodeFailureCode::WorkspaceRegistrationRequired,
9319 "register the external project as a workspace before indexing or resuming it",
9320 ));
9321 }
9322 let workspace_id = selection
9323 .route
9324 .workspace_id
9325 .clone()
9326 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9327 let canonical_root = self
9328 .native_session_route_root(&selection.route)?
9329 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9330 let registered_workspace_roots = self.workspace_roots();
9331 let provider = selection.route.provider.clone();
9332 let catalog_revision = selection.catalog_revision;
9333 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9334 let selection_id = selection.selection_id.clone();
9335 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9336 failure(
9337 NodeFailureCode::InvalidRequest,
9338 "native session index is not configured",
9339 )
9340 })?;
9341 let _permit = Arc::clone(&self.inspection_slots)
9342 .try_acquire_owned()
9343 .map_err(|_| {
9344 failure(
9345 NodeFailureCode::BackendBusy,
9346 "native session index capacity is busy",
9347 )
9348 })?;
9349 let resolved = run_native_session_catalog_operation(
9350 catalog,
9351 "native session index resolution",
9352 move |catalog| {
9353 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9354 &provider,
9355 gate4agent_types::NativeSessionCatalogScope::Workspace,
9356 Some(Path::new(&canonical_root)),
9357 ®istered_workspace_roots,
9358 catalog_revision,
9359 recent_cutoff_unix_ms,
9360 &selection_id,
9361 ))
9362 },
9363 )
9364 .await?
9365 .map_err(native_session_preview_failure)?;
9366 self.index_provider_session_with_policy(
9367 workspace_id,
9368 selection.route.provider,
9369 resolved.identity,
9370 display_name,
9371 true,
9372 )
9373 }
9374
9375 async fn preview_native_session_by_session_id(
9376 &self,
9377 workspace_id: WorkspaceId,
9378 provider: AgentId,
9379 session_id: String,
9380 message_limit: u16,
9381 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9382 let canonical_root = self.workspace_root(&workspace_id)?;
9383 let registered_workspace_roots = self.workspace_roots();
9384 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9385 failure(
9386 NodeFailureCode::InvalidRequest,
9387 "native session preview is not configured",
9388 )
9389 })?;
9390 let _permit = Arc::clone(&self.inspection_slots)
9391 .try_acquire_owned()
9392 .map_err(|_| {
9393 failure(
9394 NodeFailureCode::BackendBusy,
9395 "native session preview capacity is busy",
9396 )
9397 })?;
9398 run_native_session_catalog_operation(
9399 catalog,
9400 "native session preview",
9401 move |catalog| {
9402 catalog.preview_session_id_for_workspace(
9403 &provider,
9404 Path::new(&canonical_root),
9405 ®istered_workspace_roots,
9406 &session_id,
9407 message_limit,
9408 )
9409 },
9410 )
9411 .await
9412 }
9413
9414 async fn register_workspace(
9415 &self,
9416 workspace_id: WorkspaceId,
9417 root: String,
9418 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9419 validate_workspace_request_root(&root)?;
9420 let canonicalize_workspace_id = workspace_id.clone();
9421 let workspace = tokio::task::spawn_blocking(move || {
9422 WorkspaceConfig::new(canonicalize_workspace_id, root)
9423 })
9424 .await
9425 .map_err(|error| {
9426 failure(
9427 NodeFailureCode::BackendOperationFailed,
9428 &format!("workspace canonicalization task failed: {error}"),
9429 )
9430 })?
9431 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9432 let transaction = self
9433 .state_transaction
9434 .lock()
9435 .unwrap_or_else(|poisoned| poisoned.into_inner());
9436 let mut workspaces = self
9437 .workspaces
9438 .write()
9439 .unwrap_or_else(|poisoned| poisoned.into_inner());
9440 if workspaces.contains_key(&workspace_id) {
9441 return Err(failure(
9442 NodeFailureCode::DuplicateWorkspaceId,
9443 "workspace ID is already registered",
9444 ));
9445 }
9446 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9447 platform::roots_equal(existing_root, workspace.canonical_root())
9448 }) {
9449 return Err(failure(
9450 NodeFailureCode::DuplicateWorkspaceRoot,
9451 &format!("workspace root is already registered as '{existing_id}'"),
9452 ));
9453 }
9454 {
9455 let records = self
9456 .session_records
9457 .lock()
9458 .unwrap_or_else(|poisoned| poisoned.into_inner());
9459 if let Some(record) = records.records.values().find(|record| {
9460 record.workspace_id == workspace_id
9461 && !record
9462 .canonical_root
9463 .as_utf8()
9464 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9465 }) {
9466 return Err(failure(
9467 NodeFailureCode::SessionRecordConflict,
9468 &format!(
9469 "workspace ID is retained by session record '{}' at another root",
9470 record.record_id,
9471 ),
9472 ));
9473 }
9474 }
9475 let snapshot = WorkspaceSnapshot {
9476 workspace_id: workspace_id.clone(),
9477 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9478 sessions: Vec::new(),
9479 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9480 managed_worktree_profiles: Some(WorktreeProfileInventory {
9481 profiles: Vec::new(),
9482 }),
9483 };
9484 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9485 drop(workspaces);
9486 let previous_records = {
9487 let mut records = self
9488 .session_records
9489 .lock()
9490 .unwrap_or_else(|poisoned| poisoned.into_inner());
9491 let previous = records.records.clone();
9492 for record in records.records.values_mut().filter(|record| {
9493 record.workspace_id == workspace_id
9494 && record
9495 .canonical_root
9496 .as_utf8()
9497 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9498 }) {
9499 if record.active_session.is_none() {
9500 record.state = detached_record_state(record);
9501 }
9502 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9503 record.last_error = None;
9504 }
9505 record.updated_at_unix_ms = unix_time_ms();
9506 }
9507 previous
9508 };
9509 if let Err(error) = self.persist_state_locked() {
9510 self.workspaces
9511 .write()
9512 .unwrap_or_else(|poisoned| poisoned.into_inner())
9513 .remove(&workspace_id);
9514 self.session_records
9515 .lock()
9516 .unwrap_or_else(|poisoned| poisoned.into_inner())
9517 .records = previous_records;
9518 return Err(persistence_failure(error));
9519 }
9520 let restored = self
9521 .session_records
9522 .lock()
9523 .unwrap_or_else(|poisoned| poisoned.into_inner())
9524 .records
9525 .values()
9526 .filter(|record| record.workspace_id == workspace_id)
9527 .cloned()
9528 .collect::<Vec<_>>();
9529 drop(transaction);
9530 self.publish(NodeEvent::WorkspaceAdded {
9531 workspace: snapshot.clone(),
9532 });
9533 for record in restored {
9534 self.publish_record(record);
9535 }
9536 Ok(snapshot)
9537 }
9538
9539 async fn create_standalone_workspace(
9540 &self,
9541 workspace_id: WorkspaceId,
9542 root: String,
9543 initial_branch: Option<String>,
9544 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9545 validate_workspace_request_root(&root)?;
9546 validate_git_revision("initial branch", initial_branch.as_deref())?;
9547 let preflight_workspace_id = workspace_id.clone();
9548 let preflight_root = root.clone();
9549 let canonical_candidate = tokio::task::spawn_blocking(move || {
9550 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9551 })
9552 .await
9553 .map_err(|_| failure(
9554 NodeFailureCode::BackendOperationFailed,
9555 "standalone workspace root preflight task failed",
9556 ))?
9557 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9558 {
9559 let workspaces = self
9560 .workspaces
9561 .read()
9562 .unwrap_or_else(|poisoned| poisoned.into_inner());
9563 if workspaces.contains_key(&workspace_id) {
9564 return Err(failure(
9565 NodeFailureCode::DuplicateWorkspaceId,
9566 "workspace ID is already registered",
9567 ));
9568 }
9569 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9570 platform::roots_equal(existing_root, &canonical_candidate)
9571 }) {
9572 return Err(failure(
9573 NodeFailureCode::DuplicateWorkspaceRoot,
9574 &format!("workspace root is already registered as '{existing_id}'"),
9575 ));
9576 }
9577 }
9578 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9579 .await
9580 .map_err(standalone_workspace_failure)?;
9581 prepared
9582 .verify_for_registration()
9583 .await
9584 .map_err(standalone_workspace_failure)?;
9585 match self
9586 .register_workspace(workspace_id, prepared.root().to_owned())
9587 .await
9588 {
9589 Ok(workspace) => Ok(workspace),
9590 Err(registration_error) => {
9591 if prepared.compensate_registration_failure().await {
9592 Err(registration_error)
9593 } else {
9594 Err(failure(
9595 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9596 "standalone workspace initialization succeeded but registration failed; recovery is required",
9597 ))
9598 }
9599 }
9600 }
9601 }
9602
9603 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9604 let bindings = self
9605 .session_bindings
9606 .lock()
9607 .unwrap_or_else(|poisoned| poisoned.into_inner());
9608 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9609 return Err(failure(
9610 NodeFailureCode::WorkspaceBusy,
9611 "workspace retains a bound session; remove every session before unregistering it",
9612 ));
9613 }
9614 drop(bindings);
9615 let transaction = self
9616 .state_transaction
9617 .lock()
9618 .unwrap_or_else(|poisoned| poisoned.into_inner());
9619 let mut workspaces = self
9620 .workspaces
9621 .write()
9622 .unwrap_or_else(|poisoned| poisoned.into_inner());
9623 if !workspaces.contains_key(workspace_id) {
9624 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9625 }
9626 if workspaces.len() == 1 {
9627 return Err(failure(
9628 NodeFailureCode::LastWorkspace,
9629 "node must retain at least one workspace",
9630 ));
9631 }
9632 let removed_root = workspaces
9633 .remove(workspace_id)
9634 .expect("workspace presence was validated");
9635 drop(workspaces);
9636 let previous_records = {
9637 let mut records = self
9638 .session_records
9639 .lock()
9640 .unwrap_or_else(|poisoned| poisoned.into_inner());
9641 let previous = records.records.clone();
9642 for record in records
9643 .records
9644 .values_mut()
9645 .filter(|record| &record.workspace_id == workspace_id)
9646 {
9647 record.active_session = None;
9648 record.state = ManagedSessionState::Unavailable;
9649 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9650 record.updated_at_unix_ms = unix_time_ms();
9651 }
9652 previous
9653 };
9654 if let Err(error) = self.persist_state_locked() {
9655 self.workspaces
9656 .write()
9657 .unwrap_or_else(|poisoned| poisoned.into_inner())
9658 .insert(workspace_id.clone(), removed_root);
9659 self.session_records
9660 .lock()
9661 .unwrap_or_else(|poisoned| poisoned.into_inner())
9662 .records = previous_records;
9663 return Err(persistence_failure(error));
9664 }
9665 let unavailable = self
9666 .session_records
9667 .lock()
9668 .unwrap_or_else(|poisoned| poisoned.into_inner())
9669 .records
9670 .values()
9671 .filter(|record| &record.workspace_id == workspace_id)
9672 .cloned()
9673 .collect::<Vec<_>>();
9674 drop(transaction);
9675 self.publish(NodeEvent::WorkspaceRemoved {
9676 workspace_id: workspace_id.clone(),
9677 });
9678 for record in unavailable {
9679 self.publish_record(record);
9680 }
9681 Ok(())
9682 }
9683
9684 async fn create_worktree(
9685 &self,
9686 source_workspace_id: WorkspaceId,
9687 workspace_id: WorkspaceId,
9688 target_root: String,
9689 branch: String,
9690 base: Option<String>,
9691 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9692 self.require_worktree_service(&source_workspace_id)?;
9693 validate_workspace_request_root(&target_root)?;
9694 validate_git_revision("worktree base", base.as_deref())?;
9695 let source_root = self.workspace_root(&source_workspace_id)?;
9696 {
9697 let workspaces = self
9698 .workspaces
9699 .read()
9700 .unwrap_or_else(|poisoned| poisoned.into_inner());
9701 if workspaces.contains_key(&workspace_id) {
9702 return Err(failure(
9703 NodeFailureCode::DuplicateWorkspaceId,
9704 "workspace ID is already registered",
9705 ));
9706 }
9707 if let Some((existing_id, _)) = workspaces
9708 .iter()
9709 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9710 {
9711 return Err(failure(
9712 NodeFailureCode::DuplicateWorkspaceRoot,
9713 &format!("workspace root is already registered as '{existing_id}'"),
9714 ));
9715 }
9716 }
9717 let mut worktree = create_git_worktree(
9718 &source_root,
9719 &target_root,
9720 &branch,
9721 base.as_deref(),
9722 )
9723 .await
9724 .map_err(git_worktree_failure)?;
9725 let workspace = match self
9726 .register_workspace(workspace_id, worktree.path.clone())
9727 .await
9728 {
9729 Ok(workspace) => workspace,
9730 Err(registration_error) => {
9731 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9732 return Err(failure(
9733 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9734 "worktree registration failed and compensation requires recovery",
9735 ));
9736 }
9737 return Err(failure(
9738 registration_error.code,
9739 ®istration_error.message,
9740 ));
9741 }
9742 };
9743 worktree.workspace_id = Some(workspace.workspace_id.clone());
9744 Ok((protocol_worktree(worktree), workspace))
9745 }
9746
9747 async fn remove_worktree(
9748 &self,
9749 source_workspace_id: WorkspaceId,
9750 target_root: String,
9751 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9752 self.require_worktree_service(&source_workspace_id)?;
9753 validate_workspace_request_root(&target_root)?;
9754 let source_root = self.workspace_root(&source_workspace_id)?;
9755 let target_root = normalize_worktree_removal_target(&target_root)
9756 .map_err(git_worktree_failure)?;
9757 let target = list_git_worktrees(&source_root)
9758 .await
9759 .map_err(git_worktree_failure)?
9760 .into_iter()
9761 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9762 .ok_or_else(|| {
9763 failure(
9764 NodeFailureCode::WorktreeProtected,
9765 "refusing to remove a path that is not in Git's worktree listing",
9766 )
9767 })?;
9768 let registered_workspace_id = {
9769 let workspaces = self
9770 .workspaces
9771 .read()
9772 .unwrap_or_else(|poisoned| poisoned.into_inner());
9773 let matched = workspaces
9774 .iter()
9775 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9776 .map(|(workspace_id, _)| workspace_id.clone());
9777 if matched.is_some() && workspaces.len() == 1 {
9778 return Err(failure(
9779 NodeFailureCode::LastWorkspace,
9780 "node must retain at least one workspace",
9781 ));
9782 }
9783 matched
9784 };
9785 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9786 let bindings = self
9787 .session_bindings
9788 .lock()
9789 .unwrap_or_else(|poisoned| poisoned.into_inner());
9790 if bindings
9791 .values()
9792 .any(|binding| &binding.workspace_id == workspace_id)
9793 {
9794 return Err(failure(
9795 NodeFailureCode::WorkspaceBusy,
9796 "worktree workspace retains a bound session; remove every session first",
9797 ));
9798 }
9799 }
9800 remove_git_worktree(&source_root, &target.path)
9801 .await
9802 .map_err(git_worktree_failure)?;
9803 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9804 self.unregister_workspace(workspace_id)
9805 .map_err(|error| failure(error.code, &error.message))?;
9806 }
9807 Ok(registered_workspace_id)
9808 }
9809
9810 async fn cleanup_managed_worktree(
9811 &self,
9812 lease_id: &ManagedWorktreeLeaseId,
9813 explicit: bool,
9814 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9815 let lease = self.managed_worktrees.lock()
9816 .unwrap_or_else(|poisoned| poisoned.into_inner())
9817 .get(lease_id)
9818 .cloned()
9819 .ok_or_else(|| failure(
9820 NodeFailureCode::UnknownManagedWorktreeLease,
9821 "managed worktree lease does not exist",
9822 ))?;
9823 if lease.state == ManagedWorktreeLeaseState::Removed {
9824 return Ok(lease.snapshot());
9825 }
9826 if lease.has_holders() {
9827 return Err(failure(
9828 NodeFailureCode::ManagedWorktreeBusy,
9829 "managed worktree lease still has a session or durable-record holder",
9830 ));
9831 }
9832 if self
9833 .materializations
9834 .lock()
9835 .unwrap_or_else(|poisoned| poisoned.into_inner())
9836 .values()
9837 .any(|record| record.managed_lease_id() == Some(lease_id))
9838 {
9839 return Err(failure(
9840 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9841 "managed worktree cleanup is blocked by session environment ownership",
9842 ));
9843 }
9844 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9845 let snapshot = {
9846 let mut registry = self.managed_worktrees.lock()
9847 .unwrap_or_else(|poisoned| poisoned.into_inner());
9848 let current = registry.get_mut(lease_id)
9849 .expect("managed worktree lease remains present");
9850 current.state = ManagedWorktreeLeaseState::Retained;
9851 current.cleanup_failure = None;
9852 current.updated_at_unix_ms = unix_time_ms();
9853 current.snapshot()
9854 };
9855 if self.persist_state().is_err() {
9856 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9857 }
9858 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9859 return Ok(snapshot);
9860 }
9861 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9862 if profile.revision() != &lease.profile_revision {
9863 self.mark_managed_recovery_required(
9864 lease_id,
9865 ManagedWorktreeCleanupFailure::OwnershipConflict,
9866 );
9867 return Err(failure(
9868 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9869 "managed worktree profile revision changed",
9870 ));
9871 }
9872 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9873 self.mark_managed_recovery_required(
9874 lease_id,
9875 ManagedWorktreeCleanupFailure::OwnershipConflict,
9876 );
9877 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9878 })?;
9879 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9880 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9881 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9882 managed_git_worktree_failure(error)
9883 })?;
9884 let target = listed.iter()
9885 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9886 if let Some(target) = target {
9887 if target.is_main || target.is_bare || !exact_owned_worktree(
9888 &lease,
9889 &target.path,
9890 target.branch.as_deref(),
9891 &target.head,
9892 ) {
9893 self.mark_managed_recovery_required(
9894 lease_id,
9895 ManagedWorktreeCleanupFailure::OwnershipConflict,
9896 );
9897 return Err(failure(
9898 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9899 "Git worktree identity no longer matches the managed lease",
9900 ));
9901 }
9902 if target.prunable {
9903 self.mark_managed_cleanup_blocked(
9904 lease_id,
9905 ManagedWorktreeCleanupFailure::Prunable,
9906 );
9907 return Err(failure(
9908 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9909 "managed worktree is prunable and requires recovery",
9910 ));
9911 }
9912 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9913 let failure_kind = match error.kind {
9914 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9915 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9916 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9917 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9918 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9919 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9920 };
9921 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9922 self.mark_managed_recovery_required(lease_id, failure_kind);
9923 } else {
9924 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9925 }
9926 return Err(managed_git_worktree_failure(error));
9927 }
9928 } else {
9929 match std::fs::symlink_metadata(&lease.target_root) {
9930 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9931 Ok(_) => {
9932 self.mark_managed_recovery_required(
9933 lease_id,
9934 ManagedWorktreeCleanupFailure::OwnershipConflict,
9935 );
9936 return Err(failure(
9937 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9938 "managed target exists but is not owned by Git",
9939 ));
9940 }
9941 Err(_) => {
9942 self.mark_managed_cleanup_blocked(
9943 lease_id,
9944 ManagedWorktreeCleanupFailure::Backend,
9945 );
9946 return Err(failure(
9947 NodeFailureCode::BackendOperationFailed,
9948 "managed target identity could not be inspected",
9949 ));
9950 }
9951 }
9952 }
9953 let registered = self.workspaces.read()
9954 .unwrap_or_else(|poisoned| poisoned.into_inner())
9955 .contains_key(&lease.workspace_id);
9956 if registered {
9957 self.unregister_workspace(&lease.workspace_id)?;
9958 }
9959 let removed = {
9960 self.managed_worktrees.lock()
9961 .unwrap_or_else(|poisoned| poisoned.into_inner())
9962 .tombstone(lease_id, unix_time_ms())
9963 .expect("managed worktree lease remains present during cleanup")
9964 };
9965 self.persist_state().map_err(persistence_failure)?;
9966 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9967 Ok(removed.snapshot())
9968 }
9969
9970 fn mark_managed_cleanup_blocked(
9971 &self,
9972 lease_id: &ManagedWorktreeLeaseId,
9973 failure_kind: ManagedWorktreeCleanupFailure,
9974 ) {
9975 let snapshot = {
9976 let mut registry = self.managed_worktrees.lock()
9977 .unwrap_or_else(|poisoned| poisoned.into_inner());
9978 let Some(lease) = registry.get_mut(lease_id) else { return };
9979 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9980 lease.cleanup_failure = Some(failure_kind);
9981 lease.updated_at_unix_ms = unix_time_ms();
9982 lease.snapshot()
9983 };
9984 let _ = self.persist_state();
9985 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9986 }
9987
9988 fn require_worktree_service(
9989 &self,
9990 source_workspace_id: &WorkspaceId,
9991 ) -> Result<(), NodeFailure> {
9992 if self.managed_worktrees.lock()
9993 .unwrap_or_else(|poisoned| poisoned.into_inner())
9994 .lease_for_workspace(source_workspace_id)
9995 .is_some()
9996 {
9997 return Err(failure(
9998 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9999 "managed worktree targets are internal-only workspaces",
10000 ));
10001 }
10002 match self
10003 .worktree_service_modes
10004 .get(source_workspace_id)
10005 .copied()
10006 .unwrap_or(WorktreeServiceMode::Manual)
10007 {
10008 WorktreeServiceMode::Manual => Ok(()),
10009 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10010 NodeFailureCode::UnsupportedSpawnCapability,
10011 "Git worktree service mode is unavailable in this node build",
10012 )),
10013 }
10014 }
10015
10016 fn managed_reservation_conflict(
10017 &self,
10018 workspace_id: Option<&WorkspaceId>,
10019 root: Option<&str>,
10020 ) -> bool {
10021 self.managed_worktrees.lock()
10022 .unwrap_or_else(|poisoned| poisoned.into_inner())
10023 .active_records()
10024 .any(|lease| {
10025 workspace_id.is_some_and(|workspace_id| {
10026 workspace_id == &lease.source_workspace_id
10027 || workspace_id == &lease.workspace_id
10028 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10029 })
10030 }
10031
10032 fn reject_managed_reservation(
10033 &self,
10034 workspace_id: Option<&WorkspaceId>,
10035 root: Option<&str>,
10036 ) -> Result<(), NodeFailure> {
10037 if self.managed_reservation_conflict(workspace_id, root) {
10038 Err(failure(
10039 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10040 "managed worktree ownership may only be mutated through its lease cleanup",
10041 ))
10042 } else {
10043 Ok(())
10044 }
10045 }
10046
10047 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10048 self.validate_address(address)?;
10049 self.workspace_root(&address.workspace_id)
10050 }
10051
10052 fn require_session_runtime_policy(
10086 &self,
10087 address: &SessionAddress,
10088 requirement: ProviderRuntimeRequirement,
10089 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10090 let runtime_policy = {
10091 let bindings = self
10092 .session_bindings
10093 .lock()
10094 .unwrap_or_else(|poisoned| poisoned.into_inner());
10095 let binding = bindings
10096 .get(&address.session.instance_id)
10097 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10098 if binding.workspace_id != address.workspace_id {
10099 return Err(failure(
10100 NodeFailureCode::SessionWorkspaceMismatch,
10101 "session belongs to another workspace",
10102 ));
10103 }
10104 if binding.generation != address.session.generation {
10105 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10106 }
10107 binding.runtime_policy
10108 };
10109 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10110 let snapshot = self.internal_session_snapshot(address)?;
10111 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10112 && snapshot.provider.lead_activity != ProviderActivity::Idle
10113 {
10114 return Err(failure(
10115 NodeFailureCode::TurnInFlight,
10116 "session already has a turn in flight; wait for it to complete before sending another prompt",
10117 ));
10118 }
10119 }
10120 require_policy(runtime_policy, false, false, requirement)
10121 .map(|()| runtime_policy)
10122 .map_err(|_| failure(
10123 NodeFailureCode::UnsupportedCapability,
10124 "session runtime policy does not admit this semantic operation",
10125 ))
10126 }
10127
10128 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10129 let mut bindings = self
10130 .session_bindings
10131 .lock()
10132 .unwrap_or_else(|poisoned| poisoned.into_inner());
10133 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10134 binding.workspace_id == address.workspace_id
10135 && binding.generation == address.session.generation
10136 }) {
10137 let removed = bindings.remove(&address.session.instance_id);
10138 self.clear_terminal_frame_watermark(address);
10139 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10140 return removed;
10141 }
10142 None
10143 }
10144
10145 fn cleanup_session_owned_materialization(
10146 &self,
10147 address: &SessionAddress,
10148 binding: &SessionBinding,
10149 ) -> Result<(), NodeFailure> {
10150 let Some(id) = binding.materialization_id.as_ref() else {
10151 return Ok(());
10152 };
10153 let owner = self
10154 .materializations
10155 .lock()
10156 .unwrap_or_else(|poisoned| poisoned.into_inner())
10157 .get(id)
10158 .map(|record| record.owner().clone());
10159 match owner {
10160 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10161 Some(MaterializationOwner::Session {
10162 incarnation_id,
10163 instance_id,
10164 generation,
10165 }) if incarnation_id == self.incarnation_id
10166 && instance_id == address.session.instance_id
10167 && generation == address.session.generation =>
10168 {
10169 self.cleanup_materialization(id)
10170 }
10171 Some(MaterializationOwner::Session { .. }) => Err(failure(
10172 NodeFailureCode::BackendOperationFailed,
10173 "session environment ownership requires recovery",
10174 )),
10175 }
10176 }
10177
10178 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10179 let mut watermarks = self
10180 .terminal_frame_watermarks
10181 .lock()
10182 .unwrap_or_else(|poisoned| poisoned.into_inner());
10183 if watermarks
10184 .get(&address.session.instance_id)
10185 .is_some_and(|(current, _)| current == address)
10186 {
10187 watermarks.remove(&address.session.instance_id);
10188 }
10189 }
10190
10191 #[cfg(test)]
10192 fn address_for(
10193 &self,
10194 instance_id: AgentInstanceId,
10195 generation: SessionGeneration,
10196 ) -> Option<SessionAddress> {
10197 let binding = self.session_bindings
10198 .lock()
10199 .unwrap_or_else(|poisoned| poisoned.into_inner())
10200 .get(&instance_id)
10201 .cloned()?;
10202 (binding.generation == generation).then_some(SessionAddress {
10203 workspace_id: binding.workspace_id,
10204 session: SessionKey { instance_id, generation },
10205 })
10206 }
10207
10208 fn arm_resume(
10209 &self,
10210 address: &SessionAddress,
10211 command_id: CommandId,
10212 runtime_policy: ProviderRuntimePolicy,
10213 ) -> Result<(), NodeFailure> {
10214 let mut bindings = self
10215 .session_bindings
10216 .lock()
10217 .unwrap_or_else(|poisoned| poisoned.into_inner());
10218 let binding = bindings
10219 .get_mut(&address.session.instance_id)
10220 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10221 if binding.workspace_id != address.workspace_id {
10222 return Err(failure(
10223 NodeFailureCode::SessionWorkspaceMismatch,
10224 "session belongs to another workspace",
10225 ));
10226 }
10227 if binding.generation != address.session.generation {
10228 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10229 }
10230 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10231 Ok(())
10232 }
10233
10234 fn clear_armed_resume(&self, address: &SessionAddress) {
10235 let mut bindings = self
10236 .session_bindings
10237 .lock()
10238 .unwrap_or_else(|poisoned| poisoned.into_inner());
10239 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10240 if binding.workspace_id == address.workspace_id
10241 && binding.generation == address.session.generation
10242 {
10243 binding.pending_resume = None;
10244 }
10245 }
10246 }
10247
10248 fn publish_control(&self, event: ControlEvent) {
10249 let revokes_harness_mcp = matches!(
10250 &event.event,
10251 ControlEventKind::Exited { .. }
10252 | ControlEventKind::Failed { .. }
10253 | ControlEventKind::Removed
10254 );
10255 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10256 let mut bindings = self
10257 .session_bindings
10258 .lock()
10259 .unwrap_or_else(|poisoned| poisoned.into_inner());
10260 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10261 return;
10262 };
10263 let previous_address = SessionAddress {
10264 workspace_id: binding.workspace_id.clone(),
10265 session: SessionKey {
10266 instance_id: event.instance_id,
10267 generation: binding.generation,
10268 },
10269 };
10270 let mut resume_command_rejected = false;
10271 if binding.generation == event.generation {
10272 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10273 let rejected_resume = matches!(
10274 &event.event,
10275 ControlEventKind::CommandRejected { .. }
10276 ) && event.command_id == Some(pending_command_id);
10277 let resume_failed = matches!(
10278 &event.event,
10279 ControlEventKind::ResumeDenied { .. }
10280 | ControlEventKind::ResumeFailed { .. }
10281 );
10282 if pending_generation == binding.generation
10283 && (rejected_resume || resume_failed)
10284 {
10285 resume_command_rejected = rejected_resume;
10286 binding.pending_resume = None;
10287 }
10288 }
10289 } else {
10290 let expected = binding.generation.0.checked_add(1);
10291 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10292 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10293 generation == binding.generation
10294 })
10295 || expected != Some(event.generation.0)
10296 || !authorized
10297 {
10298 return;
10299 }
10300 let (_, _, runtime_policy) = binding
10301 .pending_resume
10302 .expect("authorized resume retains its pending runtime policy");
10303 binding.generation = event.generation;
10304 binding.runtime_policy = runtime_policy;
10305 binding.pending_resume = None;
10306 }
10307 (
10308 SessionAddress {
10309 workspace_id: binding.workspace_id.clone(),
10310 session: SessionKey {
10311 instance_id: event.instance_id,
10312 generation: event.generation,
10313 },
10314 },
10315 (previous_address.session.generation != event.generation)
10316 .then_some(previous_address),
10317 binding.record_id.clone(),
10318 binding.runtime_policy.provider_session_identity,
10319 resume_command_rejected,
10320 )
10321 };
10322 if let Some(previous_address) = previous_address.as_ref() {
10323 self.clear_terminal_frame_watermark(previous_address);
10324 }
10325 if let Some(record_id) = record_id {
10326 self.reconcile_managed_record(
10327 &record_id,
10328 &address,
10329 &event,
10330 identity_admitted,
10331 resume_command_rejected,
10332 );
10333 }
10334 match &event.event {
10342 ControlEventKind::Running { process_id } => {
10343 #[cfg(unix)]
10344 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10345 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10346 }
10347 tracing::info!(
10348 node_id = %self.node_id,
10349 workspace_id = %address.workspace_id,
10350 session = ?address.session,
10351 pid = ?process_id,
10352 "session process is running",
10353 );
10354 }
10355 ControlEventKind::Exited { exit_code, forced } => {
10356 #[cfg(unix)]
10357 if let Some(proxy) = self.signer_proxy.get().cloned() {
10358 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10359 if proxy.has_session(&key) {
10360 std::thread::spawn(move || proxy.release(&key));
10362 }
10363 }
10364 tracing::info!(
10365 node_id = %self.node_id,
10366 workspace_id = %address.workspace_id,
10367 session = ?address.session,
10368 exit_code = ?exit_code,
10369 forced = forced,
10370 "session process exited",
10371 );
10372 }
10373 ControlEventKind::Failed { message } => {
10374 tracing::warn!(
10375 node_id = %self.node_id,
10376 workspace_id = %address.workspace_id,
10377 session = ?address.session,
10378 cause = %message,
10379 "session failed",
10380 );
10381 }
10382 ControlEventKind::CommandRejected { message } => {
10383 tracing::warn!(
10384 node_id = %self.node_id,
10385 workspace_id = %address.workspace_id,
10386 session = ?address.session,
10387 cause = %message,
10388 "session command rejected",
10389 );
10390 }
10391 ControlEventKind::Removed => {
10392 tracing::info!(
10393 node_id = %self.node_id,
10394 workspace_id = %address.workspace_id,
10395 session = ?address.session,
10396 "session removed",
10397 );
10398 }
10399 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10400 tracing::info!(
10401 node_id = %self.node_id,
10402 workspace_id = %address.workspace_id,
10403 session = ?address.session,
10404 provider_session = ?provider_session,
10405 "session resume authorized",
10406 );
10407 }
10408 ControlEventKind::Resumed { session: provider_session, process_id } => {
10409 tracing::info!(
10410 node_id = %self.node_id,
10411 workspace_id = %address.workspace_id,
10412 session = ?address.session,
10413 provider_session = ?provider_session,
10414 pid = ?process_id,
10415 "session resumed",
10416 );
10417 }
10418 ControlEventKind::ResumeDenied { reason } => {
10419 tracing::warn!(
10420 node_id = %self.node_id,
10421 workspace_id = %address.workspace_id,
10422 session = ?address.session,
10423 cause = %reason,
10424 "session resume denied",
10425 );
10426 }
10427 ControlEventKind::ResumeFailed { message } => {
10428 tracing::warn!(
10429 node_id = %self.node_id,
10430 workspace_id = %address.workspace_id,
10431 session = ?address.session,
10432 cause = %message,
10433 "session resume failed",
10434 );
10435 }
10436 _ => {}
10437 }
10438 self.apply_pending_acp_options(&address, &event);
10439 let stream_chunk = agent_stream_chunk(&event);
10440 self.publish(NodeEvent::Control {
10441 address: address.clone(),
10442 event,
10443 });
10444 if let Some(chunk) = stream_chunk {
10445 self.publish_agent_stream_chunk(address.clone(), chunk);
10446 }
10447 if revokes_harness_mcp {
10448 SessionLifecycleHook::on_session_ended(self, &address);
10449 }
10450 }
10451
10452 fn reconcile_managed_record(
10453 &self,
10454 record_id: &SessionRecordId,
10455 address: &SessionAddress,
10456 event: &ControlEvent,
10457 identity_admitted: bool,
10458 resume_command_rejected: bool,
10459 ) {
10460 let _transaction = self
10461 .state_transaction
10462 .lock()
10463 .unwrap_or_else(|poisoned| poisoned.into_inner());
10464 let observed_identity = match &event.event {
10465 ControlEventKind::ProviderEvent {
10466 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10467 ..
10468 } if identity_admitted => Some(identity.clone()),
10469 _ => None,
10470 };
10471 let identity_was_observed = observed_identity.is_some();
10472 let replacement_id = observed_identity
10473 .as_ref()
10474 .and_then(|_| self.allocate_record_id().ok());
10475 let mut upserts = Vec::new();
10476 let mut removals = Vec::new();
10477 let mut rebind = None;
10478 let original_records;
10479 let original_materializations = self
10480 .materializations
10481 .lock()
10482 .unwrap_or_else(|poisoned| poisoned.into_inner())
10483 .clone();
10484 {
10485 let mut records = self
10486 .session_records
10487 .lock()
10488 .unwrap_or_else(|poisoned| poisoned.into_inner());
10489 original_records = records.records.clone();
10490 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10491 return;
10492 };
10493 if let Some(identity) = observed_identity {
10494 let existing_id = records
10495 .records
10496 .values()
10497 .find(|record| {
10498 record.record_id != *record_id
10499 && record.provider_session.as_ref().is_some_and(|record_identity| {
10500 session_registry::same_provider_session(
10501 &record.provider,
10502 record_identity,
10503 ¤t_snapshot.provider,
10504 &identity,
10505 )
10506 })
10507 })
10508 .map(|record| record.record_id.clone());
10509 if let Some(existing_id) = existing_id {
10510 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10511 record.mode == current_snapshot.mode
10512 && record.workspace_id == current_snapshot.workspace_id
10513 && record.environment_profile == current_snapshot.environment_profile
10514 && record.bundle == current_snapshot.bundle
10515 && record.context_id == current_snapshot.context_id
10516 && record.context == current_snapshot.context
10517 && record
10518 .canonical_root
10519 .as_utf8()
10520 .zip(current_snapshot.canonical_root.as_utf8())
10521 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10522 });
10523 if !scope_matches {
10524 if let Some(current) = records.records.get_mut(record_id) {
10525 current.state = ManagedSessionState::Unavailable;
10526 current.last_error =
10527 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10528 current.updated_at_unix_ms = unix_time_ms();
10529 upserts.push(current.clone());
10530 }
10531 } else {
10532 let existing_is_active = records
10533 .records
10534 .get(&existing_id)
10535 .is_some_and(|record| record.active_session.is_some());
10536 if existing_is_active {
10537 if let Some(current) = records.records.get_mut(record_id) {
10538 current.state = ManagedSessionState::Unavailable;
10539 current.last_error =
10540 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10541 current.updated_at_unix_ms = unix_time_ms();
10542 upserts.push(current.clone());
10543 }
10544 } else {
10545 if current_snapshot.provider_session.is_some() {
10546 if let Some(current) = records.records.get_mut(record_id) {
10547 current.active_session = None;
10548 current.state = ManagedSessionState::Dormant;
10549 current.updated_at_unix_ms = unix_time_ms();
10550 upserts.push(current.clone());
10551 }
10552 } else {
10553 records.records.remove(record_id);
10554 removals.push(record_id.clone());
10555 }
10556 if let Some(existing) = records.records.get_mut(&existing_id) {
10557 existing.provider_session = Some(identity.clone());
10558 existing.active_session = Some(address.clone());
10559 existing.state = ManagedSessionState::Live;
10560 existing.updated_at_unix_ms = unix_time_ms();
10561 existing.last_error = None;
10562 upserts.push(existing.clone());
10563 }
10564 rebind = Some(existing_id);
10565 }
10566 }
10567 } else if current_snapshot
10568 .provider_session
10569 .as_ref()
10570 .is_some_and(|current| {
10571 !session_registry::same_provider_session(
10572 ¤t_snapshot.provider,
10573 current,
10574 ¤t_snapshot.provider,
10575 &identity,
10576 )
10577 })
10578 {
10579 if let Some(current) = records.records.get_mut(record_id) {
10580 current.active_session = None;
10581 if current.bundle.is_some() || current.context.is_some() {
10582 let had_bundle = current.bundle.is_some();
10583 current.provider_session = None;
10584 current.bundle = None;
10585 current.context_id = None;
10586 current.context = None;
10587 current.state = ManagedSessionState::Unavailable;
10588 current.last_error = Some(if had_bundle {
10589 "bundle-unavailable".to_owned()
10590 } else {
10591 "context-unavailable".to_owned()
10592 });
10593 } else {
10594 current.state = ManagedSessionState::Dormant;
10595 }
10596 current.updated_at_unix_ms = unix_time_ms();
10597 upserts.push(current.clone());
10598 }
10599 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10600 if let Some(current) = records.records.get_mut(record_id) {
10601 current.state = ManagedSessionState::Unavailable;
10602 current.last_error =
10603 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10604 current.updated_at_unix_ms = unix_time_ms();
10605 upserts.push(current.clone());
10606 }
10607 } else if let Some(new_record_id) = replacement_id {
10608 let now = unix_time_ms();
10609 let replacement = ManagedSessionRecord {
10610 record_id: new_record_id.clone(),
10611 display_name: format!(
10612 "{} #{}",
10613 current_snapshot.provider.as_str(),
10614 records
10615 .records
10616 .values()
10617 .filter(|record| {
10618 record.provider == current_snapshot.provider
10619 })
10620 .count()
10621 .saturating_add(1),
10622 ),
10623 provider: current_snapshot.provider.clone(),
10624 mode: current_snapshot.mode,
10625 state: ManagedSessionState::Live,
10626 workspace_id: current_snapshot.workspace_id,
10627 canonical_root: current_snapshot.canonical_root,
10628 provider_session: Some(identity),
10629 active_session: Some(address.clone()),
10630 environment_profile: current_snapshot.environment_profile,
10631 bundle: current_snapshot.bundle,
10632 context_id: current_snapshot.context_id,
10633 context: current_snapshot.context,
10634 exported_context: None,
10640 task_binding: current_snapshot.task_binding,
10641 created_at_unix_ms: now,
10642 updated_at_unix_ms: now,
10643 last_error: None,
10644 };
10645 records
10646 .records
10647 .insert(new_record_id.clone(), replacement.clone());
10648 upserts.push(replacement);
10649 rebind = Some(new_record_id);
10650 } else if let Some(current) = records.records.get_mut(record_id) {
10651 current.state = ManagedSessionState::Unavailable;
10652 current.last_error =
10653 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10654 upserts.push(current.clone());
10655 }
10656 } else if let Some(current) = records.records.get_mut(record_id) {
10657 current.provider_session = Some(identity);
10658 current.active_session = Some(address.clone());
10659 current.state = ManagedSessionState::Live;
10660 current.updated_at_unix_ms = unix_time_ms();
10661 current.last_error = None;
10662 upserts.push(current.clone());
10663 }
10664 } else if let Some(current) = records.records.get_mut(record_id) {
10665 if resume_command_rejected {
10666 current.active_session = None;
10667 current.state = detached_record_state(current);
10668 current.updated_at_unix_ms = unix_time_ms();
10669 current.last_error =
10670 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10671 upserts.push(current.clone());
10672 } else {
10673 match &event.event {
10674 ControlEventKind::Running { .. }
10675 | ControlEventKind::ResumeAuthorized { .. }
10676 | ControlEventKind::Resumed { .. } => {
10677 current.active_session = Some(address.clone());
10678 current.state = active_record_state(
10679 identity_admitted,
10680 current.provider_session.is_some(),
10681 );
10682 current.updated_at_unix_ms = unix_time_ms();
10683 current.last_error = None;
10684 upserts.push(current.clone());
10685 }
10686 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10687 current.active_session = None;
10688 current.state = detached_record_state(current);
10689 current.updated_at_unix_ms = unix_time_ms();
10690 upserts.push(current.clone());
10691 }
10692 ControlEventKind::Failed { message }
10693 | ControlEventKind::ResumeFailed { message } => {
10694 current.active_session = None;
10695 current.state = detached_record_state(current);
10696 current.updated_at_unix_ms = unix_time_ms();
10697 current.last_error = Some(
10698 session_registry::sanitized_record_error_summary(message),
10699 );
10700 upserts.push(current.clone());
10701 }
10702 ControlEventKind::ResumeDenied { reason } => {
10703 current.active_session = None;
10704 current.state = detached_record_state(current);
10705 current.updated_at_unix_ms = unix_time_ms();
10706 current.last_error = Some(
10707 session_registry::sanitized_record_error_summary(reason),
10708 );
10709 upserts.push(current.clone());
10710 }
10711 _ => {}
10712 }
10713 }
10714 }
10715 }
10716 let materialization_owner_record = identity_was_observed.then(|| {
10717 rebind.clone().unwrap_or_else(|| record_id.clone())
10718 }).and_then(|candidate| {
10719 self.session_records
10720 .lock()
10721 .unwrap_or_else(|poisoned| poisoned.into_inner())
10722 .records
10723 .get(&candidate)
10724 .filter(|record| {
10725 record.provider_session.is_some()
10726 && record.active_session.as_ref() == Some(address)
10727 })
10728 .map(|_| candidate)
10729 });
10730 if let Some(owner_record_id) = materialization_owner_record {
10731 let materialization_id = self
10732 .session_bindings
10733 .lock()
10734 .unwrap_or_else(|poisoned| poisoned.into_inner())
10735 .get(&address.session.instance_id)
10736 .and_then(|binding| binding.materialization_id.clone());
10737 if let Some(materialization_id) = materialization_id {
10738 let mut transfer_failed = false;
10739 {
10740 let mut materializations = self
10741 .materializations
10742 .lock()
10743 .unwrap_or_else(|poisoned| poisoned.into_inner());
10744 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10745 match ownership.owner() {
10746 MaterializationOwner::Session {
10747 incarnation_id,
10748 instance_id,
10749 generation,
10750 } if *incarnation_id == self.incarnation_id
10751 && *instance_id == address.session.instance_id
10752 && *generation == address.session.generation =>
10753 {
10754 transfer_failed = ownership
10755 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10756 .is_err();
10757 }
10758 MaterializationOwner::Record { record_id }
10759 if record_id == &owner_record_id => {}
10760 MaterializationOwner::Record { record_id } => {
10761 let expected = record_id.clone();
10762 transfer_failed = ownership
10763 .transfer_record_owner(
10764 &expected,
10765 owner_record_id.clone(),
10766 unix_time_ms(),
10767 )
10768 .is_err();
10769 }
10770 _ => transfer_failed = true,
10771 }
10772 if transfer_failed {
10773 let _ = ownership.mark_recovery_required(unix_time_ms());
10774 }
10775 } else {
10776 transfer_failed = true;
10777 }
10778 }
10779 if transfer_failed {
10780 if let Some(record) = self
10781 .session_records
10782 .lock()
10783 .unwrap_or_else(|poisoned| poisoned.into_inner())
10784 .records
10785 .get_mut(&owner_record_id)
10786 {
10787 record.state = ManagedSessionState::Unavailable;
10788 record.last_error = Some("environment-profile-unavailable".to_owned());
10789 record.updated_at_unix_ms = unix_time_ms();
10790 upserts.push(record.clone());
10791 }
10792 }
10793 }
10794 }
10795 if let Some(new_record_id) = rebind.clone() {
10796 if let Some(binding) = self
10797 .session_bindings
10798 .lock()
10799 .unwrap_or_else(|poisoned| poisoned.into_inner())
10800 .get_mut(&address.session.instance_id)
10801 {
10802 binding.record_id = Some(new_record_id);
10803 }
10804 }
10805 if upserts.is_empty() && removals.is_empty() {
10806 return;
10807 }
10808 if self.persist_state_locked().is_err() {
10809 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10810 let mut records = self
10811 .session_records
10812 .lock()
10813 .unwrap_or_else(|poisoned| poisoned.into_inner());
10814 records.records = original_records;
10815 *self
10816 .materializations
10817 .lock()
10818 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10819 if let Some(current) = records.records.get_mut(record_id) {
10820 current.state = ManagedSessionState::Unavailable;
10821 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10822 current.updated_at_unix_ms = unix_time_ms();
10823 upserts = vec![current.clone()];
10824 } else {
10825 upserts.clear();
10826 }
10827 removals.clear();
10828 drop(records);
10829 if let Some(binding) = self
10830 .session_bindings
10831 .lock()
10832 .unwrap_or_else(|poisoned| poisoned.into_inner())
10833 .get_mut(&address.session.instance_id)
10834 {
10835 binding.record_id = Some(record_id.clone());
10836 }
10837 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10838 }
10839 for record in upserts {
10840 self.publish_record(record);
10841 }
10842 for record_id in removals {
10843 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10844 }
10845 }
10846
10847 fn snapshot(&self) -> NodeSnapshot {
10848 let control = self.handle.snapshot();
10849 let bindings = self
10850 .session_bindings
10851 .lock()
10852 .unwrap_or_else(|poisoned| poisoned.into_inner())
10853 .clone();
10854 let workspace_roots = self
10855 .workspaces
10856 .read()
10857 .unwrap_or_else(|poisoned| poisoned.into_inner())
10858 .clone();
10859 let environment_profiles = self
10860 .environment_profiles
10861 .read()
10862 .unwrap_or_else(|poisoned| poisoned.into_inner());
10863 let mut workspaces = workspace_roots
10864 .iter()
10865 .map(|(workspace_id, canonical_root)| {
10866 (
10867 workspace_id.clone(),
10868 WorkspaceSnapshot {
10869 workspace_id: workspace_id.clone(),
10870 canonical_root: opaque_windows_path(canonical_root.clone()),
10871 sessions: Vec::new(),
10872 worktree_service_mode: Some(
10873 self.worktree_service_modes
10874 .get(workspace_id)
10875 .copied()
10876 .unwrap_or(WorktreeServiceMode::Manual),
10877 ),
10878 managed_worktree_profiles: Some(WorktreeProfileInventory {
10879 profiles: self
10880 .managed_worktree_profiles
10881 .get(workspace_id)
10882 .into_iter()
10883 .flat_map(BTreeMap::values)
10884 .map(|profile| ManagedWorktreeProfileSummary {
10885 id: profile.profile_id().clone(),
10886 revision: profile.revision().clone(),
10887 retention: profile.retention(),
10888 })
10889 .collect(),
10890 }),
10891 },
10892 )
10893 })
10894 .collect::<BTreeMap<_, _>>();
10895 let mut agent_progress = Vec::new();
10896 for session in &control.sessions {
10897 let Some(binding) = bindings.get(&session.instance_id) else {
10898 continue;
10899 };
10900 if binding.generation != session.generation {
10901 continue;
10902 }
10903 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10904 let address = SessionAddress {
10905 workspace_id: binding.workspace_id.clone(),
10906 session: SessionKey {
10907 instance_id: session.instance_id,
10908 generation: session.generation,
10909 },
10910 };
10911 if let Some(progress) =
10912 agent_progress_from_provider_snapshot(address, &session.provider)
10913 {
10914 agent_progress.push(progress);
10915 }
10916 workspace.sessions.push(session.clone());
10917 }
10918 }
10919 NodeSnapshot {
10920 node_id: self.node_id.clone(),
10921 enabled_providers: self.enabled_providers.clone(),
10922 provider_runtime_statuses: {
10923 let updates = self
10924 .provider_runtime_status_updates
10925 .read()
10926 .unwrap_or_else(|poisoned| poisoned.into_inner());
10927 ProviderRuntimeStatuses::new(
10928 self.provider_runtime_statuses
10929 .iter()
10930 .filter(|status| !updates.contains_key(status.provider()))
10931 .cloned()
10932 .chain(updates.values().cloned()),
10933 )
10934 .expect("refreshed provider runtime inventory remains bounded and unique")
10935 },
10936 workspaces: workspaces.into_values().collect(),
10937 session_records: self
10938 .session_records
10939 .lock()
10940 .unwrap_or_else(|poisoned| poisoned.into_inner())
10941 .records
10942 .values()
10943 .cloned()
10944 .collect(),
10945 managed_worktrees: self.managed_worktrees
10946 .lock()
10947 .unwrap_or_else(|poisoned| poisoned.into_inner())
10948 .snapshots(),
10949 launch_inventory: Some(LaunchInventory {
10950 spawn_profiles: Some(
10951 self.spawn_profiles
10952 .iter()
10953 .filter_map(|profile| {
10954 resolved_spawn_profile_summary(profile, &environment_profiles)
10955 })
10956 .collect(),
10957 ),
10958 bundles: Some(
10959 self.bundle_catalog
10960 .read()
10961 .unwrap_or_else(|poisoned| poisoned.into_inner())
10962 .iter()
10963 .map(NodeBundle::receipt)
10964 .collect(),
10965 ),
10966 network_allowlists: {
10969 let catalog = self
10970 .network_allowlist_catalog
10971 .read()
10972 .unwrap_or_else(|poisoned| poisoned.into_inner());
10973 if catalog.is_empty() {
10974 None
10975 } else {
10976 Some(catalog.ids().cloned().collect())
10977 }
10978 },
10979 }),
10980 agent_progress,
10981 }
10982 }
10983
10984 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10985 assert!(
10986 !matches!(
10987 &event,
10988 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10989 ),
10990 "terminal frame and agent stream events must use their own dedicated live channel",
10991 );
10992 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10993 let sequence = history
10994 .last_sequence
10995 .checked_add(1)
10996 .expect("node event sequence exhausted");
10997 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10998 if let Some(evicted) = history.events.pop_front() {
10999 history.replay_floor_sequence = evicted
11000 .sequence
11001 .checked_add(1)
11002 .expect("node replay floor sequence exhausted");
11003 history.removed_record_providers.remove(&evicted.sequence);
11004 }
11005 }
11006 match &event {
11007 NodeEvent::HarnessMcpReadCall { .. } => {
11008 panic!("transient harness MCP calls must not enter durable event history")
11009 }
11010 NodeEvent::SessionRecordUpserted { record } => {
11011 history
11012 .record_providers
11013 .insert(record.record_id.clone(), record.provider.clone());
11014 }
11015 NodeEvent::SessionRecordRemoved { record_id } => {
11016 if let Some(provider) = history.record_providers.remove(record_id) {
11017 history.removed_record_providers.insert(sequence, provider);
11018 }
11019 }
11020 NodeEvent::ManagedWorktreeUpserted { .. }
11021 | NodeEvent::ManagedWorktreeRemoved { .. }
11022 | NodeEvent::Control { .. }
11023 | NodeEvent::ControllerChanged { .. }
11024 | NodeEvent::SessionRecordHistorySummarized { .. }
11025 | NodeEvent::WorkspaceAdded { .. }
11026 | NodeEvent::WorkspaceRemoved { .. }
11027 | NodeEvent::ResyncRequired { .. }
11028 | NodeEvent::AgentStream { .. }
11029 | NodeEvent::TerminalFrame { .. } => {}
11030 }
11031 let envelope = NodeEventEnvelope { sequence, event };
11032 history.events.push_back(envelope.clone());
11033 history.last_sequence = sequence;
11034 let _ = self.event_tx.send(envelope.clone());
11035 drop(history);
11036 envelope
11037 }
11038
11039 fn publish_transient(&self, event: NodeEvent) -> bool {
11050 assert!(event.requires_harness_mcp_proxy_capability());
11051 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11052 let envelope = NodeEventEnvelope { sequence: 0, event };
11053 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11054 Ok(_) => true,
11055 Err(broadcast::error::SendError(undelivered)) => {
11056 if let Some(NodeEventEnvelope {
11057 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11058 ..
11059 }) = undelivered.first() {
11060 tracing::warn!(
11066 call_id = call_id.as_str(),
11067 content_type = ?request.content_type,
11068 cause = "no-subscribers",
11069 "harness MCP proxy event could not be published",
11070 );
11071 }
11072 false
11073 }
11074 }
11075 }
11076
11077 fn publish_terminal_frames(&self) {
11078 let snapshot = self.handle.snapshot();
11079 let bindings = self
11080 .session_bindings
11081 .lock()
11082 .unwrap_or_else(|poisoned| poisoned.into_inner());
11083 let candidates = snapshot
11084 .sessions
11085 .iter()
11086 .filter_map(|session| {
11087 let binding = bindings.get(&session.instance_id)?;
11088 if binding.generation != session.generation {
11089 return None;
11090 }
11091 let frame = session.terminal_frame.clone()?;
11092 crate::session_signals::feed(
11093 binding.workspace_id.as_str(),
11094 session.instance_id.0,
11095 session.agent_id.as_str(),
11096 &frame.contents,
11097 );
11098 Some((
11099 SessionAddress {
11100 workspace_id: binding.workspace_id.clone(),
11101 session: SessionKey {
11102 instance_id: session.instance_id,
11103 generation: session.generation,
11104 },
11105 },
11106 frame,
11107 ))
11108 })
11109 .collect();
11110 drop(bindings);
11111 self.publish_terminal_frame_candidates(candidates);
11112 }
11113
11114 fn publish_terminal_frame_candidates(
11115 &self,
11116 candidates: Vec<(SessionAddress, TerminalFrame)>,
11117 ) {
11118 let mut watermarks = self
11119 .terminal_frame_watermarks
11120 .lock()
11121 .unwrap_or_else(|poisoned| poisoned.into_inner());
11122 let mut events = Vec::with_capacity(candidates.len());
11123 for (address, frame) in candidates {
11124 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11125 let advanced = watermarks
11126 .get(&address.session.instance_id)
11127 .map_or(true, |(current_address, sequence)| {
11128 current_address != &address || frame.sequence > *sequence
11129 });
11130 if !advanced {
11131 continue;
11132 }
11133 if is_first_frame {
11134 if self.controller_state().is_none() {
11148 tracing::warn!(
11149 node_id = %self.node_id,
11150 workspace_id = %address.workspace_id,
11151 session = ?address.session,
11152 terminal_size = ?frame.size,
11153 "session producing output with no controller attached",
11154 );
11155 } else {
11156 tracing::info!(
11157 node_id = %self.node_id,
11158 workspace_id = %address.workspace_id,
11159 session = ?address.session,
11160 terminal_size = ?frame.size,
11161 "session produced its first output",
11162 );
11163 }
11164 }
11165 watermarks.insert(
11166 address.session.instance_id,
11167 (address.clone(), frame.sequence),
11168 );
11169 if let Some((at, reported)) = self
11170 .pty_input_marks
11171 .lock()
11172 .unwrap_or_else(|poisoned| poisoned.into_inner())
11173 .get_mut(&address.session.instance_id)
11174 {
11175 if !*reported && at.elapsed() < Duration::from_secs(5) {
11177 *reported = true;
11178 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");
11179 }
11180 }
11181 events.push(NodeEvent::TerminalFrame { address, frame });
11182 }
11183 drop(watermarks);
11184 if events.is_empty() {
11185 return;
11186 }
11187 let mut history = self
11188 .history
11189 .lock()
11190 .unwrap_or_else(|poisoned| poisoned.into_inner());
11191 let mut envelopes = Vec::with_capacity(events.len());
11192 for event in events {
11193 let sequence = history
11194 .last_sequence
11195 .checked_add(1)
11196 .expect("node event sequence exhausted");
11197 history.last_sequence = sequence;
11198 envelopes.push(NodeEventEnvelope { sequence, event });
11199 }
11200 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11201 drop(history);
11202 }
11203
11204 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11213 let mut history = self
11214 .history
11215 .lock()
11216 .unwrap_or_else(|poisoned| poisoned.into_inner());
11217 let sequence = history
11218 .last_sequence
11219 .checked_add(1)
11220 .expect("node event sequence exhausted");
11221 history.last_sequence = sequence;
11222 drop(history);
11223 tracing::debug!(
11231 source_sequence = chunk.source_sequence,
11232 kind = agent_stream_chunk_kind_label(&chunk.kind),
11233 envelope_sequence = sequence,
11234 "publishing agent stream chunk",
11235 );
11236 let envelope = NodeEventEnvelope {
11237 sequence,
11238 event: NodeEvent::AgentStream { address, chunk },
11239 };
11240 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11241 }
11242
11243 fn current_sequence(&self) -> u64 {
11244 self.history
11245 .lock()
11246 .unwrap_or_else(|poisoned| poisoned.into_inner())
11247 .last_sequence
11248 }
11249
11250 fn controller_state(&self) -> Option<ControllerState> {
11251 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11252 let lease = controller.as_ref().copied()?;
11253 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11254 if remaining.is_zero() {
11255 *controller = None;
11256 return None;
11257 }
11258 Some(ControllerState {
11259 connection_id: lease.connection_id,
11260 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11261 })
11262 }
11263
11264 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11265 if role != ClientRole::Operator {
11266 tracing::warn!(
11267 node_id = %self.node_id,
11268 connection_id,
11269 "controller lease rejected: observer connections cannot acquire control",
11270 );
11271 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11272 }
11273 let lease_ms = if lease_ms == 0 {
11274 DEFAULT_CONTROLLER_LEASE_MS
11275 } else {
11276 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11277 };
11278 let now = Instant::now();
11279 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11280 if let Some(active) = controller.as_ref() {
11281 if active.connection_id != connection_id && active.expires_at > now {
11282 tracing::warn!(
11283 node_id = %self.node_id,
11284 connection_id,
11285 holder_connection_id = active.connection_id,
11286 "controller lease rejected: another operator holds it",
11287 );
11288 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11289 }
11290 }
11291 let is_new_attach = match controller.as_ref() {
11298 Some(active) => active.connection_id != connection_id,
11299 None => true,
11300 };
11301 *controller = Some(ControllerLease {
11302 connection_id,
11303 expires_at: now + Duration::from_millis(lease_ms),
11304 });
11305 drop(controller);
11306 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11307 if is_new_attach {
11308 tracing::info!(
11309 node_id = %self.node_id,
11310 connection_id,
11311 lease_ms,
11312 "controller lease acquired",
11313 );
11314 } else {
11315 tracing::debug!(
11316 node_id = %self.node_id,
11317 connection_id,
11318 lease_ms,
11319 "controller lease renewed",
11320 );
11321 }
11322 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11323 Ok(state)
11324 }
11325
11326 fn release_controller(&self, connection_id: u64) -> bool {
11327 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11328 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11329 if released {
11330 *controller = None;
11331 }
11332 drop(controller);
11333 if released {
11334 tracing::info!(
11335 node_id = %self.node_id,
11336 connection_id,
11337 "controller lease released",
11338 );
11339 self.publish(NodeEvent::ControllerChanged { controller: None });
11340 }
11341 released
11342 }
11343
11344 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11353 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11354 return;
11355 };
11356 let id = address.session.instance_id;
11357 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11358 return;
11359 };
11360 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11361 .iter()
11362 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11363 .collect();
11364 let (writes, set_model, effort_gap) =
11365 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11366 if set_model {
11367 if let Some(model_id) = model.clone() {
11368 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11369 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11370 }
11371 }
11372 }
11373 for (option_id, value_json) in writes {
11374 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11375 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11376 }
11377 }
11378 if effort_gap {
11379 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11380 }
11381 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11382 }
11383
11384 fn mark_spawn_provisional(&self, address: SessionAddress) {
11385 let id = address.session.instance_id;
11386 self.provisional_spawns
11387 .lock()
11388 .unwrap_or_else(|poisoned| poisoned.into_inner())
11389 .insert(id, (address, Instant::now()));
11390 tracing::info!(
11391 node_id = %self.node_id,
11392 instance_id = id.0,
11393 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11394 "draft spawn; waiting for HQ view",
11395 );
11396 }
11397
11398 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11399 let removed = self
11400 .provisional_spawns
11401 .lock()
11402 .unwrap_or_else(|poisoned| poisoned.into_inner())
11403 .remove(&instance_id);
11404 if removed.is_some() {
11405 tracing::info!(
11406 node_id = %self.node_id,
11407 instance_id = instance_id.0,
11408 "draft spawn claimed by HQ view",
11409 );
11410 }
11411 }
11412
11413 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11414 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11415 let mut map = self
11416 .provisional_spawns
11417 .lock()
11418 .unwrap_or_else(|poisoned| poisoned.into_inner());
11419 let due: Vec<AgentInstanceId> = map
11420 .iter()
11421 .filter(|(_, (_, at))| at.elapsed() >= grace)
11422 .map(|(id, _)| *id)
11423 .collect();
11424 due.into_iter()
11425 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11426 .collect()
11427 }
11428
11429 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11430 let instance_id = address.session.instance_id;
11431 tracing::warn!(
11432 node_id = %self.node_id,
11433 instance_id = instance_id.0,
11434 workspace = %address.workspace_id,
11435 reason = "attach_never_arrived",
11436 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11437 "draft spawn never viewed; stopping PTY",
11438 );
11439 let _ = self.dispatch(ControlCommand::Stop {
11440 instance_id,
11441 force: true,
11442 });
11443 let _ = self.remove_session(&address).await;
11444 }
11445
11446 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11447 if role != ClientRole::Operator {
11448 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11449 }
11450 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11451 let now = Instant::now();
11452 match controller.as_ref().copied() {
11453 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11454 Some(active) if active.expires_at <= now => {
11455 *controller = None;
11456 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11457 }
11458 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11459 }
11460 }
11461
11462 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11463 if !self
11464 .workspaces
11465 .read()
11466 .unwrap_or_else(|poisoned| poisoned.into_inner())
11467 .contains_key(&address.workspace_id)
11468 {
11469 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11470 }
11471 let binding = self
11472 .session_bindings
11473 .lock()
11474 .unwrap_or_else(|poisoned| poisoned.into_inner())
11475 .get(&address.session.instance_id)
11476 .cloned()
11477 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11478 if binding.workspace_id != address.workspace_id {
11479 return Err(failure(
11480 NodeFailureCode::SessionWorkspaceMismatch,
11481 "session belongs to another workspace",
11482 ));
11483 }
11484 if binding.generation != address.session.generation {
11485 return Err(failure(
11486 NodeFailureCode::StaleGeneration,
11487 &format!(
11488 "stale session generation: expected {}, received {}",
11489 binding.generation.0,
11490 address.session.generation.0,
11491 ),
11492 ));
11493 }
11494 let snapshot = self.handle.snapshot();
11495 let Some(current) = snapshot
11496 .sessions
11497 .iter()
11498 .find(|item| item.instance_id == address.session.instance_id)
11499 else {
11500 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11501 };
11502 if current.generation != address.session.generation {
11503 return Err(failure(
11504 NodeFailureCode::StaleGeneration,
11505 &format!(
11506 "stale session generation: expected {}, received {}",
11507 current.generation.0,
11508 address.session.generation.0,
11509 ),
11510 ));
11511 }
11512 Ok(current.agent_id.clone())
11513 }
11514
11515 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11516 self.dispatch_envelope(self.prepare_command(command))
11517 }
11518
11519 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11520 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11521 CommandEnvelope {
11522 id: CommandId(id),
11523 command,
11524 }
11525 }
11526
11527 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11528 let command_id = envelope.id;
11529 self.handle
11530 .dispatch(envelope)
11531 .map_err(|error| match error {
11532 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11533 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11534 })?;
11535 Ok(command_id)
11536 }
11537
11538 fn record_rejected_command(
11547 &self,
11548 command_id: CommandId,
11549 message: String,
11550 unsupported_transport: bool,
11551 ) {
11552 let now = Instant::now();
11553 let mut reasons = self.rejected_command_reasons.lock()
11554 .unwrap_or_else(|poisoned| poisoned.into_inner());
11555 reasons.retain(|_, reason| reason.expires_at > now);
11556 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11557 if let Some(&oldest) = reasons.keys().next() {
11558 reasons.remove(&oldest);
11559 }
11560 }
11561 reasons.insert(
11562 command_id,
11563 RejectedCommandReason {
11564 message,
11565 unsupported_transport,
11566 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11567 },
11568 );
11569 }
11570
11571 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11578 let now = Instant::now();
11579 let mut reasons = self.rejected_command_reasons.lock()
11580 .unwrap_or_else(|poisoned| poisoned.into_inner());
11581 match reasons.remove(&command_id) {
11582 Some(reason) if reason.expires_at > now => Some(reason),
11583 _ => None,
11584 }
11585 }
11586
11587 async fn dispatch_bounded(
11588 &self,
11589 command: ControlCommand,
11590 timeout_duration: Duration,
11591 ) -> Result<CommandId, NodeFailure> {
11592 let deadline = Instant::now() + timeout_duration;
11593 loop {
11594 match self.dispatch(command.clone()) {
11595 Ok(command_id) => return Ok(command_id),
11596 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11597 sleep(Duration::from_millis(2)).await;
11598 }
11599 Err(error) => return Err(error),
11600 }
11601 }
11602 }
11603
11604 fn internal_session_snapshot(
11605 &self,
11606 address: &SessionAddress,
11607 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11608 self.validate_address(address)?;
11609 self.handle
11610 .snapshot()
11611 .sessions
11612 .clone()
11613 .into_iter()
11614 .find(|session| {
11615 session.instance_id == address.session.instance_id
11616 && session.generation == address.session.generation
11617 })
11618 .ok_or_else(|| {
11619 failure(
11620 NodeFailureCode::UnknownSession,
11621 "session snapshot is unavailable",
11622 )
11623 })
11624 }
11625
11626 fn require_acp_session(
11634 &self,
11635 address: &SessionAddress,
11636 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11637 let snapshot = self.internal_session_snapshot(address)?;
11638 if snapshot.transport != TransportKind::Acp {
11639 return Err(failure(
11640 NodeFailureCode::UnsupportedTransport,
11641 "this verb requires a session spawned over the ACP transport",
11642 ));
11643 }
11644 Ok(snapshot)
11645 }
11646
11647 async fn dispatch_history_bounded(
11648 &self,
11649 address: &SessionAddress,
11650 command: ControlCommand,
11651 operation: HistoryOperation,
11652 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11653 self.validate_address(address)?;
11654 let started_after = self.current_sequence();
11655 let command_id = self
11656 .dispatch_bounded(
11657 command,
11658 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11659 )
11660 .await?;
11661 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11662 let mut scan_after = started_after;
11663 let mut accepted_after = None;
11664 loop {
11665 let events = self
11666 .history
11667 .lock()
11668 .unwrap_or_else(|poisoned| poisoned.into_inner())
11669 .events
11670 .iter()
11671 .filter(|envelope| envelope.sequence > scan_after)
11672 .cloned()
11673 .collect::<Vec<_>>();
11674 for envelope in events {
11675 scan_after = scan_after.max(envelope.sequence);
11676 let NodeEvent::Control {
11677 address: event_address,
11678 event,
11679 } = envelope.event
11680 else {
11681 continue;
11682 };
11683 if event_address != *address {
11684 continue;
11685 }
11686 if event.command_id == Some(command_id) {
11687 match &event.event {
11688 ControlEventKind::CommandRejected { .. } => {
11689 return Err(failure(
11690 NodeFailureCode::InvalidRequest,
11691 "history request was rejected",
11692 ));
11693 }
11694 ControlEventKind::HistoryRequested {
11695 operation: requested,
11696 ..
11697 } if requested == &operation => {
11698 accepted_after = Some(envelope.sequence);
11699 continue;
11700 }
11701 _ => {}
11702 }
11703 }
11704 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11705 match (&operation, &event.event) {
11706 (
11707 HistoryOperation::Discover { .. },
11708 ControlEventKind::HistoryDiscovered { .. },
11709 )
11710 | (
11711 HistoryOperation::Load { .. },
11712 ControlEventKind::HistoryLoaded { .. },
11713 ) => return self.internal_session_snapshot(address),
11714 (_, ControlEventKind::HistoryFailed { .. }) => {
11715 return Err(failure(
11716 NodeFailureCode::BackendOperationFailed,
11717 "history operation failed",
11718 ));
11719 }
11720 _ => {}
11721 }
11722 }
11723 }
11724 if Instant::now() >= deadline {
11725 return Err(failure(
11726 NodeFailureCode::BackendBusy,
11727 if accepted_after.is_some() {
11728 "history operation did not settle before the bounded deadline"
11729 } else {
11730 "history operation was not accepted before the bounded deadline"
11731 },
11732 ));
11733 }
11734 sleep(Duration::from_millis(2)).await;
11735 }
11736 }
11737
11738 async fn discover_history(
11739 &self,
11740 address: &SessionAddress,
11741 limit: u16,
11742 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11743 let query = HistoryQuery {
11744 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11745 limit,
11746 };
11747 query
11748 .validate()
11749 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11750 let snapshot = self
11751 .dispatch_history_bounded(
11752 address,
11753 ControlCommand::DiscoverHistory {
11754 instance_id: address.session.instance_id,
11755 query: query.clone(),
11756 },
11757 HistoryOperation::Discover { query },
11758 )
11759 .await?;
11760 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11761 return Err(failure(
11762 NodeFailureCode::BackendOperationFailed,
11763 "history discovery settled into an invalid state",
11764 ));
11765 }
11766 Ok(snapshot.history.candidates)
11767 }
11768
11769 async fn load_history(
11770 &self,
11771 address: &SessionAddress,
11772 candidate_id: String,
11773 ) -> Result<HistorySessionRecord, NodeFailure> {
11774 validate_candidate_id(&candidate_id)
11775 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11776 let snapshot = self
11777 .dispatch_history_bounded(
11778 address,
11779 ControlCommand::LoadHistory {
11780 instance_id: address.session.instance_id,
11781 candidate_id: candidate_id.clone(),
11782 },
11783 HistoryOperation::Load {
11784 candidate_id: candidate_id.clone(),
11785 },
11786 )
11787 .await?;
11788 if snapshot.history.pending.is_some()
11789 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11790 {
11791 return Err(failure(
11792 NodeFailureCode::BackendOperationFailed,
11793 "history load settled into an invalid state",
11794 ));
11795 }
11796 snapshot.history.loaded.ok_or_else(|| {
11797 failure(
11798 NodeFailureCode::BackendOperationFailed,
11799 "history load produced no bounded session",
11800 )
11801 })
11802 }
11803
11804 async fn export_context_pack_for_session_record(
11811 &self,
11812 record_id: &SessionRecordId,
11813 session: &SessionAddress,
11814 allow_clean_detachment: bool,
11815 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11816 let outcome = self
11822 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11823 .await;
11824 if let Err(failure) = &outcome {
11825 tracing::warn!(
11826 record = %record_id,
11827 code = ?failure.code,
11828 reason = %failure.message,
11829 "session-record context-pack export refused",
11830 );
11831 }
11832 outcome
11833 }
11834
11835 async fn export_context_pack_for_session_record_inner(
11836 &self,
11837 record_id: &SessionRecordId,
11838 session: &SessionAddress,
11839 allow_clean_detachment: bool,
11840 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11841 let step = |name: &'static str| move |failure: NodeFailure| {
11846 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11847 failure
11848 };
11849 let record = self.record(record_id).map_err(step("record-lookup"))?;
11850 let identity = match record.provider_session.clone() {
11866 Some(identity) => {
11867 if identity.key != ProviderSessionKey::SessionId {
11868 return Err(step("provider-identity-key")(failure(
11869 NodeFailureCode::BackendOperationFailed,
11870 "managed session provider identity cannot bind history exactly",
11871 )));
11872 }
11873 Some(identity)
11874 }
11875 None if record.state == ManagedSessionState::IdentityPending => {
11876 return Err(step("provider-session-identity")(failure(
11877 NodeFailureCode::BackendOperationFailed,
11878 "managed session has not yet observed its provider session identity",
11879 )));
11880 }
11881 None => None,
11882 };
11883 self.revalidate_session_record_context_export(
11884 &record,
11885 identity.as_ref(),
11886 session,
11887 allow_clean_detachment,
11888 ).map_err(step("revalidate-before-discovery"))?;
11889 let candidates = self
11890 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11891 .await
11892 .map_err(step("discover-history"))?;
11893 let candidate_id = select_history_candidate(
11898 &candidates,
11899 identity.as_ref().map(|identity| identity.id.as_str()),
11900 ).map_err(step("unique-candidate"))?;
11901 let loaded = self.load_history(session, candidate_id.clone()).await
11902 .map_err(step("load-history"))?;
11903 if let Some(identity) = identity.as_ref() {
11904 if loaded.session_id != identity.id {
11905 return Err(failure(
11906 NodeFailureCode::SessionRecordConflict,
11907 "loaded history identity does not match the managed session record",
11908 ));
11909 }
11910 }
11911 self.revalidate_session_record_context_export(
11912 &record,
11913 identity.as_ref(),
11914 session,
11915 allow_clean_detachment,
11916 )?;
11917 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11918 .map_err(step("revalidate-loaded-history"))?;
11919 let pack = self.materialize_context_pack(session).await
11920 .map_err(step("materialize-context-pack"))?;
11921 self.commit_context_pack_for_session_record(
11922 &record,
11923 identity.as_ref(),
11924 session,
11925 &candidate_id,
11926 &loaded,
11927 pack,
11928 allow_clean_detachment,
11929 )
11930 }
11931
11932 async fn export_context_pack(
11933 &self,
11934 address: &SessionAddress,
11935 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11936 let pack = self.materialize_context_pack(address).await?;
11937 self.commit_context_pack(pack)
11938 }
11939
11940 async fn materialize_context_pack(
11941 &self,
11942 address: &SessionAddress,
11943 ) -> Result<NodeContextPack, NodeFailure> {
11944 let snapshot = self.internal_session_snapshot(address)?;
11945 if !context_pack_source_status_is_usable(&snapshot.status) {
11946 return Err(failure(
11947 NodeFailureCode::InvalidRequest,
11948 "context export requires a running or successfully exited source session",
11949 ));
11950 }
11951 if snapshot.history.pending.is_some()
11952 || snapshot.history.loaded_candidate_id.is_none()
11953 {
11954 return Err(failure(
11955 NodeFailureCode::InvalidRequest,
11956 "context export requires settled loaded history",
11957 ));
11958 }
11959 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11960 failure(
11961 NodeFailureCode::InvalidRequest,
11962 "context export requires settled loaded history",
11963 )
11964 })?;
11965 let source_provider = snapshot.agent_id.clone();
11966 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11967 let repository = self
11968 .context_pack_repository(&address.workspace_id)
11969 .await?;
11970 let current = self.internal_session_snapshot(address)?;
11971 if !context_pack_source_status_is_usable(¤t.status)
11972 || current.agent_id != source_provider
11973 || current.history.pending.is_some()
11974 || current.history.loaded_candidate_id != loaded_candidate_id
11975 || current.history.loaded.as_ref() != Some(&loaded)
11976 {
11977 return Err(failure(
11978 NodeFailureCode::ContextPackMaterializationFailed,
11979 "context source changed during bounded repository capture",
11980 ));
11981 }
11982 NodeContextPack::export_with_repository(
11983 ContextPackLineageReceipt {
11984 source_node_id: self.node_id.clone(),
11985 source_session: address.clone(),
11986 source_provider,
11987 },
11988 &loaded,
11989 Some(repository),
11990 )
11991 .map_err(|_| {
11992 failure(
11993 NodeFailureCode::ContextPackMaterializationFailed,
11994 "bounded context pack export failed",
11995 )
11996 })
11997 }
11998
11999 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12005 let mut store = self
12006 .context_pack_store
12007 .lock()
12008 .unwrap_or_else(|poisoned| poisoned.into_inner());
12009 let Some(store) = store.as_mut() else {
12010 return Ok(());
12011 };
12012 store.commit(pack).map_err(|_| {
12013 failure(
12014 NodeFailureCode::ContextPackMaterializationFailed,
12015 "durable context pack commit failed",
12016 )
12017 })
12018 }
12019
12020 fn commit_context_pack(
12021 &self,
12022 pack: NodeContextPack,
12023 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12024 self.commit_context_pack_durably(&pack)?;
12025 self.context_catalog
12026 .write()
12027 .unwrap_or_else(|poisoned| poisoned.into_inner())
12028 .insert(pack)
12029 .map_err(|_| {
12030 failure(
12031 NodeFailureCode::ContextPackMaterializationFailed,
12032 "context pack catalog is full",
12033 )
12034 })
12035 }
12036
12037 fn commit_context_pack_for_session_record(
12038 &self,
12039 expected: &ManagedSessionRecord,
12040 identity: Option<&ProviderSessionIdentity>,
12041 session: &SessionAddress,
12042 candidate_id: &str,
12043 loaded: &HistorySessionRecord,
12044 pack: NodeContextPack,
12045 allow_clean_detachment: bool,
12046 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12047 let current_root = self.workspace_root(&expected.workspace_id)?;
12048 let _transaction = self
12049 .state_transaction
12050 .lock()
12051 .unwrap_or_else(|poisoned| poisoned.into_inner());
12052 let records = self
12053 .session_records
12054 .lock()
12055 .unwrap_or_else(|poisoned| poisoned.into_inner());
12056 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12057 failure(
12058 NodeFailureCode::SessionRecordConflict,
12059 "managed session record disappeared during context export",
12060 )
12061 })?;
12062 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12063 return Err(failure(
12064 NodeFailureCode::SessionRecordConflict,
12065 "managed session record changed during context export",
12066 ));
12067 }
12068 let bindings = self
12069 .session_bindings
12070 .lock()
12071 .unwrap_or_else(|poisoned| poisoned.into_inner());
12072 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12073 failure(
12074 NodeFailureCode::SessionRecordConflict,
12075 "managed session runtime binding disappeared during context export",
12076 )
12077 })?;
12078 let runtime = self
12079 .handle
12080 .snapshot()
12081 .sessions
12082 .iter()
12083 .find(|snapshot| {
12084 snapshot.instance_id == session.session.instance_id
12085 && snapshot.generation == session.session.generation
12086 })
12087 .cloned()
12088 .ok_or_else(|| {
12089 failure(
12090 NodeFailureCode::SessionRecordConflict,
12091 "managed session runtime disappeared during context export",
12092 )
12093 })?;
12094 let receipt = pack.receipt();
12095 if !session_record_context_export_binding_is_exact(
12096 expected,
12097 current,
12098 identity,
12099 session,
12100 binding,
12101 &runtime.agent_id,
12102 ¤t_root,
12103 allow_clean_detachment,
12104 ) || !session_record_context_export_source_is_usable(
12105 current,
12106 &runtime.status,
12107 allow_clean_detachment,
12108 ) || runtime.history.pending.is_some()
12109 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12110 || runtime.history.loaded.as_ref() != Some(loaded)
12111 || receipt.lineage.source_node_id != self.node_id
12112 || receipt.lineage.source_session != *session
12113 || receipt.lineage.source_provider != current.provider
12114 || receipt.source_message_count != loaded.message_count
12115 {
12116 return Err(failure(
12117 NodeFailureCode::SessionRecordConflict,
12118 "managed session source changed before context catalog commit",
12119 ));
12120 }
12121 self.commit_context_pack_durably(&pack)?;
12122 self.context_catalog
12123 .write()
12124 .unwrap_or_else(|poisoned| poisoned.into_inner())
12125 .insert(pack)
12126 .map_err(|_| {
12127 failure(
12128 NodeFailureCode::ContextPackMaterializationFailed,
12129 "context pack catalog is full",
12130 )
12131 })
12132 }
12133
12134 async fn context_pack_repository(
12135 &self,
12136 workspace_id: &WorkspaceId,
12137 ) -> Result<ContextPackRepository, NodeFailure> {
12138 let first = self
12139 .context_pack_repository_observation(workspace_id)
12140 .await?;
12141 let second = self
12142 .context_pack_repository_observation(workspace_id)
12143 .await?;
12144 stable_context_pack_repository(first, second)
12145 }
12146
12147 async fn context_pack_repository_observation(
12148 &self,
12149 workspace_id: &WorkspaceId,
12150 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12151 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12152 let head = if git.is_repository {
12153 let root = self.workspace_root(workspace_id)?;
12154 match resolve_base_commit_with_timeout(
12155 &root,
12156 "HEAD",
12157 GIT_COMMAND_TIMEOUT_MS,
12158 )
12159 .await
12160 {
12161 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12162 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12163 ContextPackRepositoryHead::Unborn
12164 }
12165 Err(_) => {
12166 return Err(failure(
12167 NodeFailureCode::ContextPackMaterializationFailed,
12168 "context pack HEAD observation failed",
12169 ));
12170 }
12171 }
12172 } else {
12173 ContextPackRepositoryHead::NotRepository
12174 };
12175 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12176 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12177 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12178 failure(
12179 NodeFailureCode::InvalidRepositoryPath,
12180 "context pack selected file path is invalid",
12181 )
12182 })?;
12183 match self.read_workspace_file(workspace_id.clone(), path).await {
12184 Ok(WorkspaceFileRead {
12185 content: WorkspaceFileContent::Utf8 { text, byte_len },
12186 ..
12187 }) => files.push(ContextPackRepositoryFileSource::utf8(
12188 selected_path,
12189 text,
12190 byte_len,
12191 )),
12192 Ok(WorkspaceFileRead {
12193 content: WorkspaceFileContent::NonUtf8 { byte_len },
12194 ..
12195 }) => files.push(ContextPackRepositoryFileSource::skipped(
12196 selected_path,
12197 ContextPackSelectedFileSkipReason::NonUtf8,
12198 Some(byte_len),
12199 )),
12200 Ok(WorkspaceFileRead {
12201 content: WorkspaceFileContent::TooLarge { .. },
12202 ..
12203 }) => files.push(ContextPackRepositoryFileSource::skipped(
12204 selected_path,
12205 ContextPackSelectedFileSkipReason::TooLarge,
12206 None,
12207 )),
12208 Err(error) => {
12209 let reason = if matches!(
12210 error.code,
12211 NodeFailureCode::InvalidRepositoryPath
12212 | NodeFailureCode::RepositoryFileNotRegular
12213 | NodeFailureCode::RepositoryPathUnsafe
12214 ) {
12215 ContextPackSelectedFileSkipReason::Unsafe
12216 } else {
12217 ContextPackSelectedFileSkipReason::Unavailable
12218 };
12219 files.push(ContextPackRepositoryFileSource::skipped(
12220 selected_path,
12221 reason,
12222 None,
12223 ));
12224 }
12225 }
12226 }
12227 Ok(ContextPackRepositoryObservation { head, git, files })
12228 }
12229
12230 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12231 let referenced_by_binding = self
12232 .session_bindings
12233 .lock()
12234 .unwrap_or_else(|poisoned| poisoned.into_inner())
12235 .values()
12236 .any(|binding| {
12237 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12238 });
12239 let referenced_by_record = self
12240 .session_records
12241 .lock()
12242 .unwrap_or_else(|poisoned| poisoned.into_inner())
12243 .records
12244 .values()
12245 .any(|record| record.context_id.as_ref() == Some(context_id));
12246 let referenced_by_materialization = self
12247 .materializations
12248 .lock()
12249 .unwrap_or_else(|poisoned| poisoned.into_inner())
12250 .values()
12251 .any(|ownership| {
12252 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12253 });
12254 if referenced_by_binding
12255 || referenced_by_record
12256 || referenced_by_materialization
12257 {
12258 return Err(failure(
12259 NodeFailureCode::ContextPackBusy,
12260 "context pack is still referenced",
12261 ));
12262 }
12263 let mut catalog = self.context_catalog
12264 .write()
12265 .unwrap_or_else(|poisoned| poisoned.into_inner());
12266 if catalog.get(context_id).is_none() {
12267 return Err(failure(
12268 NodeFailureCode::UnknownContextPack,
12269 "context pack is unavailable",
12270 ));
12271 }
12272 self.spawn_idempotency
12273 .lock()
12274 .unwrap_or_else(|poisoned| poisoned.into_inner())
12275 .entries
12276 .retain(|_, entry| !entry.value.references_context(context_id));
12277 let removed = catalog.remove(context_id);
12278 debug_assert!(removed.is_some(), "validated context pack remains present");
12279 Ok(())
12280 }
12281
12282 async fn dispatch_input_bounded(
12283 &self,
12284 address: &SessionAddress,
12285 action: InputAction,
12286 ) -> Result<(), NodeFailure> {
12287 let started_after = self.current_sequence();
12288 self.pty_input_marks
12289 .lock()
12290 .unwrap_or_else(|poisoned| poisoned.into_inner())
12291 .insert(address.session.instance_id, (Instant::now(), false));
12292 self.pty_input_wake.notify_one();
12293 let command_id = self
12294 .dispatch_bounded(
12295 ControlCommand::SendInput {
12296 instance_id: address.session.instance_id,
12297 action,
12298 },
12299 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12300 )
12301 .await?;
12302 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12306 let mut scan_after = started_after;
12307 let mut accepted_after = None;
12308 loop {
12309 let events = self
12310 .history
12311 .lock()
12312 .unwrap_or_else(|poisoned| poisoned.into_inner())
12313 .events
12314 .iter()
12315 .filter(|envelope| envelope.sequence > scan_after)
12316 .cloned()
12317 .collect::<Vec<_>>();
12318 for envelope in events {
12319 scan_after = scan_after.max(envelope.sequence);
12320 let NodeEvent::Control {
12321 address: event_address,
12322 event,
12323 } = envelope.event
12324 else {
12325 continue;
12326 };
12327 if event_address != *address {
12328 continue;
12329 }
12330 if event.command_id == Some(command_id) {
12331 match &event.event {
12332 ControlEventKind::CommandRejected { message } => {
12333 return Err(failure(NodeFailureCode::InvalidRequest, message));
12334 }
12335 ControlEventKind::InputRequested { .. } => {
12336 accepted_after = Some(envelope.sequence);
12337 continue;
12338 }
12339 _ => {}
12340 }
12341 }
12342 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12343 match &event.event {
12344 ControlEventKind::InputCompleted { .. } => return Ok(()),
12345 ControlEventKind::InputFailed { message, .. } => {
12346 return Err(failure(
12347 NodeFailureCode::BackendOperationFailed,
12348 message,
12349 ));
12350 }
12351 _ => {}
12352 }
12353 }
12354 }
12355 if Instant::now() >= deadline {
12356 return Err(failure(
12357 NodeFailureCode::BackendBusy,
12358 if accepted_after.is_some() {
12359 "input was accepted but did not settle before the bounded deadline"
12360 } else {
12361 "input was not accepted before the bounded deadline"
12362 },
12363 ));
12364 }
12365 sleep(Duration::from_millis(2)).await;
12366 }
12367 }
12368
12369 async fn dispatch_resize_bounded(
12370 &self,
12371 address: &SessionAddress,
12372 size: gate4agent_types::TerminalSize,
12373 ) -> Result<(), NodeFailure> {
12374 let started_after = self.current_sequence();
12375 let command_id = self
12376 .dispatch_bounded(
12377 ControlCommand::Resize {
12378 instance_id: address.session.instance_id,
12379 size,
12380 },
12381 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12382 )
12383 .await?;
12384 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12385 let mut scan_after = started_after;
12386 let mut accepted_after = None;
12387 loop {
12388 let events = self
12389 .history
12390 .lock()
12391 .unwrap_or_else(|poisoned| poisoned.into_inner())
12392 .events
12393 .iter()
12394 .filter(|envelope| envelope.sequence > scan_after)
12395 .cloned()
12396 .collect::<Vec<_>>();
12397 for envelope in events {
12398 scan_after = scan_after.max(envelope.sequence);
12399 let NodeEvent::Control {
12400 address: event_address,
12401 event,
12402 } = envelope.event
12403 else {
12404 continue;
12405 };
12406 if event_address != *address {
12407 continue;
12408 }
12409 if event.command_id == Some(command_id) {
12410 match &event.event {
12411 ControlEventKind::CommandRejected { message } => {
12412 return Err(failure(NodeFailureCode::InvalidRequest, message));
12413 }
12414 ControlEventKind::ResizeRequested {
12415 size: requested_size,
12416 ..
12417 } if *requested_size == size => {
12418 accepted_after = Some(envelope.sequence);
12419 continue;
12420 }
12421 _ => {}
12422 }
12423 }
12424 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12425 match &event.event {
12426 ControlEventKind::Resized { size: settled_size }
12427 if *settled_size == size =>
12428 {
12429 return Ok(());
12430 }
12431 ControlEventKind::ResizeFailed { message } => {
12432 return Err(failure(
12433 NodeFailureCode::BackendOperationFailed,
12434 message,
12435 ));
12436 }
12437 _ => {}
12438 }
12439 }
12440 }
12441 if Instant::now() >= deadline {
12442 return Err(failure(
12443 NodeFailureCode::BackendBusy,
12444 if accepted_after.is_some() {
12445 "resize was accepted but did not settle before the bounded deadline"
12446 } else {
12447 "resize was not accepted before the bounded deadline"
12448 },
12449 ));
12450 }
12451 sleep(Duration::from_millis(2)).await;
12452 }
12453 }
12454
12455 async fn spawn_session(
12456 &self,
12457 workspace_id: WorkspaceId,
12458 provider: AgentId,
12459 mode: SessionMode,
12460 terminal_size: gate4agent_types::TerminalSize,
12461 initial_prompt: Option<String>,
12462 ) -> Result<SessionAddress, NodeFailure> {
12463 if self.managed_worktrees.lock()
12464 .unwrap_or_else(|poisoned| poisoned.into_inner())
12465 .lease_for_workspace(&workspace_id)
12466 .is_some()
12467 {
12468 return Err(failure(
12469 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12470 "managed worktree targets may only be spawned through their lease request",
12471 ));
12472 }
12473 self
12474 .spawn_session_with_deadline(
12475 workspace_id,
12476 provider,
12477 mode,
12478 terminal_size,
12479 initial_prompt,
12480 None,
12481 None,
12482 None,
12483 None,
12484 None,
12485 SpawnRecordPolicy::ProviderIdentityOnly,
12486 None,
12487 &[],
12488 None,
12489 ApprovalLevel::default(),
12495 None,
12496 None,
12497 )
12498 .await
12499 .map(|(session, _runtime_policy)| session)
12500 }
12501
12502 async fn spawn_session_with_deadline(
12503 &self,
12504 workspace_id: WorkspaceId,
12505 provider: AgentId,
12506 mode: SessionMode,
12507 terminal_size: gate4agent_types::TerminalSize,
12508 initial_prompt: Option<String>,
12509 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12510 bundle: Option<ResolvedBundleReceipt>,
12511 context: Option<ResolvedContextPackReceipt>,
12512 managed_authority: Option<ManagedWorktreeLeaseId>,
12513 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12514 record_policy: SpawnRecordPolicy,
12515 deadline: Option<Instant>,
12516 required_capabilities: &[ProviderRuntimeCapability],
12517 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12518 approval_level: ApprovalLevel,
12519 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12520 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12526 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12527 let reserved_lease = self.managed_worktrees.lock()
12528 .unwrap_or_else(|poisoned| poisoned.into_inner())
12529 .lease_for_workspace(&workspace_id);
12530 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12531 (Some(expected), Some(provided)) if expected == provided => {}
12532 (None, None) => {}
12533 _ => {
12534 return Err(failure(
12535 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12536 "spawn lacks exact managed worktree lease authority",
12537 ));
12538 }
12539 }
12540 self.ensure_workspace_environment_profile_compatible(
12544 &workspace_id,
12545 environment_profile.as_ref(),
12546 )?;
12547 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12548 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12549 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12550 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12551 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12552 };
12553 let transport = match mode {
12559 SessionMode::Pty => TransportKind::Pty,
12560 SessionMode::Inline => TransportKind::Pipe,
12561 SessionMode::Acp => TransportKind::Acp,
12562 };
12563 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12564 let acp_transport = self.provider_supports_acp_transport(&provider);
12565 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12566 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12567 NodeFailureCode::UnsupportedSpawnCapability,
12568 "pre-admitted provider runtime no longer satisfies spawn requirements",
12569 ))?;
12570 runtime_policy
12571 } else if let Some(deadline) = deadline {
12572 let remaining = spawn_deadline_remaining(deadline)?;
12573 timeout(
12574 remaining,
12575 self.admit_provider_runtime(&provider, runtime_requirement),
12576 )
12577 .await
12578 .map_err(|_| {
12579 failure(
12580 NodeFailureCode::SpawnDeadlineExceeded,
12581 "provider admission exceeded the spawn deadline",
12582 )
12583 })??
12584 } else {
12585 self.admit_provider_runtime(&provider, runtime_requirement).await?
12586 };
12587 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12588 let runtime_policy =
12600 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12601 if required_capabilities
12602 .iter()
12603 .any(|capability| !runtime_policy.admits(*capability))
12604 {
12605 return Err(failure(
12606 NodeFailureCode::UnsupportedSpawnCapability,
12607 "provider runtime does not admit a required spawn capability",
12608 ));
12609 }
12610 if let Some(deadline) = deadline {
12611 spawn_deadline_remaining(deadline)?;
12612 }
12613 self.ensure_binding_capacity()?;
12614 let working_directory = self.workspace_root(&workspace_id)?;
12615 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12616 let session = SessionKey {
12617 instance_id,
12618 generation: SessionGeneration(1),
12619 };
12620 let address = SessionAddress {
12621 workspace_id: workspace_id.clone(),
12622 session,
12623 };
12624 tracing::info!(
12632 node_id = %self.node_id,
12633 workspace_id = %workspace_id,
12634 provider = %provider,
12635 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12636 session = ?address.session,
12637 "session spawn request accepted",
12638 );
12639 let mut prepared_spawn = SpawnPreparer::prepare(
12640 self,
12641 SpawnPrepareRequest {
12642 instance_id,
12643 address: &address,
12644 provider: &provider,
12645 mode,
12646 environment_profile: environment_profile.as_ref(),
12647 bundle: bundle.as_ref(),
12648 context: context.as_ref(),
12649 managed_authority: managed_authority.as_ref(),
12650 harness_mcp,
12651 approval_level,
12652 network_allowlist: network_allowlist.as_ref(),
12653 browser_profile_id,
12654 },
12655 )?;
12656 let materialization_id = prepared_spawn.materialization_id.take();
12657 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12658 let record_id = self.bind_spawn_session_with_materialization(
12659 &address,
12660 provider.clone(),
12661 mode,
12662 runtime_policy,
12663 record_policy,
12664 environment_profile,
12665 bundle,
12666 context,
12667 materialization_id,
12668 )?;
12669 prepared_spawn.retain_session_guards();
12670 let agent_id = provider;
12674 let register_command_id = match self.dispatch_bounded(
12675 ControlCommand::Register {
12676 instance_id,
12677 agent_id,
12678 transport,
12679 },
12680 dispatch_timeout,
12681 )
12682 .await {
12683 Ok(command_id) => command_id,
12684 Err(error) => {
12685 if let Some(binding) = self.remove_binding(&address) {
12686 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12687 }
12688 if let Some(record_id) = record_id.as_ref() {
12689 self.discard_record(record_id)?;
12690 }
12691 return Err(spawn_dispatch_error(error, deadline));
12692 }
12693 };
12694 let start_timeout = match spawn_dispatch_timeout(deadline) {
12695 Ok(timeout) => timeout,
12696 Err(error) => {
12697 let recovery = self
12698 .rollback_spawn(
12699 &address,
12700 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12701 )
12702 .await;
12703 return match recovery {
12704 Ok(()) => {
12705 if let Some(record_id) = record_id.as_ref() {
12706 self.discard_record(record_id)?;
12707 }
12708 Err(error)
12709 }
12710 Err(recovery_error) => {
12711 if let Some(record_id) = record_id.as_ref() {
12712 self.mark_record_error(
12713 record_id,
12714 "spawn deadline elapsed and registration recovery failed",
12715 )?;
12716 }
12717 Err(recovery_error)
12718 }
12719 };
12720 }
12721 };
12722 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12723 if let Err(recovery_error) = self
12724 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12725 .await
12726 {
12727 return Err(recovery_error);
12728 }
12729 if let Some(record_id) = record_id.as_ref() {
12730 self.discard_record(record_id)?;
12731 }
12732 return Err(start_error);
12733 }
12734 let start_result = self
12735 .dispatch_bounded(
12736 ControlCommand::Start {
12737 instance_id,
12738 runtime_policy,
12739 request: StartRequest {
12740 working_directory,
12741 terminal_size,
12742 initial_prompt,
12743 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12744 approval_level,
12745 },
12746 },
12747 start_timeout,
12748 )
12749 .await;
12750 let start_command_id = match start_result {
12751 Ok(command_id) => command_id,
12752 Err(start_error) => {
12753 let start_error = spawn_dispatch_error(start_error, deadline);
12754 let recovery = self
12755 .rollback_spawn(
12756 &address,
12757 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12758 )
12759 .await;
12760 return match recovery {
12761 Ok(()) => {
12762 if let Some(record_id) = record_id.as_ref() {
12763 self.discard_record(record_id)?;
12764 }
12765 Err(start_error)
12766 }
12767 Err(recovery_error) => {
12768 let diagnostic = format!(
12769 "start failed and registration recovery failed: {}",
12770 recovery_error.message,
12771 );
12772 if let Some(record_id) = record_id.as_ref() {
12773 self.mark_record_error(record_id, &diagnostic)?;
12774 }
12775 Err(failure(recovery_error.code, &diagnostic))
12776 }
12777 };
12778 }
12779 };
12780
12781 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12782 loop {
12783 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12792 .or_else(|| self.take_rejected_command_reason(start_command_id))
12793 {
12794 self.rollback_spawn(
12795 &address,
12796 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12797 ).await.map_err(|error| {
12798 failure(
12799 error.code,
12800 &format!(
12801 "spawn was rejected ({}) and registration recovery failed: {}",
12802 reason.message, error.message
12803 ),
12804 )
12805 })?;
12806 if let Some(record_id) = record_id.as_ref() {
12807 self.discard_record(record_id)?;
12808 }
12809 let code = if reason.unsupported_transport {
12810 NodeFailureCode::UnsupportedTransport
12811 } else {
12812 NodeFailureCode::BackendOperationFailed
12813 };
12814 return Err(failure(code, &reason.message));
12815 }
12816 if let Some(current) = self
12817 .handle
12818 .snapshot()
12819 .sessions
12820 .iter()
12821 .find(|current| current.instance_id == instance_id)
12822 {
12823 if current.generation != session.generation {
12824 let divergence = failure(
12825 NodeFailureCode::StaleGeneration,
12826 "spawn generation diverged",
12827 );
12828 return match self
12829 .rollback_spawn(
12830 &address,
12831 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12832 )
12833 .await
12834 {
12835 Ok(()) => {
12836 if let Some(record_id) = record_id.as_ref() {
12837 self.discard_record(record_id)?;
12838 }
12839 Err(divergence)
12840 }
12841 Err(recovery_error) => {
12842 if let Some(record_id) = record_id.as_ref() {
12843 self.mark_record_error(
12844 record_id,
12845 "spawn generation diverged and recovery failed",
12846 )?;
12847 }
12848 Err(recovery_error)
12849 }
12850 };
12851 }
12852 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12853 prepared_spawn.retain_launch_guards();
12854 return Ok((address, runtime_policy));
12855 }
12856 }
12857 if Instant::now() >= commit_deadline {
12858 self.rollback_spawn(
12859 &address,
12860 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12861 ).await.map_err(|error| {
12862 failure(
12863 error.code,
12864 &format!(
12865 "start did not commit and registration recovery failed: {}",
12866 error.message
12867 ),
12868 )
12869 })?;
12870 if let Some(record_id) = record_id.as_ref() {
12871 self.discard_record(record_id)?;
12872 }
12873 return Err(failure(
12874 if deadline.is_some() {
12875 NodeFailureCode::SpawnDeadlineExceeded
12876 } else {
12877 NodeFailureCode::BackendBusy
12878 },
12879 "start did not commit before the bounded deadline; registration was removed",
12880 ));
12881 }
12882 sleep(Duration::from_millis(2)).await;
12883 }
12884 }
12885
12886 fn effective_spawn_runtime_policy(
12887 &self,
12888 runtime_policy: ProviderRuntimePolicy,
12889 ) -> ProviderRuntimePolicy {
12890 #[cfg(feature = "fixture")]
12891 let runtime_policy = if self.fixture_semantic_hook_policy {
12892 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12893 .expect("monitoring Hook fixture policy is internally valid")
12894 } else {
12895 runtime_policy
12896 };
12897 runtime_policy
12898 }
12899
12900 fn verify_harness_mcp_before_start(
12901 &self,
12902 prepared: Option<&PreparedHarnessMcpSpawn>,
12903 ) -> Result<(), NodeFailure> {
12904 prepared
12905 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12906 .transpose()
12907 .map(|_| ())
12908 .map_err(harness_mcp_failure)
12909 }
12910
12911 async fn rollback_spawn(
12912 &self,
12913 address: &SessionAddress,
12914 commit_timeout: Duration,
12915 ) -> Result<(), NodeFailure> {
12916 self.dispatch_bounded(
12917 ControlCommand::Remove {
12918 instance_id: address.session.instance_id,
12919 },
12920 commit_timeout,
12921 )
12922 .await?;
12923 self.wait_until_removed(address, commit_timeout).await?;
12924 if let Some(binding) = self.remove_binding(address) {
12925 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12926 }
12927 Ok(())
12928 }
12929
12930 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12931 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12932 self.dispatch_bounded(
12933 ControlCommand::Remove {
12934 instance_id: address.session.instance_id,
12935 },
12936 commit_timeout,
12937 )
12938 .await?;
12939 self.wait_until_removed(address, commit_timeout).await?;
12940 let removed = self.remove_binding(address);
12941 if let Some(binding) = removed.as_ref() {
12942 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12943 }
12944 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12945 let snapshot = self.managed_worktrees.lock()
12946 .unwrap_or_else(|poisoned| poisoned.into_inner())
12947 .release_session(
12948 &lease_id,
12949 self.incarnation_id,
12950 address.session.instance_id,
12951 address.session.generation,
12952 unix_time_ms(),
12953 );
12954 if self.persist_state().is_err() {
12955 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12956 }
12957 if let Some(snapshot) = snapshot {
12958 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12959 }
12960 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12961 }
12962 Ok(())
12963 }
12964
12965 async fn wait_until_removed(
13011 &self,
13012 address: &SessionAddress,
13013 commit_timeout: Duration,
13014 ) -> Result<(), NodeFailure> {
13015 let deadline = Instant::now() + commit_timeout;
13016 let mut next_dispatch = Instant::now();
13017 loop {
13018 let snapshot = self.handle.snapshot();
13019 let status = snapshot
13020 .sessions
13021 .iter()
13022 .find(|session| session.instance_id == address.session.instance_id)
13023 .map(|session| session.status.clone());
13024 let Some(status) = status else {
13025 return Ok(());
13026 };
13027 let now = Instant::now();
13028 if now >= deadline {
13029 return Err(failure(
13030 NodeFailureCode::BackendBusy,
13031 "remove did not commit before the bounded deadline; session binding was retained",
13032 ));
13033 }
13034 if status.allows_remove() && now >= next_dispatch {
13035 let _ = self.dispatch(ControlCommand::Remove {
13036 instance_id: address.session.instance_id,
13037 });
13038 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13039 }
13040 sleep(Duration::from_millis(2)).await;
13041 }
13042 }
13043
13044 fn resync(&self, after_sequence: u64) -> NodeResponse {
13045 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13046 let event_sequence = history.last_sequence;
13047 let oldest_available_sequence = history.replay_floor_sequence;
13048 let events = history
13049 .events
13050 .iter()
13051 .filter(|event| event.sequence > after_sequence)
13052 .cloned()
13053 .collect();
13054 drop(history);
13055 NodeResponse::Resync {
13056 event_sequence,
13057 oldest_available_sequence,
13058 snapshot: self.snapshot(),
13059 events,
13060 }
13061 }
13062}
13063
13064fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13069 match kind {
13070 AgentStreamChunkKindV1::Text { .. } => "text",
13071 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13072 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13073 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13074 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13075 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13076 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13077 }
13078}
13079
13080async fn accept_connections(
13094 endpoint: &str,
13095 call_home: Option<std::net::SocketAddr>,
13096 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13097 shared: Arc<NodeShared>,
13098) -> Result<(), NodeServerError> {
13099 let mut connections = JoinSet::new();
13100 let result = match call_home {
13101 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13102 Some(relay) => {
13103 let listener =
13104 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13105 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13106 tokio::pin!(listener);
13107 tokio::pin!(dialler);
13108 tokio::select! {
13109 result = &mut listener => result,
13110 result = &mut dialler => result,
13111 }
13112 }
13113 };
13114 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13115 while !connections.is_empty() {
13116 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13117 if remaining.is_zero()
13118 || timeout(remaining, connections.join_next()).await.is_err()
13119 {
13120 break;
13121 }
13122 }
13123 connections.abort_all();
13124 while connections.join_next().await.is_some() {}
13125 result
13126}
13127
13128const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13132
13133async fn call_home_loop(
13147 relay: std::net::SocketAddr,
13148 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13149 shared: Arc<NodeShared>,
13150) -> Result<(), NodeServerError> {
13151 let mut failures = 0_usize;
13152 loop {
13153 if shared.shutdown.load(Ordering::Acquire) {
13154 return Ok(());
13155 }
13156 let permit = tokio::select! {
13157 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13158 permit.map_err(|_| NodeServerError::PreauthClosed)?
13159 }
13160 _ = shared.shutdown_notify.notified() => return Ok(()),
13161 };
13162 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13163 Ok(()) => {
13164 failures = 0;
13168 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
13169 }
13170 Err(error) => {
13171 failures = failures.saturating_add(1);
13172 tracing::warn!(
13173 node_id = %shared.node_id,
13174 %relay,
13175 cause = %error,
13176 attempt = failures,
13177 "call-home connection failed",
13178 );
13179 let backoff = CALL_HOME_BACKOFFS_MS
13180 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
13181 tokio::select! {
13182 _ = sleep(Duration::from_millis(backoff)) => {}
13183 _ = shared.shutdown_notify.notified() => return Ok(()),
13184 }
13185 }
13186 }
13187 }
13188}
13189
13190async fn dial_relay_and_serve(
13192 relay: std::net::SocketAddr,
13193 identity: Option<&hatchery_enroll::NodeIdentity>,
13194 shared: Arc<NodeShared>,
13195 preauth_permit: OwnedSemaphorePermit,
13196) -> Result<(), NodeServerError> {
13197 let mut stream = tokio::net::TcpStream::connect(relay)
13198 .await
13199 .map_err(NodeServerError::CallHomeTransport)?;
13200 let _ = stream.set_nodelay(true);
13204 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13205 .await
13206 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13207 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13208 let mut session_token = None;
13209 if let Some(identity) = identity {
13210 let mut magic = [0_u8; 4];
13214 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13215 .await
13216 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13217 .map_err(NodeServerError::CallHomeTransport)?;
13218 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13219 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13220 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13221 .await
13222 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13223 .map_err(NodeServerError::CallHomeTransport)?;
13224 let (answer, token) = identity
13225 .answer_challenge(shared.node_id.as_str(), &frame)
13226 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13227 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13228 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13229 session_token = Some(token);
13230 }
13231 }
13232 serve_connection(stream, shared, session_token, preauth_permit).await
13233}
13234
13235async fn accept_connections_inner(
13236 endpoint: &str,
13237 shared: Arc<NodeShared>,
13238 connections: &mut JoinSet<Result<(), NodeServerError>>,
13239) -> Result<(), NodeServerError> {
13240 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13241 loop {
13242 while connections.try_join_next().is_some() {}
13243 if shared.shutdown.load(Ordering::Acquire) {
13244 return Ok(());
13245 }
13246 let preauth_permit = tokio::select! {
13247 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13248 permit.map_err(|_| NodeServerError::PreauthClosed)?
13249 }
13250 _ = shared.shutdown_notify.notified() => return Ok(()),
13251 };
13252 let server = tokio::select! {
13253 result = listener.accept() => result?,
13254 _ = shared.shutdown_notify.notified() => return Ok(()),
13255 };
13256 let connection_shared = Arc::clone(&shared);
13257 connections.spawn(async move {
13258 serve_connection(server, connection_shared, None, preauth_permit).await
13259 });
13260 if shared.shutdown.load(Ordering::Acquire) {
13261 return Ok(());
13262 }
13263 }
13264}
13265
13266async fn serve_connection<S>(
13274 mut pipe: S,
13275 shared: Arc<NodeShared>,
13276 session_token: Option<String>,
13277 preauth_permit: OwnedSemaphorePermit,
13278) -> Result<(), NodeServerError>
13279where
13280 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13281{
13282 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13284 let hello_frame = timeout(
13285 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13286 read_json_frame_limited_body_timeout(
13287 &mut pipe,
13288 MAX_NODE_HELLO_FRAME_BYTES,
13289 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13290 ),
13291 )
13292 .await
13293 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13294 let ClientFrame::Hello(hello) = hello_frame else {
13295 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13296 };
13297 if hello.build_stamp != BUILD_STAMP {
13298 return Err(NodeServerError::Handshake(format!(
13299 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13300 hello.build_stamp,
13301 )));
13302 }
13303 let compatibility = match hello.compatibility.as_ref() {
13304 Some(offer) => {
13305 let mut selected = node_compatibility_support(&shared)?
13306 .negotiate(offer)
13307 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13308 project_negotiated_provider_ids(&mut selected);
13309 Some(selected)
13310 }
13311 None => None,
13312 };
13313 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13314 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13315 (Some(offer), Some(selected)) => negotiated_auth_proof(
13316 auth_token,
13317 AuthDirection::Server,
13318 hello.role,
13319 &hello.client_nonce,
13320 &server_nonce,
13321 offer,
13322 selected,
13323 ),
13324 (None, None) => auth_proof(
13325 auth_token,
13326 AuthDirection::Server,
13327 hello.role,
13328 &hello.client_nonce,
13329 &server_nonce,
13330 ),
13331 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13332 }
13333 .map_err(NodeServerError::Authentication)?;
13334 write_json_frame_limited(
13335 &mut pipe,
13336 &ServerFrame::Challenge(ServerChallenge {
13337 build_stamp: BUILD_STAMP.to_owned(),
13338 server_nonce,
13339 server_proof,
13340 compatibility: compatibility.clone(),
13341 }),
13342 MAX_NODE_HELLO_FRAME_BYTES,
13343 )
13344 .await?;
13345 let authentication = timeout(
13346 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13347 read_json_frame_limited_body_timeout(
13348 &mut pipe,
13349 MAX_NODE_HELLO_FRAME_BYTES,
13350 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13351 ),
13352 )
13353 .await
13354 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13355 let ClientFrame::Authenticate(authentication) = authentication else {
13356 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13357 };
13358 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13359 (Some(offer), Some(selected)) => negotiated_auth_proof(
13360 auth_token,
13361 AuthDirection::Client,
13362 hello.role,
13363 &hello.client_nonce,
13364 &server_nonce,
13365 offer,
13366 selected,
13367 ),
13368 (None, None) => auth_proof(
13369 auth_token,
13370 AuthDirection::Client,
13371 hello.role,
13372 &hello.client_nonce,
13373 &server_nonce,
13374 ),
13375 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13376 }
13377 .map_err(NodeServerError::Authentication)?;
13378 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13379 return Err(NodeServerError::Handshake("access denied".to_owned()));
13380 }
13381 let selected_capabilities = compatibility
13382 .as_ref()
13383 .map(|selected| selected.capabilities.clone())
13384 .unwrap_or_default();
13385 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13386 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13387 });
13388 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13389 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13390 });
13391 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13392 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13393 });
13394 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13395 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13396 });
13397 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13398 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13399 });
13400 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13401 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13402 }) && selected_capabilities.iter().any(|capability| {
13403 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13404 });
13405 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13406 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13407 });
13408 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13409 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13410 });
13411 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13412 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13413 });
13414 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13415 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13416 });
13417 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13418 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13419 });
13420 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13421 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13422 });
13423 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13424 .try_acquire_owned()
13425 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13426 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13427 let _release_guard = ControllerReleaseGuard {
13428 shared: Arc::clone(&shared),
13429 connection_id,
13430 };
13431 drop(preauth_permit);
13432 let _authenticated_permit = authenticated_permit;
13433 let mut event_rx = shared.event_tx.subscribe();
13434 let mut terminal_event_rx = terminal_event_subscription(
13435 &shared,
13436 include_terminal_frame_events,
13437 );
13438 let mut agent_stream_event_rx = agent_stream_event_subscription(
13439 &shared,
13440 include_agent_stream_events,
13441 );
13442 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13443 &shared,
13444 include_harness_mcp_proxy,
13445 );
13446 write_json_frame(
13447 &mut pipe,
13448 &ServerFrame::Hello(NodeHello {
13449 build_stamp: BUILD_STAMP.to_owned(),
13450 incarnation_id: shared.incarnation_id,
13451 connection_id,
13452 role: hello.role,
13453 event_sequence: shared.current_sequence(),
13454 controller: shared.controller_state(),
13455 snapshot: snapshot_for_wire(
13456 &shared,
13457 include_provider_runtime_status,
13458 include_open_provider_ids,
13459 include_spawn_profiles,
13460 include_managed_worktrees,
13461 include_child_environment_profiles,
13462 include_session_bundles,
13463 include_history_context_packs,
13464 include_agent_progress,
13465 include_session_task_correlation,
13466 ),
13467 compatibility,
13468 }),
13469 )
13470 .await?;
13471 let (mut reader, mut writer) = tokio::io::split(pipe);
13472 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13473 let reader_task = tokio::spawn(async move {
13474 loop {
13475 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13476 &mut reader,
13477 MAX_NODE_CLIENT_FRAME_BYTES,
13478 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13479 )
13480 .await;
13481 let terminal = frame.is_err();
13482 if frame_tx.send(frame).await.is_err() || terminal {
13483 break;
13484 }
13485 }
13486 });
13487 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13488 let mut pending_events = Vec::new();
13489 let mut resync_required = false;
13490 let mut discard_events_through = 0_u64;
13491 loop {
13492 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13497 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13498 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13499 for _ in 0..durable_drain_budget {
13500 match event_rx.try_recv() {
13501 Ok(event) => queue_connection_event(
13502 &mut pending_events,
13503 event,
13504 discard_events_through,
13505 ),
13506 Err(broadcast::error::TryRecvError::Empty) => break,
13507 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13508 resync_required = true;
13509 break;
13510 }
13511 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13512 }
13513 }
13514 if let Some(receiver) = terminal_event_rx.as_mut() {
13515 match receiver.try_recv() {
13516 Ok(events) => queue_connection_event_batch(
13517 &mut pending_events,
13518 &events,
13519 discard_events_through,
13520 ),
13521 Err(broadcast::error::TryRecvError::Empty) => {}
13522 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13523 resync_required = true;
13524 }
13525 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13526 }
13527 }
13528 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13529 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13539 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13540 for _ in 0..agent_stream_drain_budget {
13541 match receiver.try_recv() {
13542 Ok(events) => queue_connection_event_batch(
13543 &mut pending_events,
13544 &events,
13545 discard_events_through,
13546 ),
13547 Err(broadcast::error::TryRecvError::Empty) => break,
13548 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13549 tracing::warn!(
13550 connection_id,
13551 dropped,
13552 "agent stream event subscription lagged; forcing a resync",
13553 );
13554 resync_required = true;
13555 break;
13556 }
13557 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13558 }
13559 }
13560 }
13561 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13562 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13568 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13569 for _ in 0..harness_mcp_proxy_drain_budget {
13570 match receiver.try_recv() {
13571 Ok(events) => queue_connection_event_batch(
13572 &mut pending_events,
13573 &events,
13574 discard_events_through,
13575 ),
13576 Err(broadcast::error::TryRecvError::Empty) => break,
13577 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13578 tracing::warn!(
13579 connection_id,
13580 dropped,
13581 "harness MCP proxy event subscription lagged; forcing a resync",
13582 );
13583 resync_required = true;
13584 break;
13585 }
13586 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13587 }
13588 }
13589 }
13590 if resync_required {
13591 let event = resync_required_event(&shared);
13592 apply_connection_resync_watermark(
13593 &mut pending_events,
13594 &mut discard_events_through,
13595 event.sequence,
13596 );
13597 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13598 resync_required = false;
13599 continue;
13600 }
13601 if !pending_events.is_empty() {
13602 pending_events.sort_unstable_by_key(|event| event.sequence);
13603 let burst_len = connection_event_burst_len(pending_events.len());
13604 shared
13605 .connection_events_sent
13606 .fetch_add(burst_len as u64, Ordering::Relaxed);
13607 for event in pending_events.drain(..burst_len) {
13608 let event = if include_managed_worktrees {
13609 Some(event)
13610 } else {
13611 project_event_without_managed_worktrees(event)
13612 };
13613 let event = event.and_then(|event| if include_open_provider_ids {
13614 Some(event)
13615 } else {
13616 project_event_legacy_provider_ids(&shared, event)
13617 });
13618 let event = event.map(|event| if include_child_environment_profiles {
13619 event
13620 } else {
13621 project_event_without_child_environment_profile(event)
13622 });
13623 let event = event.map(|event| if include_session_bundles {
13624 event
13625 } else {
13626 project_event_without_session_bundle(event)
13627 });
13628 let event = event.map(|event| {
13629 project_event_history_for_wire(event, include_history_context_packs)
13630 });
13631 let event = event.map(|event| if include_history_context_packs {
13632 event
13633 } else {
13634 project_event_without_context_pack(event)
13635 });
13636 let event = event.map(|event| if include_session_task_correlation {
13637 event
13638 } else {
13639 project_event_without_session_task_binding(event)
13640 });
13641 let exact_harness_controller = hello.role == ClientRole::Operator
13642 && shared.controller_state().is_some_and(|controller| {
13643 controller.connection_id == connection_id
13644 });
13645 let event = event.and_then(|event| {
13646 if include_harness_mcp_proxy && exact_harness_controller {
13647 Some(event)
13648 } else {
13649 project_event_without_harness_mcp_proxy(event)
13650 }
13651 });
13652 if let Some(event) = event {
13653 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13654 }
13655 }
13656 }
13657 tokio::select! {
13658 biased;
13659 frame = frame_rx.recv() => {
13660 let frame = match frame {
13661 Some(frame) => frame,
13662 None => break,
13663 };
13664 let frame = match frame {
13665 Ok(frame) => frame,
13666 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13667 Err(error) => return Err(error.into()),
13668 };
13669 let ClientFrame::Request(request) = frame else {
13670 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13671 };
13672 #[cfg(feature = "fixture")]
13673 let probe_spawn_managed_worktree_v2 = matches!(
13674 &request.request,
13675 NodeRequest::SpawnManagedWorktreeV2 { .. }
13676 );
13677 let requires_open_provider_ids =
13678 request_requires_open_provider_ids(&shared, &request.request);
13679 let requires_child_environment_profile =
13680 request_requires_child_environment_profile(&shared, &request.request);
13681 let requires_session_bundle =
13682 request_requires_session_bundle(&shared, &request.request);
13683 let requires_history_context_pack =
13684 request_requires_history_context_pack(&shared, &request.request);
13685 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13686 ResponseEnvelope {
13687 request_id: request.request_id,
13688 result: Err(failure(
13689 NodeFailureCode::UnsupportedCapability,
13690 "open provider IDs were not negotiated",
13691 )),
13692 }
13693 } else if requires_child_environment_profile
13694 && !include_child_environment_profiles
13695 {
13696 ResponseEnvelope {
13697 request_id: request.request_id,
13698 result: Err(failure(
13699 NodeFailureCode::UnsupportedCapability,
13700 "child environment profiles were not negotiated",
13701 )),
13702 }
13703 } else if requires_session_bundle && !include_session_bundles {
13704 ResponseEnvelope {
13705 request_id: request.request_id,
13706 result: Err(failure(
13707 NodeFailureCode::UnsupportedCapability,
13708 "session bundles were not negotiated",
13709 )),
13710 }
13711 } else if requires_history_context_pack && !include_history_context_packs {
13712 ResponseEnvelope {
13713 request_id: request.request_id,
13714 result: Err(failure(
13715 NodeFailureCode::UnsupportedCapability,
13716 "history context packs were not negotiated",
13717 )),
13718 }
13719 } else if request_uses_unnegotiated_capability(
13720 &request.request,
13721 &selected_capabilities,
13722 )
13723 {
13724 ResponseEnvelope {
13725 request_id: request.request_id,
13726 result: Err(failure(
13727 NodeFailureCode::UnsupportedCapability,
13728 "request capability was not negotiated",
13729 )),
13730 }
13731 } else {
13732 process_request(&shared, connection_id, hello.role, request).await
13733 };
13734 #[cfg(feature = "fixture")]
13735 if probe_spawn_managed_worktree_v2 {
13736 shared
13737 .fixture_spawn_managed_worktree_v2_failure_probe
13738 .record(reply.result.as_ref().err().map(|failure| failure.code));
13739 }
13740 if !include_provider_runtime_status {
13741 clear_response_provider_runtime_status(&mut reply);
13742 }
13743 if !include_managed_worktrees {
13744 project_response_without_managed_worktrees(&mut reply);
13745 }
13746 if !include_open_provider_ids {
13747 project_response_legacy_provider_ids(&shared, &mut reply);
13748 }
13749 if !include_child_environment_profiles {
13750 project_response_without_child_environment_profile(&mut reply);
13751 }
13752 if !include_session_bundles {
13753 project_response_without_session_bundle(&mut reply);
13754 }
13755 project_response_history_for_wire(
13756 &mut reply,
13757 include_history_context_packs,
13758 );
13759 if !include_history_context_packs {
13760 project_response_without_context_pack(&mut reply);
13761 }
13762 if !include_agent_progress {
13763 project_response_without_agent_progress(&mut reply);
13764 }
13765 if !include_session_task_correlation {
13766 project_response_without_session_task_binding(&mut reply);
13767 }
13768 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13769 }
13770 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13771 event = event_rx.recv() => {
13772 match event {
13773 Ok(event) => queue_connection_event(
13774 &mut pending_events,
13775 event,
13776 discard_events_through,
13777 ),
13778 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13779 Err(broadcast::error::RecvError::Closed) => break,
13780 }
13781 }
13782 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13783 match events {
13784 Ok(events) => queue_connection_event_batch(
13785 &mut pending_events,
13786 &events,
13787 discard_events_through,
13788 ),
13789 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13790 Err(broadcast::error::RecvError::Closed) => break,
13791 }
13792 }
13793 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13794 match events {
13795 Ok(events) => queue_connection_event_batch(
13796 &mut pending_events,
13797 &events,
13798 discard_events_through,
13799 ),
13800 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13801 Err(broadcast::error::RecvError::Closed) => break,
13802 }
13803 }
13804 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13805 match events {
13806 Ok(events) => queue_connection_event_batch(
13807 &mut pending_events,
13808 &events,
13809 discard_events_through,
13810 ),
13811 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13812 Err(broadcast::error::RecvError::Closed) => break,
13813 }
13814 }
13815 _ = shared.shutdown_notify.notified() => break,
13816 }
13817 }
13818 Ok(())
13819}
13820
13821async fn receive_terminal_event_batch(
13822 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13823) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13824 match receiver {
13825 Some(receiver) => receiver.recv().await,
13826 None => std::future::pending().await,
13827 }
13828}
13829
13830fn terminal_event_subscription(
13831 shared: &NodeShared,
13832 enabled: bool,
13833) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13834 enabled.then(|| shared.terminal_event_tx.subscribe())
13835}
13836
13837fn agent_stream_event_subscription(
13841 shared: &NodeShared,
13842 enabled: bool,
13843) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13844 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13845}
13846
13847fn harness_mcp_proxy_event_subscription(
13852 shared: &NodeShared,
13853 enabled: bool,
13854) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13855 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13856}
13857
13858fn queue_connection_event(
13859 pending: &mut Vec<NodeEventEnvelope>,
13860 event: NodeEventEnvelope,
13861 discard_through: u64,
13862) {
13863 if event.sequence <= discard_through
13864 && !event.event.requires_harness_mcp_proxy_capability()
13865 {
13866 return;
13867 }
13868 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13869 if let Some(index) = pending.iter().position(|current| {
13870 matches!(
13871 ¤t.event,
13872 NodeEvent::TerminalFrame {
13873 address: current_address,
13874 ..
13875 } if current_address == address
13876 )
13877 }) {
13878 pending.remove(index);
13879 }
13880 }
13881 pending.push(event);
13882}
13883
13884fn project_event_without_harness_mcp_proxy(
13885 envelope: NodeEventEnvelope,
13886) -> Option<NodeEventEnvelope> {
13887 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13888}
13889
13890fn queue_connection_event_batch(
13891 pending: &mut Vec<NodeEventEnvelope>,
13892 events: &[NodeEventEnvelope],
13893 discard_through: u64,
13894) {
13895 for event in events {
13896 queue_connection_event(pending, event.clone(), discard_through);
13897 }
13898}
13899
13900fn apply_connection_resync_watermark(
13901 pending: &mut Vec<NodeEventEnvelope>,
13902 discard_through: &mut u64,
13903 marker_sequence: u64,
13904) {
13905 *discard_through = (*discard_through).max(marker_sequence);
13906 pending.retain(|event| event.sequence > *discard_through);
13907}
13908
13909fn connection_event_burst_len(pending_len: usize) -> usize {
13910 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13911}
13912
13913fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13914 let history = shared
13915 .history
13916 .lock()
13917 .unwrap_or_else(|poisoned| poisoned.into_inner());
13918 let sequence = history.last_sequence;
13919 let oldest_available_sequence = history.replay_floor_sequence;
13920 NodeEventEnvelope {
13921 sequence,
13922 event: NodeEvent::ResyncRequired {
13923 oldest_available_sequence,
13924 },
13925 }
13926}
13927
13928fn node_compatibility_support(
13929 shared: &NodeShared,
13930) -> Result<NodeCompatibilitySupport, NodeServerError> {
13931 let mut support = node_compatibility_support_for_manifest(
13932 &shared.provider_contracts,
13933 &shared.provider_adapter_contracts,
13934 )?;
13935 if shared
13936 .delivery_store
13937 .lock()
13938 .unwrap_or_else(|poisoned| poisoned.into_inner())
13939 .is_some()
13940 {
13941 support.capabilities.push(
13942 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13943 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13944 );
13945 }
13946 if shared.harness_mcp_registry.is_some() {
13947 support.capabilities.push(
13948 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13949 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13950 );
13951 }
13952 Ok(support)
13953}
13954
13955fn node_compatibility_support_for_manifest(
13956 provider_contracts: &[ProviderContractSupport],
13957 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13958) -> Result<NodeCompatibilitySupport, NodeServerError> {
13959 Ok(NodeCompatibilitySupport {
13960 build_stamp: BUILD_STAMP.to_owned(),
13961 capabilities: baseline_capabilities()?,
13962 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13963 path_semantics: platform::path_semantics(),
13964 local_transport: platform::local_transport(),
13965 state_schema: StateSchemaSupport {
13966 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13967 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13968 },
13969 provider_contracts: provider_contracts.to_vec(),
13970 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13971 })
13972}
13973
13974fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13975 let capabilities = [
13976 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13977 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13978 NODE_REPOSITORY_PATH_CAPABILITY,
13979 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13980 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13981 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13982 NODE_GIT_READ_CAPABILITY,
13983 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13984 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13985 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13986 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13987 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13988 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13989 NODE_ACP_CONTROL_CAPABILITY,
13990 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13991 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13992 NODE_WORKTREE_SELECTION_CAPABILITY,
13993 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13994 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13995 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13996 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13997 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13998 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13999 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14000 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14001 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14002 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14003 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14004 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14005 ]
14006 .into_iter()
14007 .map(|capability| {
14008 CapabilityId::new(capability)
14009 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14010 })
14011 .collect::<Result<Vec<_>, _>>()?;
14012 #[cfg(windows)]
14013 let capabilities = {
14014 let mut capabilities = capabilities;
14015 capabilities.push(
14016 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14017 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14018 );
14019 capabilities
14020 };
14021 Ok(capabilities)
14022}
14023
14024fn request_uses_unnegotiated_capability(
14025 request: &NodeRequest,
14026 selected_capabilities: &[CapabilityId],
14027) -> bool {
14028 let selected = |required: &str| {
14029 selected_capabilities
14030 .iter()
14031 .any(|capability| capability.as_str() == required)
14032 };
14033 request
14034 .required_capability()
14035 .is_some_and(|required| !selected(required))
14036 || (request.requires_worktree_selection_capability()
14037 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14038 || (request.requires_spawn_spec_defaults_overrides_capability()
14039 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14040 || (request.requires_spawn_profile_revision_capability()
14041 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14042 || (request.requires_child_environment_profile_capability()
14043 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14044 || (request.requires_session_bundle_materialization_capability()
14045 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14046 || (request.requires_history_context_pack_capability()
14047 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14048}
14049
14050fn snapshot_for_wire(
14051 shared: &NodeShared,
14052 include_provider_runtime_status: bool,
14053 include_open_provider_ids: bool,
14054 include_spawn_profiles: bool,
14055 include_managed_worktrees: bool,
14056 include_child_environment_profiles: bool,
14057 include_session_bundles: bool,
14058 include_history_context_packs: bool,
14059 include_agent_progress: bool,
14060 include_session_task_correlation: bool,
14061) -> NodeSnapshot {
14062 let mut snapshot = shared.snapshot();
14063 if !include_provider_runtime_status {
14064 snapshot.provider_runtime_statuses.clear();
14065 }
14066 if !include_open_provider_ids {
14067 project_snapshot_legacy_provider_ids(&mut snapshot);
14068 }
14069 if !include_managed_worktrees {
14070 snapshot.managed_worktrees.clear();
14071 for workspace in &mut snapshot.workspaces {
14072 workspace.managed_worktree_profiles = None;
14073 }
14074 }
14075 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14076 if !include_spawn_profiles {
14077 inventory.spawn_profiles = None;
14078 }
14079 if !include_session_bundles {
14080 inventory.bundles = None;
14081 }
14082 if inventory.spawn_profiles.is_none()
14083 && inventory.bundles.is_none()
14084 && inventory.network_allowlists.is_none()
14085 {
14086 snapshot.launch_inventory = None;
14087 }
14088 }
14089 if !include_child_environment_profiles {
14090 clear_snapshot_child_environment_profiles(&mut snapshot);
14091 }
14092 if !include_session_bundles {
14093 clear_snapshot_session_bundles(&mut snapshot);
14094 }
14095 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14096 if !include_history_context_packs {
14097 clear_snapshot_context_packs(&mut snapshot);
14098 }
14099 if !include_agent_progress {
14100 snapshot.agent_progress.clear();
14101 }
14102 if !include_session_task_correlation {
14103 clear_snapshot_session_task_bindings(&mut snapshot);
14104 }
14105 snapshot
14106}
14107
14108fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14109 for record in &mut snapshot.session_records {
14110 record.task_binding = None;
14111 }
14112}
14113
14114fn project_event_without_session_task_binding(
14115 mut envelope: NodeEventEnvelope,
14116) -> NodeEventEnvelope {
14117 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14118 record.task_binding = None;
14119 }
14120 envelope
14121}
14122
14123fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14124 let Ok(response) = reply.result.as_mut() else { return };
14125 match response {
14126 NodeResponse::Snapshot { snapshot, .. } => {
14127 clear_snapshot_session_task_bindings(snapshot);
14128 }
14129 NodeResponse::Resync { snapshot, events, .. } => {
14130 clear_snapshot_session_task_bindings(snapshot);
14131 for event in events {
14132 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14133 record.task_binding = None;
14134 }
14135 }
14136 }
14137 NodeResponse::SessionRecordUpdated { record }
14138 | NodeResponse::ProviderSessionIndexed { record }
14139 | NodeResponse::NativeSessionIndexed { record, .. }
14140 | NodeResponse::SessionRecordResumed { record, .. } => {
14141 record.task_binding = None;
14142 }
14143 _ => {}
14144 }
14145}
14146
14147fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14148 let Ok(response) = reply.result.as_mut() else { return };
14149 match response {
14150 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14151 snapshot.agent_progress.clear();
14152 }
14153 _ => {}
14154 }
14155}
14156
14157fn project_event_without_managed_worktrees(
14158 mut envelope: NodeEventEnvelope,
14159) -> Option<NodeEventEnvelope> {
14160 if matches!(
14161 envelope.event,
14162 NodeEvent::ManagedWorktreeUpserted { .. }
14163 | NodeEvent::ManagedWorktreeRemoved { .. }
14164 ) {
14165 return None;
14166 }
14167 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14168 workspace.managed_worktree_profiles = None;
14169 }
14170 Some(envelope)
14171}
14172
14173fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14174 if matches!(
14175 reply.result,
14176 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14177 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14178 ) {
14179 reply.result = Err(failure(
14180 NodeFailureCode::UnsupportedCapability,
14181 "managed worktree capability was not negotiated",
14182 ));
14183 return;
14184 }
14185 let Ok(response) = reply.result.as_mut() else { return };
14186 match response {
14187 NodeResponse::Snapshot { snapshot, .. } => {
14188 snapshot.managed_worktrees.clear();
14189 for workspace in &mut snapshot.workspaces {
14190 workspace.managed_worktree_profiles = None;
14191 }
14192 }
14193 NodeResponse::Resync { snapshot, events, .. } => {
14194 snapshot.managed_worktrees.clear();
14195 for workspace in &mut snapshot.workspaces {
14196 workspace.managed_worktree_profiles = None;
14197 }
14198 *events = std::mem::take(events)
14199 .into_iter()
14200 .filter_map(project_event_without_managed_worktrees)
14201 .collect();
14202 }
14203 NodeResponse::WorkspaceRegistered { workspace }
14204 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14205 | NodeResponse::WorktreeCreated { workspace, .. } => {
14206 workspace.managed_worktree_profiles = None;
14207 }
14208 NodeResponse::WorkspaceInspected { inspection } => {
14209 inspection.git.managed_worktree = None;
14210 }
14211 _ => {}
14212 }
14213}
14214
14215fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14216 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14217 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14218 });
14219 if includes_open_provider_ids {
14220 return;
14221 }
14222 compatibility
14223 .provider_contracts
14224 .retain(|contract| provider_id_is_legacy(&contract.provider));
14225 compatibility
14226 .provider_adapter_contracts
14227 .retain(|contract| provider_id_is_legacy(&contract.provider));
14228}
14229
14230fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14231 if let Some(spec) = match request {
14232 NodeRequest::SpawnSpec { spec }
14233 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14234 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14235 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14236 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14237 _ => None,
14238 } {
14239 return shared
14240 .resolve_spawn_spec(spec)
14241 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14242 }
14243 request_requires_open_provider_ids_with(
14244 request,
14245 |session| shared.validate_address(session).ok(),
14246 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14247 )
14248}
14249
14250fn request_requires_child_environment_profile(
14251 shared: &NodeShared,
14252 request: &NodeRequest,
14253) -> bool {
14254 if let Some(spec) = match request {
14255 NodeRequest::SpawnSpec { spec }
14256 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14257 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14258 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14259 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14260 _ => None,
14261 } {
14262 return shared
14263 .resolve_spawn_spec(spec)
14264 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14265 }
14266 match request {
14267 NodeRequest::Resume { session, .. }
14268 | NodeRequest::Prompt { session, .. }
14269 | NodeRequest::Paste { session, .. }
14270 | NodeRequest::Input { session, .. }
14271 | NodeRequest::TerminalBytes { session, .. }
14272 | NodeRequest::TerminalControl { session, .. }
14273 | NodeRequest::Resize { session, .. }
14274 | NodeRequest::Interrupt { session }
14275 | NodeRequest::Stop { session, .. }
14276 | NodeRequest::Remove { session }
14277 | NodeRequest::ResolveInteraction { session, .. }
14278 | NodeRequest::SetSessionMode { session, .. }
14279 | NodeRequest::SetSessionConfigOption { session, .. }
14280 | NodeRequest::SetSessionModel { session, .. }
14281 | NodeRequest::DiscoverHistory { session, .. }
14282 | NodeRequest::LoadHistory { session, .. }
14283 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14284 | NodeRequest::ExportContextPack { session } => shared
14285 .session_bindings
14286 .lock()
14287 .unwrap_or_else(|poisoned| poisoned.into_inner())
14288 .get(&session.session.instance_id)
14289 .filter(|binding| {
14290 binding.workspace_id == session.workspace_id
14291 && binding.generation == session.session.generation
14292 })
14293 .map_or(true, |binding| binding.environment_profile.is_some()),
14294 NodeRequest::RenameSessionRecord { record_id, .. }
14295 | NodeRequest::SetSessionTask { record_id, .. }
14296 | NodeRequest::ResumeSessionRecord { record_id, .. }
14297 | NodeRequest::ForgetSessionRecord { record_id } => shared
14298 .record(record_id)
14299 .map_or(true, |record| record.environment_profile.is_some()),
14300 NodeRequest::ArmHarnessMcpReservation { .. }
14301 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14302 | NodeRequest::ActivateHarnessMcpReservation { .. }
14303 | NodeRequest::AbortHarnessMcpReservation { .. }
14304 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14305 | NodeRequest::RejectHarnessMcpCall { .. }
14306 | NodeRequest::Snapshot
14307 | NodeRequest::Resync { .. }
14308 | NodeRequest::BeginDeliveryStage { .. }
14309 | NodeRequest::PutDeliveryBlobChunk { .. }
14310 | NodeRequest::CommitDeliveryStage { .. }
14311 | NodeRequest::AbortDeliveryStage { .. }
14312 | NodeRequest::BrowseHostDirectories { .. }
14313 | NodeRequest::InspectWorkspace { .. }
14314 | NodeRequest::ReadWorkspaceFile { .. }
14315 | NodeRequest::WriteWorkspaceFile { .. }
14316 | NodeRequest::CreateWorkspaceFile { .. }
14317 | NodeRequest::CreateWorkspaceDirectory { .. }
14318 | NodeRequest::ReadGitHistory { .. }
14319 | NodeRequest::ReadGitDiff { .. }
14320 | NodeRequest::AcquireController { .. }
14321 | NodeRequest::ReleaseController
14322 | NodeRequest::Spawn { .. }
14323 | NodeRequest::SpawnWithOptions { .. }
14324 | NodeRequest::SpawnSpec { .. }
14325 | NodeRequest::RegisterWorkspace { .. }
14326 | NodeRequest::CreateStandaloneWorkspace { .. }
14327 | NodeRequest::UnregisterWorkspace { .. }
14328 | NodeRequest::CreateWorktree { .. }
14329 | NodeRequest::RemoveWorktree { .. }
14330 | NodeRequest::SpawnManagedWorktree { .. }
14331 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14332 | NodeRequest::CleanupManagedWorktree { .. }
14333 | NodeRequest::IndexProviderSession { .. }
14334 | NodeRequest::IndexNativeSession { .. }
14335 | NodeRequest::CatalogNativeSessions { .. }
14336 | NodeRequest::PageNativeSessions { .. }
14337 | NodeRequest::PreviewNativeSession { .. }
14338 | NodeRequest::PreviewSessionRecord { .. }
14339 | NodeRequest::ForgetContextPack { .. }
14340 | NodeRequest::ResolveDurableContextPack { .. }
14341 | NodeRequest::ReadContextPack { .. }
14342 | NodeRequest::Shutdown => false,
14343 }
14344}
14345
14346fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14347 if let Some(spec) = match request {
14348 NodeRequest::SpawnSpec { spec }
14349 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14350 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14351 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14352 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14353 _ => None,
14354 } {
14355 return shared
14356 .resolve_spawn_spec(spec)
14357 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14358 }
14359 match request {
14360 NodeRequest::Resume { session, .. }
14361 | NodeRequest::Prompt { session, .. }
14362 | NodeRequest::Paste { session, .. }
14363 | NodeRequest::Input { session, .. }
14364 | NodeRequest::TerminalBytes { session, .. }
14365 | NodeRequest::TerminalControl { session, .. }
14366 | NodeRequest::Resize { session, .. }
14367 | NodeRequest::Interrupt { session }
14368 | NodeRequest::Stop { session, .. }
14369 | NodeRequest::Remove { session }
14370 | NodeRequest::ResolveInteraction { session, .. }
14371 | NodeRequest::SetSessionMode { session, .. }
14372 | NodeRequest::SetSessionConfigOption { session, .. }
14373 | NodeRequest::SetSessionModel { session, .. }
14374 | NodeRequest::DiscoverHistory { session, .. }
14375 | NodeRequest::LoadHistory { session, .. }
14376 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14377 | NodeRequest::ExportContextPack { session } => shared
14378 .session_bindings
14379 .lock()
14380 .unwrap_or_else(|poisoned| poisoned.into_inner())
14381 .get(&session.session.instance_id)
14382 .filter(|binding| {
14383 binding.workspace_id == session.workspace_id
14384 && binding.generation == session.session.generation
14385 })
14386 .map_or(true, |binding| binding.bundle.is_some()),
14387 NodeRequest::RenameSessionRecord { record_id, .. }
14388 | NodeRequest::SetSessionTask { record_id, .. }
14389 | NodeRequest::ResumeSessionRecord { record_id, .. }
14390 | NodeRequest::ForgetSessionRecord { record_id } => shared
14391 .record(record_id)
14392 .map_or(true, |record| record.bundle.is_some()),
14393 NodeRequest::ArmHarnessMcpReservation { .. }
14394 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14395 | NodeRequest::ActivateHarnessMcpReservation { .. }
14396 | NodeRequest::AbortHarnessMcpReservation { .. }
14397 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14398 | NodeRequest::RejectHarnessMcpCall { .. }
14399 | NodeRequest::Snapshot
14400 | NodeRequest::Resync { .. }
14401 | NodeRequest::BeginDeliveryStage { .. }
14402 | NodeRequest::PutDeliveryBlobChunk { .. }
14403 | NodeRequest::CommitDeliveryStage { .. }
14404 | NodeRequest::AbortDeliveryStage { .. }
14405 | NodeRequest::BrowseHostDirectories { .. }
14406 | NodeRequest::InspectWorkspace { .. }
14407 | NodeRequest::ReadWorkspaceFile { .. }
14408 | NodeRequest::WriteWorkspaceFile { .. }
14409 | NodeRequest::CreateWorkspaceFile { .. }
14410 | NodeRequest::CreateWorkspaceDirectory { .. }
14411 | NodeRequest::ReadGitHistory { .. }
14412 | NodeRequest::ReadGitDiff { .. }
14413 | NodeRequest::AcquireController { .. }
14414 | NodeRequest::ReleaseController
14415 | NodeRequest::Spawn { .. }
14416 | NodeRequest::SpawnWithOptions { .. }
14417 | NodeRequest::SpawnSpec { .. }
14418 | NodeRequest::RegisterWorkspace { .. }
14419 | NodeRequest::CreateStandaloneWorkspace { .. }
14420 | NodeRequest::UnregisterWorkspace { .. }
14421 | NodeRequest::CreateWorktree { .. }
14422 | NodeRequest::RemoveWorktree { .. }
14423 | NodeRequest::SpawnManagedWorktree { .. }
14424 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14425 | NodeRequest::CleanupManagedWorktree { .. }
14426 | NodeRequest::IndexProviderSession { .. }
14427 | NodeRequest::IndexNativeSession { .. }
14428 | NodeRequest::CatalogNativeSessions { .. }
14429 | NodeRequest::PageNativeSessions { .. }
14430 | NodeRequest::PreviewNativeSession { .. }
14431 | NodeRequest::PreviewSessionRecord { .. }
14432 | NodeRequest::ForgetContextPack { .. }
14433 | NodeRequest::ResolveDurableContextPack { .. }
14434 | NodeRequest::ReadContextPack { .. }
14435 | NodeRequest::Shutdown => false,
14436 }
14437}
14438
14439fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14440 if request.requires_history_context_pack_capability() {
14441 return true;
14442 }
14443 if let Some(spec) = match request {
14444 NodeRequest::SpawnSpec { spec }
14445 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14446 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14447 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14448 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14449 _ => None,
14450 } {
14451 return shared
14452 .resolve_spawn_spec(spec)
14453 .map_or(true, |resolved| resolved.context_id.is_some());
14454 }
14455 match request {
14456 NodeRequest::Resume { session, .. }
14457 | NodeRequest::Prompt { session, .. }
14458 | NodeRequest::Paste { session, .. }
14459 | NodeRequest::Input { session, .. }
14460 | NodeRequest::TerminalBytes { session, .. }
14461 | NodeRequest::TerminalControl { session, .. }
14462 | NodeRequest::Resize { session, .. }
14463 | NodeRequest::Interrupt { session }
14464 | NodeRequest::Stop { session, .. }
14465 | NodeRequest::Remove { session }
14466 | NodeRequest::ResolveInteraction { session, .. }
14467 | NodeRequest::SetSessionMode { session, .. }
14468 | NodeRequest::SetSessionConfigOption { session, .. }
14469 | NodeRequest::SetSessionModel { session, .. } => shared
14470 .session_bindings
14471 .lock()
14472 .unwrap_or_else(|poisoned| poisoned.into_inner())
14473 .get(&session.session.instance_id)
14474 .filter(|binding| {
14475 binding.workspace_id == session.workspace_id
14476 && binding.generation == session.session.generation
14477 })
14478 .map_or(true, |binding| binding.context.is_some()),
14479 NodeRequest::RenameSessionRecord { record_id, .. }
14480 | NodeRequest::SetSessionTask { record_id, .. }
14481 | NodeRequest::ResumeSessionRecord { record_id, .. }
14482 | NodeRequest::ForgetSessionRecord { record_id } => shared
14483 .record(record_id)
14484 .map_or(true, |record| record.context.is_some()),
14485 NodeRequest::ArmHarnessMcpReservation { .. }
14486 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14487 | NodeRequest::ActivateHarnessMcpReservation { .. }
14488 | NodeRequest::AbortHarnessMcpReservation { .. }
14489 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14490 | NodeRequest::RejectHarnessMcpCall { .. }
14491 | NodeRequest::Snapshot
14492 | NodeRequest::Resync { .. }
14493 | NodeRequest::BeginDeliveryStage { .. }
14494 | NodeRequest::PutDeliveryBlobChunk { .. }
14495 | NodeRequest::CommitDeliveryStage { .. }
14496 | NodeRequest::AbortDeliveryStage { .. }
14497 | NodeRequest::BrowseHostDirectories { .. }
14498 | NodeRequest::InspectWorkspace { .. }
14499 | NodeRequest::ReadWorkspaceFile { .. }
14500 | NodeRequest::WriteWorkspaceFile { .. }
14501 | NodeRequest::CreateWorkspaceFile { .. }
14502 | NodeRequest::CreateWorkspaceDirectory { .. }
14503 | NodeRequest::ReadGitHistory { .. }
14504 | NodeRequest::ReadGitDiff { .. }
14505 | NodeRequest::AcquireController { .. }
14506 | NodeRequest::ReleaseController
14507 | NodeRequest::Spawn { .. }
14508 | NodeRequest::SpawnWithOptions { .. }
14509 | NodeRequest::SpawnSpec { .. }
14510 | NodeRequest::RegisterWorkspace { .. }
14511 | NodeRequest::CreateStandaloneWorkspace { .. }
14512 | NodeRequest::UnregisterWorkspace { .. }
14513 | NodeRequest::CreateWorktree { .. }
14514 | NodeRequest::RemoveWorktree { .. }
14515 | NodeRequest::SpawnManagedWorktree { .. }
14516 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14517 | NodeRequest::CleanupManagedWorktree { .. }
14518 | NodeRequest::IndexProviderSession { .. }
14519 | NodeRequest::IndexNativeSession { .. }
14520 | NodeRequest::CatalogNativeSessions { .. }
14521 | NodeRequest::PageNativeSessions { .. }
14522 | NodeRequest::PreviewNativeSession { .. }
14523 | NodeRequest::PreviewSessionRecord { .. }
14524 | NodeRequest::DiscoverHistory { .. }
14525 | NodeRequest::LoadHistory { .. }
14526 | NodeRequest::ExportContextPackForSessionRecord { .. }
14527 | NodeRequest::ExportContextPack { .. }
14528 | NodeRequest::ForgetContextPack { .. }
14529 | NodeRequest::ResolveDurableContextPack { .. }
14530 | NodeRequest::ReadContextPack { .. }
14531 | NodeRequest::Shutdown => false,
14532 }
14533}
14534
14535fn request_requires_open_provider_ids_with(
14536 request: &NodeRequest,
14537 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14538 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14539) -> bool {
14540 match request {
14541 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14542 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14543 NodeRequest::CatalogNativeSessions { route, .. }
14544 | NodeRequest::PageNativeSessions { route, .. } => {
14545 !provider_id_is_legacy(&route.provider)
14546 }
14547 NodeRequest::PreviewNativeSession { selection, .. }
14548 | NodeRequest::IndexNativeSession { selection, .. } => {
14549 !provider_id_is_legacy(&selection.route.provider)
14550 }
14551 NodeRequest::SpawnSpec { .. }
14552 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14553 NodeRequest::SpawnManagedWorktree { .. }
14554 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14555 NodeRequest::Resume { session, .. }
14556 | NodeRequest::Prompt { session, .. }
14557 | NodeRequest::Paste { session, .. }
14558 | NodeRequest::Input { session, .. }
14559 | NodeRequest::TerminalBytes { session, .. }
14560 | NodeRequest::TerminalControl { session, .. }
14561 | NodeRequest::Resize { session, .. }
14562 | NodeRequest::Interrupt { session }
14563 | NodeRequest::Stop { session, .. }
14564 | NodeRequest::Remove { session }
14565 | NodeRequest::ResolveInteraction { session, .. }
14566 | NodeRequest::SetSessionMode { session, .. }
14567 | NodeRequest::SetSessionConfigOption { session, .. }
14568 | NodeRequest::SetSessionModel { session, .. }
14569 | NodeRequest::DiscoverHistory { session, .. }
14570 | NodeRequest::LoadHistory { session, .. }
14571 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14572 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14573 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14574 NodeRequest::RenameSessionRecord { record_id, .. }
14575 | NodeRequest::SetSessionTask { record_id, .. }
14576 | NodeRequest::ResumeSessionRecord { record_id, .. }
14577 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14578 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14579 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14580 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14581 NodeRequest::ArmHarnessMcpReservation { .. }
14582 | NodeRequest::ActivateHarnessMcpReservation { .. }
14583 | NodeRequest::AbortHarnessMcpReservation { .. }
14584 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14585 | NodeRequest::RejectHarnessMcpCall { .. }
14586 | NodeRequest::Snapshot
14587 | NodeRequest::Resync { .. }
14588 | NodeRequest::BeginDeliveryStage { .. }
14589 | NodeRequest::PutDeliveryBlobChunk { .. }
14590 | NodeRequest::CommitDeliveryStage { .. }
14591 | NodeRequest::AbortDeliveryStage { .. }
14592 | NodeRequest::BrowseHostDirectories { .. }
14593 | NodeRequest::InspectWorkspace { .. }
14594 | NodeRequest::ReadWorkspaceFile { .. }
14595 | NodeRequest::WriteWorkspaceFile { .. }
14596 | NodeRequest::CreateWorkspaceFile { .. }
14597 | NodeRequest::CreateWorkspaceDirectory { .. }
14598 | NodeRequest::ReadGitHistory { .. }
14599 | NodeRequest::ReadGitDiff { .. }
14600 | NodeRequest::AcquireController { .. }
14601 | NodeRequest::ReleaseController
14602 | NodeRequest::RegisterWorkspace { .. }
14603 | NodeRequest::CreateStandaloneWorkspace { .. }
14604 | NodeRequest::UnregisterWorkspace { .. }
14605 | NodeRequest::CreateWorktree { .. }
14606 | NodeRequest::RemoveWorktree { .. }
14607 | NodeRequest::CleanupManagedWorktree { .. }
14608 | NodeRequest::Shutdown => false,
14609 NodeRequest::ForgetContextPack { .. } => true,
14610 NodeRequest::ResolveDurableContextPack { .. }
14616 | NodeRequest::ReadContextPack { .. } => true,
14617 }
14618}
14619
14620fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14621 snapshot.enabled_providers.retain(provider_id_is_legacy);
14622 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14623 snapshot
14624 .provider_runtime_statuses
14625 .as_slice()
14626 .iter()
14627 .filter(|status| provider_id_is_legacy(status.provider()))
14628 .cloned(),
14629 )
14630 .expect("a filtered provider runtime projection remains valid");
14631 snapshot
14632 .session_records
14633 .retain(|record| provider_id_is_legacy(&record.provider));
14634 for workspace in &mut snapshot.workspaces {
14635 workspace
14636 .sessions
14637 .retain(|session| provider_id_is_legacy(&session.agent_id));
14638 }
14639}
14640
14641fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14642 for record in &mut snapshot.session_records {
14643 record.environment_profile = None;
14644 }
14645 if let Some(profiles) = snapshot
14646 .launch_inventory
14647 .as_mut()
14648 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14649 {
14650 for profile in profiles {
14651 profile.environment_profile = None;
14652 }
14653 }
14654}
14655
14656fn project_event_without_child_environment_profile(
14657 mut envelope: NodeEventEnvelope,
14658) -> NodeEventEnvelope {
14659 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14660 record.environment_profile = None;
14661 }
14662 envelope
14663}
14664
14665fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14666 let contains_environment_profile = match reply.result.as_ref() {
14667 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14668 receipt.environment_profile.is_some()
14669 }
14670 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14671 receipt.spawn.environment_profile.is_some()
14672 }
14673 _ => false,
14674 };
14675 if contains_environment_profile {
14676 reply.result = Err(failure(
14677 NodeFailureCode::UnsupportedCapability,
14678 "child environment profiles were not negotiated",
14679 ));
14680 return;
14681 }
14682 let Ok(response) = reply.result.as_mut() else {
14683 return;
14684 };
14685 match response {
14686 NodeResponse::Snapshot { snapshot, .. } => {
14687 clear_snapshot_child_environment_profiles(snapshot);
14688 }
14689 NodeResponse::Resync { snapshot, events, .. } => {
14690 clear_snapshot_child_environment_profiles(snapshot);
14691 for event in events {
14692 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14693 record.environment_profile = None;
14694 }
14695 }
14696 }
14697 NodeResponse::SessionRecordUpdated { record }
14698 | NodeResponse::ProviderSessionIndexed { record }
14699 | NodeResponse::NativeSessionIndexed { record, .. }
14700 | NodeResponse::SessionRecordResumed { record, .. } => {
14701 record.environment_profile = None;
14702 }
14703 NodeResponse::Armed { .. }
14704 | NodeResponse::Spawned { .. }
14705 | NodeResponse::Activated { .. }
14706 | NodeResponse::Aborted { .. }
14707 | NodeResponse::ReplyChunkAccepted { .. }
14708 | NodeResponse::CallRejected { .. }
14709 | NodeResponse::DeliveryStageBegun { .. }
14710 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14711 | NodeResponse::DeliveryCommitted { .. }
14712 | NodeResponse::DeliveryStageAborted { .. }
14713 | NodeResponse::WorkspaceInspected { .. }
14714 | NodeResponse::HostDirectoriesBrowsed { .. }
14715 | NodeResponse::WorkspaceFileRead { .. }
14716 | NodeResponse::WorkspaceFileWritten { .. }
14717 | NodeResponse::WorkspaceFileCreated { .. }
14718 | NodeResponse::WorkspaceDirectoryCreated { .. }
14719 | NodeResponse::GitHistoryRead { .. }
14720 | NodeResponse::GitDiffRead { .. }
14721 | NodeResponse::Controller { .. }
14722 | NodeResponse::SpawnAccepted { .. }
14723 | NodeResponse::SpawnSpecAccepted { .. }
14724 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14725 | NodeResponse::ManagedWorktreeCleanup { .. }
14726 | NodeResponse::NativeSessionsCataloged { .. }
14727 | NodeResponse::NativeSessionsPaged { .. }
14728 | NodeResponse::NativeSessionPreviewed { .. }
14729 | NodeResponse::SessionRecordPreviewed { .. }
14730 | NodeResponse::HistoryDiscovered { .. }
14731 | NodeResponse::HistoryLoaded { .. }
14732 | NodeResponse::ContextPackForSessionRecordExported { .. }
14733 | NodeResponse::ContextPackExported { .. }
14734 | NodeResponse::ContextPackForgotten { .. }
14735 | NodeResponse::DurableContextPackResolved { .. }
14736 | NodeResponse::ContextPackBytesRead { .. }
14737 | NodeResponse::SessionRecordForgotten { .. }
14738 | NodeResponse::WorkspaceRegistered { .. }
14739 | NodeResponse::StandaloneWorkspaceCreated { .. }
14740 | NodeResponse::WorkspaceUnregistered { .. }
14741 | NodeResponse::WorktreeCreated { .. }
14742 | NodeResponse::WorktreeRemoved { .. }
14743 | NodeResponse::Accepted
14744 | NodeResponse::ShuttingDown => {}
14745 }
14746}
14747
14748fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14749 for record in &mut snapshot.session_records {
14750 record.bundle = None;
14751 }
14752}
14753
14754fn project_event_without_session_bundle(
14755 mut envelope: NodeEventEnvelope,
14756) -> NodeEventEnvelope {
14757 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14758 record.bundle = None;
14759 }
14760 envelope
14761}
14762
14763fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14764 let contains_bundle = match reply.result.as_ref() {
14765 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14766 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14767 receipt.spawn.bundle.is_some()
14768 }
14769 _ => false,
14770 };
14771 if contains_bundle {
14772 reply.result = Err(failure(
14773 NodeFailureCode::UnsupportedCapability,
14774 "session bundles were not negotiated",
14775 ));
14776 return;
14777 }
14778 let Ok(response) = reply.result.as_mut() else {
14779 return;
14780 };
14781 match response {
14782 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14783 NodeResponse::Resync { snapshot, events, .. } => {
14784 clear_snapshot_session_bundles(snapshot);
14785 for event in events {
14786 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14787 record.bundle = None;
14788 }
14789 }
14790 }
14791 NodeResponse::SessionRecordUpdated { record }
14792 | NodeResponse::ProviderSessionIndexed { record }
14793 | NodeResponse::NativeSessionIndexed { record, .. }
14794 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14795 NodeResponse::Armed { .. }
14796 | NodeResponse::Spawned { .. }
14797 | NodeResponse::Activated { .. }
14798 | NodeResponse::Aborted { .. }
14799 | NodeResponse::ReplyChunkAccepted { .. }
14800 | NodeResponse::CallRejected { .. }
14801 | NodeResponse::DeliveryStageBegun { .. }
14802 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14803 | NodeResponse::DeliveryCommitted { .. }
14804 | NodeResponse::DeliveryStageAborted { .. }
14805 | NodeResponse::WorkspaceInspected { .. }
14806 | NodeResponse::HostDirectoriesBrowsed { .. }
14807 | NodeResponse::WorkspaceFileRead { .. }
14808 | NodeResponse::WorkspaceFileWritten { .. }
14809 | NodeResponse::WorkspaceFileCreated { .. }
14810 | NodeResponse::WorkspaceDirectoryCreated { .. }
14811 | NodeResponse::GitHistoryRead { .. }
14812 | NodeResponse::GitDiffRead { .. }
14813 | NodeResponse::Controller { .. }
14814 | NodeResponse::SpawnAccepted { .. }
14815 | NodeResponse::SpawnSpecAccepted { .. }
14816 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14817 | NodeResponse::ManagedWorktreeCleanup { .. }
14818 | NodeResponse::NativeSessionsCataloged { .. }
14819 | NodeResponse::NativeSessionsPaged { .. }
14820 | NodeResponse::NativeSessionPreviewed { .. }
14821 | NodeResponse::SessionRecordPreviewed { .. }
14822 | NodeResponse::HistoryDiscovered { .. }
14823 | NodeResponse::HistoryLoaded { .. }
14824 | NodeResponse::ContextPackForSessionRecordExported { .. }
14825 | NodeResponse::ContextPackExported { .. }
14826 | NodeResponse::ContextPackForgotten { .. }
14827 | NodeResponse::DurableContextPackResolved { .. }
14828 | NodeResponse::ContextPackBytesRead { .. }
14829 | NodeResponse::SessionRecordForgotten { .. }
14830 | NodeResponse::WorkspaceRegistered { .. }
14831 | NodeResponse::StandaloneWorkspaceCreated { .. }
14832 | NodeResponse::WorkspaceUnregistered { .. }
14833 | NodeResponse::WorktreeCreated { .. }
14834 | NodeResponse::WorktreeRemoved { .. }
14835 | NodeResponse::Accepted
14836 | NodeResponse::ShuttingDown => {}
14837 }
14838}
14839
14840fn project_snapshot_history_for_wire(
14841 snapshot: &mut NodeSnapshot,
14842 include_history_metadata: bool,
14843) {
14844 for workspace in &mut snapshot.workspaces {
14845 for session in &mut workspace.sessions {
14846 if include_history_metadata {
14847 session.history.loaded = None;
14848 } else {
14849 session.history = Default::default();
14850 }
14851 }
14852 }
14853}
14854
14855fn project_event_history_for_wire(
14856 mut envelope: NodeEventEnvelope,
14857 include_history_metadata: bool,
14858) -> NodeEventEnvelope {
14859 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14860 for session in &mut workspace.sessions {
14861 if include_history_metadata {
14862 session.history.loaded = None;
14863 } else {
14864 session.history = Default::default();
14865 }
14866 }
14867 }
14868 envelope
14869}
14870
14871fn project_response_history_for_wire(
14872 reply: &mut ResponseEnvelope,
14873 include_history_metadata: bool,
14874) {
14875 let Ok(response) = reply.result.as_mut() else {
14876 return;
14877 };
14878 match response {
14879 NodeResponse::Snapshot { snapshot, .. } => {
14880 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14881 }
14882 NodeResponse::Resync {
14883 snapshot, events, ..
14884 } => {
14885 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14886 for event in events {
14887 *event = project_event_history_for_wire(
14888 event.clone(),
14889 include_history_metadata,
14890 );
14891 }
14892 }
14893 NodeResponse::WorkspaceRegistered { workspace }
14894 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14895 | NodeResponse::WorktreeCreated { workspace, .. } => {
14896 for session in &mut workspace.sessions {
14897 if include_history_metadata {
14898 session.history.loaded = None;
14899 } else {
14900 session.history = Default::default();
14901 }
14902 }
14903 }
14904 _ => {}
14905 }
14906}
14907
14908fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14909 for record in &mut snapshot.session_records {
14910 record.context_id = None;
14911 record.context = None;
14912 record.exported_context = None;
14913 }
14914}
14915
14916fn project_event_without_context_pack(
14917 mut envelope: NodeEventEnvelope,
14918) -> NodeEventEnvelope {
14919 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14920 record.context_id = None;
14921 record.context = None;
14922 record.exported_context = None;
14923 }
14924 envelope
14925}
14926
14927fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14928 let contains_context = match reply.result.as_ref() {
14929 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14930 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14931 receipt.spawn.context.is_some()
14932 }
14933 Ok(NodeResponse::HistoryDiscovered { .. })
14934 | Ok(NodeResponse::HistoryLoaded { .. })
14935 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14936 | Ok(NodeResponse::ContextPackExported { .. })
14937 | Ok(NodeResponse::ContextPackForgotten { .. })
14938 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14939 _ => false,
14940 };
14941 if contains_context {
14942 reply.result = Err(failure(
14943 NodeFailureCode::UnsupportedCapability,
14944 "history context packs were not negotiated",
14945 ));
14946 return;
14947 }
14948 let Ok(response) = reply.result.as_mut() else {
14949 return;
14950 };
14951 match response {
14952 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14953 NodeResponse::Resync {
14954 snapshot, events, ..
14955 } => {
14956 clear_snapshot_context_packs(snapshot);
14957 for event in events {
14958 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14959 record.context_id = None;
14960 record.context = None;
14961 record.exported_context = None;
14962 }
14963 }
14964 }
14965 NodeResponse::SessionRecordUpdated { record }
14966 | NodeResponse::ProviderSessionIndexed { record }
14967 | NodeResponse::NativeSessionIndexed { record, .. }
14968 | NodeResponse::SessionRecordResumed { record, .. } => {
14969 record.context_id = None;
14970 record.context = None;
14971 record.exported_context = None;
14972 }
14973 _ => {}
14974 }
14975}
14976
14977fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14978 let contains_open_record = match reply.result.as_ref() {
14979 Ok(NodeResponse::SessionRecordUpdated { record })
14980 | Ok(NodeResponse::ProviderSessionIndexed { record })
14981 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14982 !provider_id_is_legacy(&record.provider)
14983 }
14984 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14985 !provider_id_is_legacy(&selection.route.provider)
14986 || !provider_id_is_legacy(&record.provider)
14987 }
14988 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14989 !provider_id_is_legacy(&receipt.provider)
14990 }
14991 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14992 !provider_id_is_legacy(&receipt.spawn.provider)
14993 }
14994 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14995 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14996 !provider_id_is_legacy(&route.provider)
14997 }
14998 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14999 !provider_id_is_legacy(&selection.route.provider)
15000 }
15001 _ => false,
15002 };
15003 if contains_open_record {
15004 reply.result = Err(failure(
15005 NodeFailureCode::UnsupportedCapability,
15006 "open provider IDs were not negotiated",
15007 ));
15008 return;
15009 }
15010 let Ok(response) = reply.result.as_mut() else {
15011 return;
15012 };
15013 match response {
15014 NodeResponse::Snapshot { snapshot, .. } => {
15015 project_snapshot_legacy_provider_ids(snapshot);
15016 }
15017 NodeResponse::Resync { snapshot, events, .. } => {
15018 project_snapshot_legacy_provider_ids(snapshot);
15019 *events = std::mem::take(events)
15020 .into_iter()
15021 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15022 .collect();
15023 }
15024 NodeResponse::WorkspaceRegistered { workspace }
15025 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15026 | NodeResponse::WorktreeCreated { workspace, .. } => {
15027 workspace
15028 .sessions
15029 .retain(|session| provider_id_is_legacy(&session.agent_id));
15030 }
15031 NodeResponse::Armed { .. }
15032 | NodeResponse::Spawned { .. }
15033 | NodeResponse::Activated { .. }
15034 | NodeResponse::Aborted { .. }
15035 | NodeResponse::ReplyChunkAccepted { .. }
15036 | NodeResponse::CallRejected { .. }
15037 | NodeResponse::DeliveryStageBegun { .. }
15038 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15039 | NodeResponse::DeliveryCommitted { .. }
15040 | NodeResponse::DeliveryStageAborted { .. }
15041 | NodeResponse::WorkspaceInspected { .. }
15042 | NodeResponse::HostDirectoriesBrowsed { .. }
15043 | NodeResponse::WorkspaceFileRead { .. }
15044 | NodeResponse::WorkspaceFileWritten { .. }
15045 | NodeResponse::WorkspaceFileCreated { .. }
15046 | NodeResponse::WorkspaceDirectoryCreated { .. }
15047 | NodeResponse::GitHistoryRead { .. }
15048 | NodeResponse::GitDiffRead { .. }
15049 | NodeResponse::Controller { .. }
15050 | NodeResponse::SpawnAccepted { .. }
15051 | NodeResponse::SpawnSpecAccepted { .. }
15052 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15053 | NodeResponse::ManagedWorktreeCleanup { .. }
15054 | NodeResponse::NativeSessionsCataloged { .. }
15055 | NodeResponse::NativeSessionsPaged { .. }
15056 | NodeResponse::NativeSessionPreviewed { .. }
15057 | NodeResponse::SessionRecordPreviewed { .. }
15058 | NodeResponse::HistoryDiscovered { .. }
15059 | NodeResponse::HistoryLoaded { .. }
15060 | NodeResponse::ContextPackForSessionRecordExported { .. }
15061 | NodeResponse::ContextPackExported { .. }
15062 | NodeResponse::ContextPackForgotten { .. }
15063 | NodeResponse::DurableContextPackResolved { .. }
15064 | NodeResponse::ContextPackBytesRead { .. }
15065 | NodeResponse::SessionRecordUpdated { .. }
15066 | NodeResponse::ProviderSessionIndexed { .. }
15067 | NodeResponse::NativeSessionIndexed { .. }
15068 | NodeResponse::SessionRecordResumed { .. }
15069 | NodeResponse::SessionRecordForgotten { .. }
15070 | NodeResponse::WorkspaceUnregistered { .. }
15071 | NodeResponse::WorktreeRemoved { .. }
15072 | NodeResponse::Accepted
15073 | NodeResponse::ShuttingDown => {}
15074 }
15075}
15076
15077fn project_event_legacy_provider_ids(
15078 shared: &NodeShared,
15079 mut envelope: NodeEventEnvelope,
15080) -> Option<NodeEventEnvelope> {
15081 let include = match &mut envelope.event {
15082 NodeEvent::HarnessMcpReadCall { .. } => true,
15083 NodeEvent::Control { address, .. }
15084 | NodeEvent::AgentStream { address, .. }
15085 | NodeEvent::TerminalFrame { address, .. } => shared
15086 .validate_address(address)
15087 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15088 NodeEvent::WorkspaceAdded { workspace } => {
15089 workspace
15090 .sessions
15091 .retain(|session| provider_id_is_legacy(&session.agent_id));
15092 true
15093 }
15094 NodeEvent::SessionRecordUpserted { record } => {
15095 provider_id_is_legacy(&record.provider)
15096 }
15097 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15098 NodeEvent::SessionRecordRemoved { .. } => shared
15099 .history
15100 .lock()
15101 .unwrap_or_else(|poisoned| poisoned.into_inner())
15102 .removed_record_providers
15103 .get(&envelope.sequence)
15104 .is_some_and(provider_id_is_legacy),
15105 NodeEvent::ControllerChanged { .. }
15106 | NodeEvent::ManagedWorktreeUpserted { .. }
15107 | NodeEvent::ManagedWorktreeRemoved { .. }
15108 | NodeEvent::WorkspaceRemoved { .. }
15109 | NodeEvent::ResyncRequired { .. } => true,
15110 };
15111 include.then_some(envelope)
15112}
15113
15114fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15115 let Ok(response) = reply.result.as_mut() else {
15116 return;
15117 };
15118 match response {
15119 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15120 snapshot.provider_runtime_statuses.clear();
15121 }
15122 NodeResponse::Armed { .. }
15123 | NodeResponse::Spawned { .. }
15124 | NodeResponse::Activated { .. }
15125 | NodeResponse::Aborted { .. }
15126 | NodeResponse::ReplyChunkAccepted { .. }
15127 | NodeResponse::CallRejected { .. }
15128 | NodeResponse::DeliveryStageBegun { .. }
15129 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15130 | NodeResponse::DeliveryCommitted { .. }
15131 | NodeResponse::DeliveryStageAborted { .. }
15132 | NodeResponse::WorkspaceInspected { .. }
15133 | NodeResponse::HostDirectoriesBrowsed { .. }
15134 | NodeResponse::WorkspaceFileRead { .. }
15135 | NodeResponse::WorkspaceFileWritten { .. }
15136 | NodeResponse::WorkspaceFileCreated { .. }
15137 | NodeResponse::WorkspaceDirectoryCreated { .. }
15138 | NodeResponse::GitHistoryRead { .. }
15139 | NodeResponse::GitDiffRead { .. }
15140 | NodeResponse::Controller { .. }
15141 | NodeResponse::SpawnAccepted { .. }
15142 | NodeResponse::SpawnSpecAccepted { .. }
15143 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15144 | NodeResponse::ManagedWorktreeCleanup { .. }
15145 | NodeResponse::NativeSessionsCataloged { .. }
15146 | NodeResponse::NativeSessionsPaged { .. }
15147 | NodeResponse::NativeSessionPreviewed { .. }
15148 | NodeResponse::SessionRecordPreviewed { .. }
15149 | NodeResponse::HistoryDiscovered { .. }
15150 | NodeResponse::HistoryLoaded { .. }
15151 | NodeResponse::ContextPackForSessionRecordExported { .. }
15152 | NodeResponse::ContextPackExported { .. }
15153 | NodeResponse::ContextPackForgotten { .. }
15154 | NodeResponse::DurableContextPackResolved { .. }
15155 | NodeResponse::ContextPackBytesRead { .. }
15156 | NodeResponse::SessionRecordUpdated { .. }
15157 | NodeResponse::ProviderSessionIndexed { .. }
15158 | NodeResponse::NativeSessionIndexed { .. }
15159 | NodeResponse::SessionRecordResumed { .. }
15160 | NodeResponse::SessionRecordForgotten { .. }
15161 | NodeResponse::WorkspaceRegistered { .. }
15162 | NodeResponse::StandaloneWorkspaceCreated { .. }
15163 | NodeResponse::WorkspaceUnregistered { .. }
15164 | NodeResponse::WorktreeCreated { .. }
15165 | NodeResponse::WorktreeRemoved { .. }
15166 | NodeResponse::Accepted
15167 | NodeResponse::ShuttingDown => {}
15168 }
15169}
15170
15171struct ControllerReleaseGuard {
15172 shared: Arc<NodeShared>,
15173 connection_id: u64,
15174}
15175
15176impl Drop for ControllerReleaseGuard {
15177 fn drop(&mut self) {
15178 self.shared.release_controller(self.connection_id);
15179 }
15180}
15181
15182struct AbortTaskOnDrop(AbortHandle);
15183
15184impl Drop for AbortTaskOnDrop {
15185 fn drop(&mut self) {
15186 self.0.abort();
15187 }
15188}
15189
15190fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15195 std::env::var(name)
15196 .ok()
15197 .and_then(|value| value.parse::<u64>().ok())
15198 .filter(|value| *value > 0)
15199 .unwrap_or(default)
15200 .min(max)
15201}
15202
15203fn workspace_inspection_time_budget_ms() -> u64 {
15204 env_config_bounded(
15205 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15206 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15207 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15208 )
15209}
15210
15211fn workspace_inspection_entry_cap() -> usize {
15212 env_config_bounded(
15213 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15214 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15215 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15216 ) as usize
15217}
15218
15219struct WorkspaceWalkBudget {
15225 deadline: Instant,
15226 entry_cap: usize,
15227 entries_visited: u64,
15228 time_budget_exceeded: bool,
15229 entry_cap_exceeded: bool,
15230}
15231
15232impl WorkspaceWalkBudget {
15233 fn new(deadline: Instant, entry_cap: usize) -> Self {
15234 Self {
15235 deadline,
15236 entry_cap,
15237 entries_visited: 0,
15238 time_budget_exceeded: false,
15239 entry_cap_exceeded: false,
15240 }
15241 }
15242
15243 fn is_exhausted(&self) -> bool {
15244 self.time_budget_exceeded || self.entry_cap_exceeded
15245 }
15246
15247 fn record_visit(&mut self) -> bool {
15250 self.entries_visited += 1;
15251 if self.entries_visited as usize >= self.entry_cap {
15252 self.entry_cap_exceeded = true;
15253 }
15254 if Instant::now() >= self.deadline {
15255 self.time_budget_exceeded = true;
15256 }
15257 self.is_exhausted()
15258 }
15259}
15260
15261fn collect_workspace_entries(
15262 root: &Path,
15263 deadline: Instant,
15264 entry_cap: usize,
15265) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15266 let mut entries = Vec::new();
15267 let mut truncated = false;
15268 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15269 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15270 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15274 (entries, truncated, budget)
15275}
15276
15277fn workspace_inspection_truncation(
15281 walk_budget: &WorkspaceWalkBudget,
15282 git_time_budget_exceeded: bool,
15283 elapsed: Duration,
15284) -> Option<WorkspaceInspectionTruncationV1> {
15285 if !walk_budget.time_budget_exceeded
15286 && !walk_budget.entry_cap_exceeded
15287 && !git_time_budget_exceeded
15288 {
15289 return None;
15290 }
15291 Some(WorkspaceInspectionTruncationV1 {
15292 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15293 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15294 git_time_budget_exceeded,
15295 entries_visited: walk_budget.entries_visited,
15296 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15297 })
15298}
15299
15300fn walk_workspace_directory(
15301 directory: &Path,
15302 relative_directory: Option<&RepositoryPath>,
15303 depth: usize,
15304 entries: &mut Vec<WorkspaceEntry>,
15305 truncated: &mut bool,
15306 budget: &mut WorkspaceWalkBudget,
15307) {
15308 if budget.is_exhausted() {
15309 *truncated = true;
15310 return;
15311 }
15312 let read_dir = match std::fs::read_dir(directory) {
15313 Ok(read_dir) => read_dir,
15314 Err(_) => {
15315 *truncated = true;
15316 return;
15317 }
15318 };
15319 let mut children = Vec::new();
15320 for child in read_dir {
15321 match child {
15322 Ok(child) => children.push(child),
15323 Err(_) => *truncated = true,
15324 }
15325 }
15326 children.sort_by_key(|child| child.file_name());
15327 for child in children {
15328 if budget.record_visit() {
15329 *truncated = true;
15330 return;
15331 }
15332 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15333 *truncated = true;
15334 return;
15335 }
15336 let file_type = match child.file_type() {
15337 Ok(file_type) => file_type,
15338 Err(_) => {
15339 *truncated = true;
15340 continue;
15341 }
15342 };
15343 if file_type.is_symlink() {
15344 continue;
15345 }
15346 let name = match child.file_name().into_string() {
15347 Ok(name) => name,
15348 Err(_) => {
15349 *truncated = true;
15350 continue;
15351 }
15352 };
15353 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15354 continue;
15355 }
15356 let relative_path = match relative_directory {
15357 None => windows_repository_path(name),
15358 Some(parent) => parent
15359 .as_utf8()
15360 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15361 };
15362 let Some(relative_path) = relative_path else {
15363 *truncated = true;
15364 continue;
15365 };
15366 let kind = if file_type.is_dir() {
15367 WorkspaceEntryKind::Directory
15368 } else if file_type.is_file() {
15369 WorkspaceEntryKind::File
15370 } else {
15371 continue;
15372 };
15373 entries.push(WorkspaceEntry {
15374 relative_path: relative_path.clone(),
15375 kind,
15376 });
15377 if file_type.is_dir() {
15378 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15379 walk_workspace_directory(
15380 &child.path(),
15381 Some(&relative_path),
15382 depth + 1,
15383 entries,
15384 truncated,
15385 budget,
15386 );
15387 } else {
15388 *truncated = true;
15389 }
15390 }
15391 }
15392}
15393
15394fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15395 if value.contains('\\') {
15396 return None;
15397 }
15398 RepositoryPath::utf8(value).ok()
15399}
15400
15401fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15402 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15403}
15404
15405fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15414 if name.eq_ignore_ascii_case(".git")
15415 || name.eq_ignore_ascii_case("target")
15416 || name.eq_ignore_ascii_case("node_modules")
15417 {
15418 return true;
15419 }
15420 path.join("CACHEDIR.TAG").is_file()
15421}
15422
15423fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15424 matches!(
15425 status,
15426 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15427 )
15428}
15429
15430#[derive(Clone, Debug, Eq, PartialEq)]
15431enum ContextPackRepositoryHead {
15432 NotRepository,
15433 Unborn,
15434 Commit(String),
15435}
15436
15437#[derive(Clone, Debug, Eq, PartialEq)]
15438struct ContextPackRepositoryObservation {
15439 head: ContextPackRepositoryHead,
15440 git: GitSnapshot,
15441 files: Vec<ContextPackRepositoryFileSource>,
15442}
15443
15444fn stable_context_pack_repository(
15445 first: ContextPackRepositoryObservation,
15446 second: ContextPackRepositoryObservation,
15447) -> Result<ContextPackRepository, NodeFailure> {
15448 if first != second {
15449 return Err(failure(
15450 NodeFailureCode::ContextPackMaterializationFailed,
15451 "context repository changed during bounded capture",
15452 ));
15453 }
15454 Ok(ContextPackRepository::from_sources(
15455 &second.git,
15456 second.files,
15457 ))
15458}
15459
15460fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15467 if Instant::now() < deadline {
15468 return false;
15469 }
15470 append_git_diagnostic(
15471 snapshot,
15472 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15473 );
15474 true
15475}
15476
15477async fn run_git_bounded_with_deadline(
15486 root: &str,
15487 arguments: &[&str],
15488 output_limit: usize,
15489 deadline: Instant,
15490) -> io::Result<GitCommandOutput> {
15491 let remaining = deadline.checked_duration_since(Instant::now());
15492 let timeout_ms = remaining
15493 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15494 .unwrap_or(0)
15495 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15496 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15497}
15498
15499async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15506 let mut snapshot = GitSnapshot {
15507 is_repository: false,
15508 branch: None,
15509 status: Vec::new(),
15510 recent_commits: Vec::new(),
15511 worktrees: Vec::new(),
15512 managed_worktree: None,
15513 truncated: false,
15514 diagnostic: None,
15515 };
15516 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15517 return (snapshot, true);
15518 }
15519 let repository = match run_git_bounded_with_deadline(
15520 root,
15521 &["rev-parse", "--is-inside-work-tree"],
15522 4 * 1_024,
15523 deadline,
15524 )
15525 .await
15526 {
15527 Ok(output) => output,
15528 Err(error) => {
15529 append_git_diagnostic(
15530 &mut snapshot,
15531 &format!("git inspection unavailable: {error}"),
15532 );
15533 return (snapshot, false);
15534 }
15535 };
15536 snapshot.truncated |= repository.truncated;
15537 if repository.timed_out {
15538 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15539 return (snapshot, false);
15540 }
15541 if !repository.success
15542 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15543 {
15544 let stderr = String::from_utf8_lossy(&repository.stderr);
15545 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15546 append_git_diagnostic(&mut snapshot, stderr.trim());
15547 }
15548 return (snapshot, false);
15549 }
15550 snapshot.is_repository = true;
15551
15552 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15553 return (snapshot, true);
15554 }
15555 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15556 Ok(output) => {
15557 snapshot.truncated |= output.truncated;
15558 if output.timed_out {
15559 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15560 } else if output.success {
15561 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15562 if !branch.is_empty() {
15563 snapshot.branch = Some(truncate_git_field(
15564 &branch,
15565 MAX_REPOSITORY_PATH_BYTES,
15566 &mut snapshot.truncated,
15567 ));
15568 }
15569 }
15570 }
15571 Err(error) => append_git_diagnostic(
15572 &mut snapshot,
15573 &format!("git branch query failed: {error}"),
15574 ),
15575 }
15576 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15577 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15578 snapshot.truncated |= output.truncated;
15579 if output.success && !output.timed_out {
15580 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15581 if !head.is_empty() {
15582 snapshot.branch = Some(format!("detached:{head}"));
15583 }
15584 }
15585 }
15586 }
15587
15588 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15589 return (snapshot, true);
15590 }
15591 match run_git_bounded_with_deadline(
15592 root,
15593 &[
15594 "status",
15595 "--porcelain=v1",
15596 "-z",
15597 "--renames",
15598 "--untracked-files=normal",
15599 "--",
15600 ".",
15601 ],
15602 GIT_OUTPUT_MAX_BYTES,
15603 deadline,
15604 )
15605 .await
15606 {
15607 Ok(output) => {
15608 snapshot.truncated |= output.truncated;
15609 if output.timed_out {
15610 append_git_diagnostic(&mut snapshot, "git status timed out");
15611 } else if output.success {
15612 parse_git_status(&output.stdout, &mut snapshot);
15613 } else {
15614 append_git_diagnostic(
15615 &mut snapshot,
15616 String::from_utf8_lossy(&output.stderr).trim(),
15617 );
15618 }
15619 }
15620 Err(error) => append_git_diagnostic(
15621 &mut snapshot,
15622 &format!("git status failed: {error}"),
15623 ),
15624 }
15625
15626 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15627 return (snapshot, true);
15628 }
15629 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15630 match run_git_bounded_with_deadline(
15631 root,
15632 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15633 16 * 1_024,
15634 deadline,
15635 )
15636 .await
15637 {
15638 Ok(output) => {
15639 snapshot.truncated |= output.truncated;
15640 if output.timed_out {
15641 append_git_diagnostic(&mut snapshot, "git log timed out");
15642 } else if output.success {
15643 parse_git_commits(&output.stdout, &mut snapshot);
15644 } else {
15645 let stderr = String::from_utf8_lossy(&output.stderr);
15646 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15647 append_git_diagnostic(&mut snapshot, stderr.trim());
15648 }
15649 }
15650 }
15651 Err(error) => append_git_diagnostic(
15652 &mut snapshot,
15653 &format!("git log failed: {error}"),
15654 ),
15655 }
15656
15657 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15658 return (snapshot, true);
15659 }
15660 match list_git_worktrees_with_deadline(root, deadline).await {
15661 Ok(worktrees) => {
15662 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15663 }
15664 Err(error) => append_git_diagnostic(
15665 &mut snapshot,
15666 &format!("git worktree list failed: {}", error.message),
15667 ),
15668 }
15669 (snapshot, false)
15670}
15671
15672fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15673 let mut remaining = output;
15674 while !remaining.is_empty() {
15675 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15676 snapshot.truncated = true;
15677 break;
15678 }
15679 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15680 snapshot.truncated = true;
15681 break;
15682 };
15683 let record = &remaining[..record_end];
15684 remaining = &remaining[record_end + 1..];
15685 let has_previous_path = record
15686 .get(..2)
15687 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15688 let previous_path_bytes = if has_previous_path {
15689 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15690 snapshot.truncated = true;
15691 break;
15692 };
15693 let previous = &remaining[..previous_end];
15694 remaining = &remaining[previous_end + 1..];
15695 Some(previous)
15696 } else {
15697 None
15698 };
15699 if record.len() < 4
15700 || record[2] != b' '
15701 || !is_git_status_code(record[0])
15702 || !is_git_status_code(record[1])
15703 {
15704 snapshot.truncated = true;
15705 continue;
15706 }
15707 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15708 snapshot.truncated = true;
15709 continue;
15710 };
15711 let previous_path = match previous_path_bytes {
15712 Some(previous) => {
15713 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15714 snapshot.truncated = true;
15715 continue;
15716 };
15717 Some(previous)
15718 }
15719 None => None,
15720 };
15721 snapshot.status.push(GitStatusEntry {
15722 index_status: (record[0] as char).to_string(),
15723 worktree_status: (record[1] as char).to_string(),
15724 path,
15725 previous_path,
15726 });
15727 }
15728}
15729
15730fn is_git_status_code(byte: u8) -> bool {
15731 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15732}
15733
15734fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15735 for line in String::from_utf8_lossy(output).lines() {
15736 let Some((id, summary)) = line.split_once('\u{1f}') else {
15737 continue;
15738 };
15739 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15740 snapshot.truncated = true;
15741 break;
15742 }
15743 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15744 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15745 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15746 }
15747}
15748
15749fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15750 if value.len() <= max_bytes {
15751 return value.to_owned();
15752 }
15753 *truncated = true;
15754 let mut boundary = max_bytes;
15755 while boundary > 0 && !value.is_char_boundary(boundary) {
15756 boundary -= 1;
15757 }
15758 value[..boundary].to_owned()
15759}
15760
15761fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15762 if message.is_empty() {
15763 return;
15764 }
15765 let normalized = message
15766 .chars()
15767 .map(|character| if character.is_control() { ' ' } else { character })
15768 .collect::<String>();
15769 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15770 if !diagnostic.is_empty() {
15771 diagnostic.push_str("; ");
15772 }
15773 diagnostic.push_str(normalized.trim());
15774 snapshot.diagnostic = Some(truncate_git_field(
15775 &diagnostic,
15776 GIT_DIAGNOSTIC_MAX_BYTES,
15777 &mut snapshot.truncated,
15778 ));
15779}
15780
15781async fn run_git_bounded(
15782 root: &str,
15783 arguments: &[&str],
15784 output_limit: usize,
15785) -> io::Result<GitCommandOutput> {
15786 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15787}
15788
15789async fn read_git_history_bounded(
15790 root: &str,
15791 path: Option<&RepositoryPath>,
15792 before: Option<&GitObjectId>,
15793 limit: u16,
15794) -> Result<GitHistoryPage, NodeFailure> {
15795 let path = path
15796 .map(|path| {
15797 path.as_utf8().ok_or_else(|| {
15798 failure(
15799 NodeFailureCode::InvalidRepositoryPath,
15800 "git history path must use UTF-8",
15801 )
15802 })
15803 })
15804 .transpose()?
15805 .unwrap_or(".");
15806 let count = (usize::from(limit) + 1).to_string();
15807 let start = before
15808 .map(|revision| format!("{}^", revision.as_str()))
15809 .unwrap_or_else(|| "HEAD".to_owned());
15810 let arguments = [
15811 "--no-pager",
15812 "log",
15813 "--no-decorate",
15814 "--date=iso-strict",
15815 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15816 "-n",
15817 count.as_str(),
15818 start.as_str(),
15819 "--",
15820 path,
15821 ];
15822 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15823 .await
15824 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15825 if output.timed_out {
15826 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15827 }
15828 if !output.success {
15829 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15830 }
15831 let mut commits = parse_git_history(&output.stdout);
15832 let has_more = commits.len() > usize::from(limit) || output.truncated;
15833 commits.truncate(usize::from(limit));
15834 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15835 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15836}
15837
15838fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15839 String::from_utf8_lossy(output)
15840 .split('\u{1e}')
15841 .filter_map(|record| {
15842 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15843 if fields.len() != 9 {
15844 return None;
15845 }
15846 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15847 let parents = if fields[1].is_empty() {
15848 Vec::new()
15849 } else {
15850 fields[1]
15851 .split(' ')
15852 .map(|value| GitObjectId::new(value.to_owned()))
15853 .collect::<Result<Vec<_>, _>>()
15854 .ok()?
15855 };
15856 Some(GitCommitDetails {
15857 id,
15858 parents,
15859 subject: bounded_git_text(fields[2], 512),
15860 author_name: bounded_git_text(fields[3], 256),
15861 author_email: bounded_git_text(fields[4], 320),
15862 authored_at: bounded_git_text(fields[5], 64),
15863 committer_name: bounded_git_text(fields[6], 256),
15864 committer_email: bounded_git_text(fields[7], 320),
15865 committed_at: bounded_git_text(fields[8], 64),
15866 signature_status: GitSignatureStatus::NoSignature,
15867 signer: None,
15868 })
15869 })
15870 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15871 .collect()
15872}
15873
15874fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15875 let normalized = value
15876 .chars()
15877 .map(|character| if character.is_control() { ' ' } else { character })
15878 .collect::<String>();
15879 if normalized.len() <= max_bytes {
15880 return normalized;
15881 }
15882 let mut boundary = max_bytes;
15883 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15884 boundary -= 1;
15885 }
15886 normalized[..boundary].to_owned()
15887}
15888
15889async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15890 let path = request.path.as_ref().map(|path| {
15891 path.as_utf8()
15892 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15893 }).transpose()?;
15894 let mut arguments = match &request.mode {
15895 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15896 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15897 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15898 };
15899 if let GitDiffMode::Commit { revision } = &request.mode {
15900 arguments.push(revision.as_str());
15901 }
15902 arguments.push("--");
15903 if let Some(path) = path {
15904 arguments.push(path);
15905 }
15906 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15907 .await
15908 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15909 if output.timed_out {
15910 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15911 }
15912 if !output.success {
15913 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15914 }
15915 Ok(GitDiff {
15916 mode: request.mode,
15917 path: request.path,
15918 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15919 truncated: output.truncated,
15920 })
15921}
15922
15923async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15924 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15925 ResponseEnvelope { request_id: envelope.request_id, result }
15926}
15927
15928async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15929 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15930 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15931 }
15932 let read_only = matches!(
15933 &request,
15934 NodeRequest::Snapshot
15935 | NodeRequest::Resync { .. }
15936 | NodeRequest::BrowseHostDirectories { .. }
15937 | NodeRequest::InspectWorkspace { .. }
15938 | NodeRequest::ReadWorkspaceFile { .. }
15939 | NodeRequest::ReadGitHistory { .. }
15940 | NodeRequest::ReadGitDiff { .. }
15941 | NodeRequest::CatalogNativeSessions { .. }
15942 | NodeRequest::PageNativeSessions { .. }
15943 | NodeRequest::PreviewNativeSession { .. }
15944 | NodeRequest::PreviewSessionRecord { .. }
15945 );
15946 let _mutation_guard = if read_only {
15947 None
15948 } else {
15949 let guard = shared.mutation_gate.lock().await;
15950 if shared.shutdown.load(Ordering::Acquire) {
15951 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15952 }
15953 Some(guard)
15954 };
15955 match request {
15956 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15957 event_sequence: shared.current_sequence(),
15958 controller: shared.controller_state(),
15959 snapshot: shared.snapshot(),
15960 }),
15961 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15962 NodeRequest::ArmHarnessMcpReservation {
15963 reservation_id,
15964 activation_digest,
15965 spawn_spec,
15966 launch,
15967 expires_at_unix_ms,
15968 } => {
15969 shared.require_controller(connection_id, role)?;
15970 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15971 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15972 })?;
15973 let expires_at_unix_ms = registry
15974 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15975 .await
15976 .map_err(harness_mcp_failure)?;
15977 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15978 }
15979 NodeRequest::SpawnSpecWithHarnessMcp {
15980 reservation_id,
15981 activation_digest,
15982 spec,
15983 deadline_unix_ms,
15984 } => {
15985 shared.require_controller(connection_id, role)?;
15986 let receipt = shared
15987 .spawn_from_spec_with_harness_mcp(
15988 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15989 )
15990 .await?;
15991 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15992 }
15993 NodeRequest::ActivateHarnessMcpReservation {
15994 reservation_id,
15995 activation_digest,
15996 record_id,
15997 session,
15998 } => {
15999 shared.require_controller(connection_id, role)?;
16000 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16001 current.instance_id == session.session.instance_id
16002 && current.generation == session.session.generation
16003 }).and_then(|current| current.process_id).ok_or_else(|| {
16004 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16005 })?;
16006 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16007 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16008 })?.activate(
16009 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16010 ).map_err(harness_mcp_failure)?;
16011 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16012 }
16013 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16014 shared.require_controller(connection_id, role)?;
16015 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16016 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16017 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16018 if let Some(instance_id) = instance_id {
16019 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16020 }
16021 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16022 }
16023 NodeRequest::PutHarnessMcpReplyChunk {
16024 reservation_id, activation_digest, record_id, session, call_id,
16025 offset, final_chunk, chunk_hex,
16026 } => {
16027 shared.require_controller(connection_id, role)?;
16028 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16029 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16030 })?.put_reply_chunk(
16031 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16032 offset, final_chunk, &chunk_hex,
16033 ).map_err(harness_mcp_failure)?;
16034 Ok(NodeResponse::ReplyChunkAccepted {
16035 reservation_id, activation_digest, record_id, session, call_id,
16036 next_offset, completed,
16037 })
16038 }
16039 NodeRequest::RejectHarnessMcpCall {
16040 reservation_id, activation_digest, record_id, session, call_id, reason,
16041 } => {
16042 shared.require_controller(connection_id, role)?;
16043 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16044 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16045 })?.reject_call(
16046 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16047 ).map_err(harness_mcp_failure)?;
16048 Ok(NodeResponse::CallRejected {
16049 reservation_id, activation_digest, record_id, session, call_id,
16050 })
16051 }
16052 NodeRequest::BeginDeliveryStage { manifest } => {
16053 shared.require_controller(connection_id, role)?;
16054 shared.begin_delivery_stage(manifest)
16055 }
16056 NodeRequest::PutDeliveryBlobChunk {
16057 stage_id,
16058 blob_digest,
16059 offset,
16060 chunk_hex,
16061 } => {
16062 shared.require_controller(connection_id, role)?;
16063 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16064 }
16065 NodeRequest::CommitDeliveryStage { stage_id } => {
16066 shared.require_controller(connection_id, role)?;
16067 let receipt = shared.commit_delivery_stage(stage_id)?;
16068 Ok(NodeResponse::DeliveryCommitted { receipt })
16069 }
16070 NodeRequest::AbortDeliveryStage { stage_id } => {
16071 shared.require_controller(connection_id, role)?;
16072 shared.abort_delivery_stage(&stage_id)?;
16073 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16074 }
16075 NodeRequest::BrowseHostDirectories { directory, after } => {
16076 let listing = shared.browse_host_directories(directory, after).await?;
16077 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16078 }
16079 NodeRequest::InspectWorkspace { workspace_id } => {
16080 let inspection = shared.inspect_workspace(workspace_id).await?;
16081 Ok(NodeResponse::WorkspaceInspected { inspection })
16082 }
16083 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16084 let file = shared.read_workspace_file(workspace_id, path).await?;
16085 Ok(NodeResponse::WorkspaceFileRead { file })
16086 }
16087 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16088 shared.require_controller(connection_id, role)?;
16089 let file = shared
16090 .write_workspace_file(workspace_id, path, expected_revision, text)
16091 .await?;
16092 Ok(NodeResponse::WorkspaceFileWritten { file })
16093 }
16094 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16095 shared.require_controller(connection_id, role)?;
16096 let file = shared.create_workspace_file(workspace_id, path).await?;
16097 Ok(NodeResponse::WorkspaceFileCreated { file })
16098 }
16099 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16100 shared.require_controller(connection_id, role)?;
16101 let entry = shared
16102 .create_workspace_directory(workspace_id.clone(), path)
16103 .await?;
16104 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16105 }
16106 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16107 if role != ClientRole::Operator {
16108 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16109 }
16110 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16111 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16112 }
16113 let page = shared
16114 .read_git_history(workspace_id.clone(), path, before, limit)
16115 .await?;
16116 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16117 }
16118 NodeRequest::ReadGitDiff { workspace_id, request } => {
16119 if role != ClientRole::Operator {
16120 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16121 }
16122 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16123 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16124 }
16125 NodeRequest::CatalogNativeSessions { route, limit } => {
16126 if role != ClientRole::Operator {
16127 return Err(failure(
16128 NodeFailureCode::ObserverReadOnly,
16129 "native session catalog requires an operator connection",
16130 ));
16131 }
16132 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16133 return Err(failure(
16134 NodeFailureCode::InvalidRequest,
16135 "native session catalog limit is invalid",
16136 ));
16137 }
16138 let (entries, summary) = shared
16139 .catalog_native_sessions(route.clone(), limit)
16140 .await?;
16141 Ok(NodeResponse::NativeSessionsCataloged {
16142 route,
16143 entries,
16144 summary: Some(summary),
16145 })
16146 }
16147 NodeRequest::PageNativeSessions {
16148 route,
16149 window,
16150 catalog_revision,
16151 recent_cutoff_unix_ms,
16152 after_selection_id,
16153 limit,
16154 } => {
16155 if role != ClientRole::Operator {
16156 return Err(failure(
16157 NodeFailureCode::ObserverReadOnly,
16158 "native session catalog paging requires an operator connection",
16159 ));
16160 }
16161 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16162 || after_selection_id
16163 .as_deref()
16164 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16165 {
16166 return Err(failure(
16167 NodeFailureCode::InvalidRequest,
16168 "native session catalog page request is invalid",
16169 ));
16170 }
16171 let page = shared
16172 .page_native_sessions(
16173 route.clone(),
16174 window,
16175 catalog_revision,
16176 recent_cutoff_unix_ms,
16177 after_selection_id,
16178 limit,
16179 )
16180 .await?;
16181 Ok(NodeResponse::NativeSessionsPaged {
16182 route,
16183 page,
16184 })
16185 }
16186 NodeRequest::PreviewNativeSession {
16187 selection,
16188 message_limit,
16189 } => {
16190 if role != ClientRole::Operator {
16191 return Err(failure(
16192 NodeFailureCode::ObserverReadOnly,
16193 "native session preview requires an operator connection",
16194 ));
16195 }
16196 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16197 .contains(&message_limit)
16198 || selection.validate().is_err()
16199 {
16200 return Err(failure(
16201 NodeFailureCode::InvalidRequest,
16202 "native session preview request is invalid",
16203 ));
16204 }
16205 let preview = shared
16206 .preview_native_session(selection.clone(), message_limit)
16207 .await?;
16208 Ok(NodeResponse::NativeSessionPreviewed {
16209 selection,
16210 preview,
16211 })
16212 }
16213 NodeRequest::IndexNativeSession {
16214 selection,
16215 display_name,
16216 } => {
16217 shared.require_controller(connection_id, role)?;
16218 let record = shared
16219 .index_native_session(selection.clone(), display_name)
16220 .await?;
16221 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16222 }
16223 NodeRequest::PreviewSessionRecord {
16224 record_id,
16225 message_limit,
16226 } => {
16227 if role != ClientRole::Operator {
16228 return Err(failure(
16229 NodeFailureCode::ObserverReadOnly,
16230 "native session preview requires an operator connection",
16231 ));
16232 }
16233 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16234 .contains(&message_limit)
16235 {
16236 return Err(failure(
16237 NodeFailureCode::InvalidRequest,
16238 "native session preview request is invalid",
16239 ));
16240 }
16241 let record = shared.record(&record_id)?;
16242 let working_directory = shared.workspace_root(&record.workspace_id)?;
16243 if !platform::roots_equal(
16244 &working_directory,
16245 windows_path_text(&record.canonical_root),
16246 ) {
16247 return Err(failure(
16248 NodeFailureCode::SessionRecordConflict,
16249 "managed session workspace root changed; refusing to preview another directory",
16250 ));
16251 }
16252 let identity = record.provider_session.clone().ok_or_else(|| {
16253 failure(
16254 NodeFailureCode::BackendOperationFailed,
16255 "native session preview is unavailable for this session record",
16256 )
16257 })?;
16258 if identity.key != ProviderSessionKey::SessionId {
16259 return Err(failure(
16260 NodeFailureCode::BackendOperationFailed,
16261 "native session preview is unavailable for this session identity",
16262 ));
16263 }
16264 let preview = shared
16265 .preview_native_session_by_session_id(
16266 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16267 )
16268 .await?;
16269 if preview.session_id != identity.id {
16270 return Err(failure(
16271 NodeFailureCode::SessionRecordConflict,
16272 "native session preview identity changed while loading",
16273 ));
16274 }
16275 shared.revalidate_session_record_preview(&record, &identity)?;
16276 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16277 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16278 record_id: record_id.clone(),
16279 summary: SessionHistorySummaryV1::from(&preview),
16280 });
16281 Ok(NodeResponse::SessionRecordPreviewed {
16282 record_id,
16283 preview,
16284 })
16285 }
16286 NodeRequest::AcquireController { lease_ms } => {
16287 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16288 Ok(NodeResponse::Controller { controller: Some(controller) })
16289 }
16290 NodeRequest::ReleaseController => {
16291 shared.release_controller(connection_id);
16292 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16293 }
16294 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16295 shared.require_controller(connection_id, role)?;
16296 let root = require_windows_path(root)?;
16297 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16298 let workspace = shared.register_workspace(workspace_id, root).await?;
16299 Ok(NodeResponse::WorkspaceRegistered { workspace })
16300 }
16301 NodeRequest::CreateStandaloneWorkspace {
16302 workspace_id,
16303 root,
16304 initial_branch,
16305 } => {
16306 shared.require_controller(connection_id, role)?;
16307 let root = require_windows_path(root)?;
16308 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16309 let workspace = shared
16310 .create_standalone_workspace(workspace_id, root, initial_branch)
16311 .await?;
16312 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16313 }
16314 NodeRequest::UnregisterWorkspace { workspace_id } => {
16315 shared.require_controller(connection_id, role)?;
16316 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16317 shared.unregister_workspace(&workspace_id)?;
16318 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16319 }
16320 NodeRequest::CreateWorktree {
16321 source_workspace_id,
16322 workspace_id,
16323 target_root,
16324 branch,
16325 base,
16326 } => {
16327 shared.require_controller(connection_id, role)?;
16328 let target_root = require_windows_path(target_root)?;
16329 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16330 let (worktree, workspace) = shared
16331 .create_worktree(
16332 source_workspace_id,
16333 workspace_id,
16334 target_root,
16335 branch,
16336 base,
16337 )
16338 .await?;
16339 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16340 }
16341 NodeRequest::RemoveWorktree {
16342 source_workspace_id,
16343 target_root,
16344 } => {
16345 shared.require_controller(connection_id, role)?;
16346 let native_target_root = require_windows_path(target_root.clone())?;
16347 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16348 let workspace_id = shared
16349 .remove_worktree(source_workspace_id, native_target_root)
16350 .await?;
16351 Ok(NodeResponse::WorktreeRemoved {
16352 target_root,
16353 workspace_id,
16354 })
16355 }
16356 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16357 shared.require_controller(connection_id, role)?;
16358 if !terminal_size.is_valid() {
16359 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16360 }
16361 if let Some(prompt) = initial_prompt.as_deref() {
16362 validate_node_text("spawn initial prompt", prompt)?;
16363 }
16364 match shared
16365 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16366 .await
16367 {
16368 Ok(session) => {
16369 shared.mark_spawn_provisional(session.clone());
16370 Ok(NodeResponse::SpawnAccepted { session })
16371 }
16372 Err(err) => {
16373 tracing::warn!(
16374 %provider,
16375 %workspace_id,
16376 ?mode,
16377 code = ?err.code,
16378 message = %err.message,
16379 "session spawn refused"
16380 );
16381 Err(err)
16382 }
16383 }
16384 }
16385 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16386 shared.require_controller(connection_id, role)?;
16387 if !terminal_size.is_valid() {
16388 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16389 }
16390 if let Some(prompt) = initial_prompt.as_deref() {
16391 validate_node_text("spawn initial prompt", prompt)?;
16392 }
16393 let plan = backend_paths::plan_launch_options(
16394 provider.as_str(),
16395 mode,
16396 options.model.is_some(),
16397 options.effort.is_some(),
16398 )
16399 .map_err(|r| {
16400 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16401 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16402 })?;
16403 let selection = spawn_selection(&options)?;
16404 let pty_selection = match plan {
16405 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16406 _ => None,
16407 };
16408 let spawned = SPAWN_SESSION_OPTIONS
16409 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16410 .await;
16411 match spawned {
16412 Ok(session) => {
16413 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16414 shared
16415 .pending_acp_options
16416 .lock()
16417 .unwrap_or_else(|p| p.into_inner())
16418 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16419 }
16420 shared.mark_spawn_provisional(session.clone());
16421 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16422 Ok(NodeResponse::SpawnAccepted { session })
16423 }
16424 Err(err) => {
16425 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16426 Err(err)
16427 }
16428 }
16429 }
16430 NodeRequest::SpawnSpec { spec } => {
16431 shared.require_controller(connection_id, role)?;
16432 let receipt = shared.spawn_from_spec(spec).await?;
16433 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16434 }
16435 NodeRequest::SpawnManagedWorktree { request } => {
16436 shared.require_controller(connection_id, role)?;
16437 let receipt = shared.spawn_managed_worktree(request).await?;
16438 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16439 }
16440 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16441 shared.require_controller(connection_id, role)?;
16442 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16443 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16444 }
16445 NodeRequest::CleanupManagedWorktree { lease_id } => {
16446 shared.require_controller(connection_id, role)?;
16447 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16448 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16449 }
16450 NodeRequest::DiscoverHistory { session, limit } => {
16451 controlled_session(shared, connection_id, role, &session)?;
16452 let candidates = shared.discover_history(&session, limit).await?;
16453 Ok(NodeResponse::HistoryDiscovered {
16454 session,
16455 candidates,
16456 })
16457 }
16458 NodeRequest::LoadHistory {
16459 session,
16460 candidate_id,
16461 } => {
16462 controlled_session(shared, connection_id, role, &session)?;
16463 let loaded = shared
16464 .load_history(&session, candidate_id)
16465 .await?;
16466 Ok(NodeResponse::HistoryLoaded {
16467 session,
16468 session_id: loaded.session_id,
16469 message_count: loaded.message_count,
16470 completed_turn_count: loaded.completed_turn_count,
16471 })
16472 }
16473 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16474 shared.require_controller(connection_id, role)?;
16475 let context = shared
16476 .export_context_pack_for_session_record(&record_id, &session, false)
16477 .await?;
16478 Ok(NodeResponse::ContextPackForSessionRecordExported {
16479 record_id,
16480 session,
16481 context,
16482 })
16483 }
16484 NodeRequest::ExportContextPack { session } => {
16485 controlled_session(shared, connection_id, role, &session)?;
16486 let context = shared.export_context_pack(&session).await?;
16487 Ok(NodeResponse::ContextPackExported { context })
16488 }
16489 NodeRequest::ForgetContextPack { context_id } => {
16490 shared.require_controller(connection_id, role)?;
16491 shared.forget_context_pack(&context_id)?;
16492 Ok(NodeResponse::ContextPackForgotten { context_id })
16493 }
16494 NodeRequest::ResolveDurableContextPack { context_id } => {
16495 let context = shared
16499 .context_catalog
16500 .read()
16501 .unwrap_or_else(|poisoned| poisoned.into_inner())
16502 .get(&context_id)
16503 .map(|pack| pack.receipt().clone())
16504 .ok_or_else(|| {
16505 failure(
16506 NodeFailureCode::UnknownContextPack,
16507 "context pack is not present",
16508 )
16509 })?;
16510 Ok(NodeResponse::DurableContextPackResolved { context })
16511 }
16512 NodeRequest::ReadContextPack { digest } => {
16513 let (id, byte_len, bytes) = shared
16518 .context_catalog
16519 .read()
16520 .unwrap_or_else(|poisoned| poisoned.into_inner())
16521 .get_by_digest(&digest)
16522 .map(|pack| {
16523 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16524 })
16525 .ok_or_else(|| {
16526 failure(
16527 NodeFailureCode::UnknownContextPack,
16528 &format!("context pack {digest} not on this node"),
16529 )
16530 })?;
16531 tracing::debug!(
16532 digest = digest.as_str(),
16533 byte_len,
16534 "read context pack bytes",
16535 );
16536 Ok(NodeResponse::ContextPackBytesRead {
16537 pack: ContextPackBytesRead {
16538 digest,
16539 id,
16540 byte_len,
16541 bytes,
16542 },
16543 })
16544 }
16545 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16546 let provider = controlled_session(shared, connection_id, role, &session)?;
16547 if let Some(prompt) = initial_prompt.as_deref() {
16548 validate_node_text("resume initial prompt", prompt)?;
16549 }
16550 let runtime_requirement = if initial_prompt.is_some() {
16551 ProviderRuntimeRequirement::ResumeWithPrompt
16552 } else {
16553 ProviderRuntimeRequirement::Resume
16554 };
16555 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16556 let runtime_policy = shared
16557 .admit_provider_runtime(&provider, runtime_requirement)
16558 .await?;
16559 let working_directory = shared.bound_workspace_root(&session)?;
16560 let request = ResumeLaunchRequest {
16561 working_directory,
16562 terminal_size,
16563 initial_prompt,
16564 };
16565 request
16566 .validate()
16567 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16568 let command = shared.prepare_command(ControlCommand::Resume {
16569 instance_id: session.session.instance_id,
16570 target: ResumeTarget::CurrentProvider,
16571 runtime_policy,
16572 request,
16573 });
16574 shared.arm_resume(&session, command.id, runtime_policy)?;
16575 let dispatch = shared.dispatch_envelope(command);
16576 if let Err(error) = dispatch {
16577 shared.clear_armed_resume(&session);
16578 return Err(error);
16579 }
16580 Ok(NodeResponse::Accepted)
16581 }
16582 NodeRequest::RenameSessionRecord {
16583 record_id,
16584 display_name,
16585 } => {
16586 shared.require_controller(connection_id, role)?;
16587 let record = shared.rename_session_record(&record_id, display_name)?;
16588 Ok(NodeResponse::SessionRecordUpdated { record })
16589 }
16590 NodeRequest::SetSessionTask {
16591 record_id,
16592 expected_revision,
16593 target,
16594 } => {
16595 shared.require_controller(connection_id, role)?;
16596 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16597 Ok(NodeResponse::SessionRecordUpdated { record })
16598 }
16599 NodeRequest::IndexProviderSession {
16600 workspace_id,
16601 provider,
16602 identity,
16603 display_name,
16604 } => {
16605 shared.require_controller(connection_id, role)?;
16606 let record = shared.index_provider_session(
16607 workspace_id,
16608 provider,
16609 identity,
16610 display_name,
16611 )?;
16612 Ok(NodeResponse::ProviderSessionIndexed { record })
16613 }
16614 NodeRequest::ResumeSessionRecord {
16615 record_id,
16616 terminal_size,
16617 initial_prompt,
16618 } => {
16619 shared.require_controller(connection_id, role)?;
16620 if let Some(prompt) = initial_prompt.as_deref() {
16621 validate_node_text("managed resume initial prompt", prompt)?;
16622 }
16623 let (record, session) = shared
16624 .resume_session_record(
16625 &record_id,
16626 terminal_size,
16627 initial_prompt,
16628 )
16629 .await?;
16630 Ok(NodeResponse::SessionRecordResumed { record, session })
16631 }
16632 NodeRequest::ForgetSessionRecord { record_id } => {
16633 shared.require_controller(connection_id, role)?;
16634 shared.forget_session_record(&record_id).await?;
16635 Ok(NodeResponse::SessionRecordForgotten { record_id })
16636 }
16637 NodeRequest::Prompt { session, text } => {
16638 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16639 validate_node_text("prompt", &text)?;
16640 shared.require_session_runtime_policy(
16641 &session,
16642 ProviderRuntimeRequirement::SemanticPrompt,
16643 )?;
16644 let framing = prompt_framing(&agent_id);
16645 shared
16646 .dispatch_input_bounded(
16647 &session,
16648 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16649 )
16650 .await?;
16651 Ok(NodeResponse::Accepted)
16652 }
16653 NodeRequest::Paste { session, text } => {
16654 controlled_session(shared, connection_id, role, &session)?;
16655 validate_node_text("paste", &text)?;
16656 shared.require_session_runtime_policy(
16657 &session,
16658 ProviderRuntimeRequirement::SemanticPrompt,
16659 )?;
16660 shared
16661 .dispatch_input_bounded(
16662 &session,
16663 InputAction::InsertDraft(PromptPayload {
16664 text,
16665 framing: PromptFraming::BracketedPaste,
16666 }),
16667 )
16668 .await?;
16669 Ok(NodeResponse::Accepted)
16670 }
16671 NodeRequest::Input { session, text } => {
16672 controlled_session(shared, connection_id, role, &session)?;
16673 validate_node_text("terminal input", &text)?;
16674 shared
16675 .dispatch_input_bounded(
16676 &session,
16677 InputAction::TerminalText(TerminalText { text }),
16678 )
16679 .await?;
16680 Ok(NodeResponse::Accepted)
16681 }
16682 NodeRequest::TerminalBytes { session, bytes } => {
16683 controlled_session(shared, connection_id, role, &session)?;
16684 shared.sessionize_spawn(session.session.instance_id);
16685 let action = terminal_bytes_action(bytes)?;
16686 shared
16687 .dispatch_input_bounded(&session, action)
16688 .await?;
16689 Ok(NodeResponse::Accepted)
16690 }
16691 NodeRequest::TerminalControl { session, control } => {
16692 controlled_session(shared, connection_id, role, &session)?;
16693 shared
16694 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16695 .await?;
16696 Ok(NodeResponse::Accepted)
16697 }
16698 NodeRequest::Resize { session, size } => {
16699 controlled_session(shared, connection_id, role, &session)?;
16700 shared.sessionize_spawn(session.session.instance_id);
16701 if !size.is_valid() {
16702 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16703 }
16704 shared.dispatch_resize_bounded(&session, size).await?;
16705 Ok(NodeResponse::Accepted)
16706 }
16707 NodeRequest::Interrupt { session } => {
16708 controlled_session(shared, connection_id, role, &session)?;
16709 shared
16710 .dispatch_input_bounded(
16711 &session,
16712 InputAction::TerminalControl(TerminalControl::Interrupt),
16713 )
16714 .await?;
16715 Ok(NodeResponse::Accepted)
16716 }
16717 NodeRequest::Stop { session, force } => {
16718 controlled_session(shared, connection_id, role, &session)?;
16719 shared.sessionize_spawn(session.session.instance_id);
16720 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16721 Ok(NodeResponse::Accepted)
16722 }
16723 NodeRequest::Remove { session } => {
16724 controlled_session(shared, connection_id, role, &session)?;
16725 shared.remove_session(&session).await?;
16726 Ok(NodeResponse::Accepted)
16727 }
16728 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16729 shared.require_controller(connection_id, role)?;
16730 let snapshot = shared.require_acp_session(&session)?;
16731 let interaction = snapshot
16732 .provider
16733 .interactions
16734 .iter()
16735 .find(|interaction| {
16736 interaction_correlation(
16737 session.session.instance_id,
16738 session.session.generation,
16739 interaction.id.0,
16740 ) == correlation_id
16741 })
16742 .ok_or_else(|| failure(
16743 NodeFailureCode::InvalidRequest,
16744 "unknown interaction correlation",
16745 ))?;
16746 if interaction.status != ProviderInteractionStatus::Pending {
16747 return Err(failure(
16748 NodeFailureCode::BackendOperationFailed,
16749 "interaction has already been resolved",
16750 ));
16751 }
16752 response
16753 .validate_for(interaction.interaction_kind)
16754 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16755 let interaction_id = interaction.id;
16756 let response_kind = response.kind();
16757 let response_outcome = response.outcome();
16758 shared.dispatch(ControlCommand::ResolveInteraction {
16759 instance_id: session.session.instance_id,
16760 generation: session.session.generation,
16761 interaction_id,
16762 response,
16763 })?;
16764 tracing::info!(
16770 node_id = %shared.node_id,
16771 session = ?session.session,
16772 correlation_id = %correlation_id,
16773 interaction_id = interaction_id.0,
16774 response_kind = ?response_kind,
16775 outcome = ?response_outcome,
16776 "interaction resolved",
16777 );
16778 Ok(NodeResponse::Accepted)
16779 }
16780 NodeRequest::SetSessionMode { session, mode_id } => {
16781 shared.require_controller(connection_id, role)?;
16782 shared.require_acp_session(&session)?;
16783 shared.dispatch(ControlCommand::SetSessionMode {
16784 instance_id: session.session.instance_id,
16785 mode_id,
16786 })?;
16787 Ok(NodeResponse::Accepted)
16788 }
16789 NodeRequest::SetSessionConfigOption {
16790 session,
16791 option_id,
16792 value_json,
16793 } => {
16794 shared.require_controller(connection_id, role)?;
16795 shared.require_acp_session(&session)?;
16796 shared.dispatch(ControlCommand::SetSessionConfigOption {
16797 instance_id: session.session.instance_id,
16798 option_id,
16799 value_json,
16800 })?;
16801 Ok(NodeResponse::Accepted)
16802 }
16803 NodeRequest::SetSessionModel { session, model_id } => {
16804 shared.require_controller(connection_id, role)?;
16805 shared.require_acp_session(&session)?;
16806 shared.dispatch(ControlCommand::SetSessionModel {
16807 instance_id: session.session.instance_id,
16808 model_id,
16809 })?;
16810 Ok(NodeResponse::Accepted)
16811 }
16812 NodeRequest::Shutdown => {
16813 shared.require_controller(connection_id, role)?;
16814 shared.begin_shutdown_locked().await?;
16815 Ok(NodeResponse::ShuttingDown)
16816 }
16817 }
16818}
16819
16820fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16821 shared.require_controller(connection_id, role)?;
16822 shared.validate_address(session)
16823}
16824
16825fn select_history_candidate(
16831 candidates: &[HistoryCandidateSummary],
16832 provider_session_id: Option<&str>,
16833) -> Result<String, NodeFailure> {
16834 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16835 Some(id) => candidate.session_id_hint == id,
16836 None => true,
16837 });
16838 let candidate = matches.next().ok_or_else(|| {
16839 failure(
16840 NodeFailureCode::SessionRecordConflict,
16841 if provider_session_id.is_some() {
16842 "no history candidate matches the managed provider session"
16843 } else {
16844 "no history candidate exists for the managed session"
16845 },
16846 )
16847 })?;
16848 if matches.next().is_some() {
16849 return Err(failure(
16850 NodeFailureCode::SessionRecordConflict,
16851 if provider_session_id.is_some() {
16852 "history identity is ambiguous for the managed provider session"
16853 } else {
16854 "history is ambiguous without a verified provider session identity"
16855 },
16856 ));
16857 }
16858 Ok(candidate.id.clone())
16859}
16860
16861fn resolved_spawn_profile_summary(
16862 profile: &SpawnProfileDefaults,
16863 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16864) -> Option<SpawnProfileSummary> {
16865 let environment_profile = match profile.environment_profile_id.as_ref() {
16866 None => None,
16867 Some(profile_id) => {
16868 let binding = environment_profiles.get(profile_id)?;
16869 if binding.provider != profile.provider
16870 || binding.native_profile_id(profile.mode).is_none()
16871 {
16872 return None;
16873 }
16874 Some(ResolvedEnvironmentProfileReceipt {
16875 profile_id: binding.id.clone(),
16876 profile_revision: binding.revision.clone(),
16877 network_allowlist: None,
16878 browser_profile_id: None,
16879 })
16880 }
16881 };
16882 Some(SpawnProfileSummary {
16883 id: profile.profile_id.clone(),
16884 revision: profile.revision.clone(),
16885 environment_profile,
16886 })
16887}
16888
16889fn session_record_export_target_matches(
16908 expected: &ManagedSessionRecord,
16909 current: &ManagedSessionRecord,
16910 session: &SessionAddress,
16911 allow_clean_detachment: bool,
16912) -> bool {
16913 if current == expected && current.active_session.as_ref() == Some(session) {
16914 return current.state == ManagedSessionState::Live;
16915 }
16916 allow_clean_detachment
16917 && current.state == ManagedSessionState::Dormant
16918 && current.active_session.is_none()
16919 && current.record_id == expected.record_id
16920 && current.display_name == expected.display_name
16921 && current.provider == expected.provider
16922 && current.mode == expected.mode
16923 && current.workspace_id == expected.workspace_id
16924 && current.canonical_root == expected.canonical_root
16925 && current.provider_session == expected.provider_session
16926 && current.environment_profile == expected.environment_profile
16927 && current.bundle == expected.bundle
16928 && current.context_id == expected.context_id
16929 && current.context == expected.context
16930 && current.exported_context == expected.exported_context
16931 && current.task_binding == expected.task_binding
16932 && current.created_at_unix_ms == expected.created_at_unix_ms
16933 && current.last_error == expected.last_error
16934}
16935
16936fn session_record_context_export_binding_is_exact(
16937 expected: &ManagedSessionRecord,
16938 current: &ManagedSessionRecord,
16939 identity: Option<&ProviderSessionIdentity>,
16940 session: &SessionAddress,
16941 binding: &SessionBinding,
16942 provider: &AgentId,
16943 current_root: &str,
16944 allow_clean_detachment: bool,
16945) -> bool {
16946 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16947 && current.provider_session.as_ref() == identity
16948 && current.workspace_id == session.workspace_id
16949 && provider == ¤t.provider
16950 && binding.workspace_id == session.workspace_id
16951 && binding.generation == session.session.generation
16952 && binding.record_id.as_ref() == Some(¤t.record_id)
16953 && platform::roots_equal(
16954 current_root,
16955 windows_path_text(¤t.canonical_root),
16956 )
16957}
16958
16959fn session_record_context_export_source_is_usable(
16960 record: &ManagedSessionRecord,
16961 status: &SessionStatus,
16962 allow_clean_detachment: bool,
16963) -> bool {
16964 (record.state == ManagedSessionState::Live
16965 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16966 && context_pack_source_status_is_usable(status)
16967}
16968
16969fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16970 PromptFraming::BracketedPaste
16971}
16972
16973fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16974 if text.len() > MAX_NODE_TEXT_BYTES {
16975 return Err(failure(
16976 NodeFailureCode::InvalidRequest,
16977 &format!(
16978 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16979 text.len(),
16980 ),
16981 ));
16982 }
16983 Ok(())
16984}
16985
16986fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16987 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16988 return Err(failure(
16989 NodeFailureCode::InvalidRequest,
16990 &format!(
16991 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16992 bytes.len(),
16993 ),
16994 ));
16995 }
16996 Ok(())
16997}
16998
16999fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17000 validate_terminal_bytes(&bytes)?;
17001 Ok(InputAction::TerminalBytes(bytes))
17002}
17003
17004fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17005 if root.is_empty()
17006 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17007 || root.chars().any(char::is_control)
17008 {
17009 return Err(failure(
17010 NodeFailureCode::InvalidWorkspaceRoot,
17011 &format!(
17012 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17013 ),
17014 ));
17015 }
17016 Ok(())
17017}
17018
17019fn canonical_standalone_workspace_candidate(
17020 workspace_id: &WorkspaceId,
17021 root: &str,
17022) -> Result<String, NodeServerError> {
17023 let path = Path::new(root);
17024 match std::fs::symlink_metadata(path) {
17025 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17026 .map(|workspace| workspace.canonical_root),
17027 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17028 if !path.is_absolute() {
17029 return Err(NodeServerError::InvalidWorkspaceRoot {
17030 workspace_id: workspace_id.clone(),
17031 path: root.to_owned(),
17032 message: "path is not absolute".to_owned(),
17033 });
17034 }
17035 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17036 workspace_id: workspace_id.clone(),
17037 path: root.to_owned(),
17038 message: "path has no standalone directory name".to_owned(),
17039 })?;
17040 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17041 workspace_id: workspace_id.clone(),
17042 path: root.to_owned(),
17043 message: "path has no parent directory".to_owned(),
17044 })?;
17045 let canonical = std::fs::canonicalize(parent)
17046 .map(|parent| parent.join(name))
17047 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17048 workspace_id: workspace_id.clone(),
17049 path: root.to_owned(),
17050 message: error.to_string(),
17051 })?;
17052 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17053 NodeServerError::InvalidWorkspaceRoot {
17054 workspace_id: workspace_id.clone(),
17055 path: path.to_string_lossy().into_owned(),
17056 message: "canonical path is not valid Unicode".to_owned(),
17057 }
17058 })?;
17059 let canonical_root = platform::normalize_canonical_root(canonical_root);
17060 validate_workspace_root(workspace_id, &canonical_root)?;
17061 Ok(canonical_root)
17062 }
17063 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17064 workspace_id: workspace_id.clone(),
17065 path: root.to_owned(),
17066 message: error.to_string(),
17067 }),
17068 }
17069}
17070
17071fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17072 let Some(revision) = revision else {
17073 return Ok(());
17074 };
17075 if revision.is_empty()
17076 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17077 || revision.chars().any(char::is_control)
17078 {
17079 return Err(failure(
17080 NodeFailureCode::InvalidRequest,
17081 &format!(
17082 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17083 ),
17084 ));
17085 }
17086 Ok(())
17087}
17088
17089fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17090 let code = match error.kind {
17091 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17092 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17093 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17094 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17095 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17096 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17097 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17098 };
17099 failure(code, &error.message)
17100}
17101
17102fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17103 let code = match error.kind {
17104 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17105 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17106 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17107 NodeFailureCode::BackendOperationFailed
17108 }
17109 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17110 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17111 }
17112 };
17113 failure(code, error.message)
17114}
17115
17116fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17117 let (code, message) = match error.kind {
17118 GitWorktreeErrorKind::Invalid => (
17119 NodeFailureCode::InvalidRequest,
17120 "managed Git input is invalid",
17121 ),
17122 GitWorktreeErrorKind::NotRepository => (
17123 NodeFailureCode::NotGitRepository,
17124 "managed source is not a Git repository",
17125 ),
17126 GitWorktreeErrorKind::Conflict => (
17127 NodeFailureCode::WorktreeConflict,
17128 "managed Git identity conflicts with existing repository state",
17129 ),
17130 GitWorktreeErrorKind::Protected => (
17131 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17132 "managed worktree ownership validation failed",
17133 ),
17134 GitWorktreeErrorKind::Dirty => (
17135 NodeFailureCode::WorktreeDirty,
17136 "managed worktree contains uncommitted changes",
17137 ),
17138 GitWorktreeErrorKind::Locked => (
17139 NodeFailureCode::WorktreeLocked,
17140 "managed worktree is locked",
17141 ),
17142 GitWorktreeErrorKind::Failed => (
17143 NodeFailureCode::BackendOperationFailed,
17144 "managed Git operation failed",
17145 ),
17146 };
17147 NodeFailure { code, message: message.to_owned() }
17148}
17149
17150fn managed_spawn_receipt(
17151 resolved: &ResolvedSpawnSpec,
17152 incarnation_id: NodeIncarnationId,
17153 session: SessionAddress,
17154 lease: ManagedWorktreeLeaseSnapshot,
17155 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17156 bundle: Option<ResolvedBundleReceipt>,
17157 context: Option<ResolvedContextPackReceipt>,
17158) -> ManagedWorktreeSpawnReceipt {
17159 let mut managed = resolved.clone();
17160 managed.target.worktree_id = Some(lease.workspace_id.clone());
17161 ManagedWorktreeSpawnReceipt {
17162 spawn: managed.receipt_with_materialization(
17163 incarnation_id,
17164 session,
17165 environment_profile,
17166 bundle,
17167 context,
17168 ),
17169 lease,
17170 }
17171}
17172
17173fn validate_selected_worktree(
17174 source_root: &str,
17175 selected_root: &str,
17176 worktrees: &[NativeGitWorktreeSnapshot],
17177) -> Result<(), NodeFailure> {
17178 if !worktrees
17179 .iter()
17180 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17181 {
17182 return Err(failure(
17183 NodeFailureCode::WorktreeProtected,
17184 "source workspace is not an authoritative non-bare Git worktree root",
17185 ));
17186 }
17187 if !worktrees.iter().any(|worktree| {
17188 worktree_paths_equal(&worktree.path, selected_root)
17189 && !worktree.is_main
17190 && !worktree.is_bare
17191 }) {
17192 return Err(failure(
17193 NodeFailureCode::WorktreeProtected,
17194 "selected workspace is not an eligible Git worktree in the source repository",
17195 ));
17196 }
17197 Ok(())
17198}
17199
17200fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17201 let code = match error.kind() {
17202 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17203 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17204 NodeFailureCode::RepositoryPathUnsafe
17205 }
17206 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17207 WorkspaceFileReadErrorKind::NotRegularFile => {
17208 NodeFailureCode::RepositoryFileNotRegular
17209 }
17210 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17211 NodeFailureCode::RepositoryFileReadFailed
17212 }
17213 WorkspaceFileReadErrorKind::RevisionConflict => {
17214 NodeFailureCode::RepositoryFileRevisionConflict
17215 }
17216 WorkspaceFileReadErrorKind::AlreadyExists
17217 | WorkspaceFileReadErrorKind::ParentNotFound
17218 | WorkspaceFileReadErrorKind::ParentNotDirectory
17219 | WorkspaceFileReadErrorKind::Canceled => {
17220 NodeFailureCode::RepositoryFileReadFailed
17221 }
17222 };
17223 failure(code, "repository file read failed")
17224}
17225
17226fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17227 let code = match error.kind() {
17228 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17229 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17230 NodeFailureCode::RepositoryPathUnsafe
17231 }
17232 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17233 WorkspaceFileReadErrorKind::NotRegularFile => {
17234 NodeFailureCode::RepositoryFileNotRegular
17235 }
17236 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17237 NodeFailureCode::RepositoryFileWriteFailed
17238 }
17239 WorkspaceFileReadErrorKind::RevisionConflict => {
17240 NodeFailureCode::RepositoryFileRevisionConflict
17241 }
17242 WorkspaceFileReadErrorKind::AlreadyExists
17243 | WorkspaceFileReadErrorKind::ParentNotFound
17244 | WorkspaceFileReadErrorKind::ParentNotDirectory
17245 | WorkspaceFileReadErrorKind::Canceled => {
17246 NodeFailureCode::RepositoryFileWriteFailed
17247 }
17248 };
17249 failure(code, "repository file write failed")
17250}
17251
17252async fn settle_workspace_entry_create<T>(
17253 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17254 commit_state: Arc<AtomicU8>,
17255 deadline: Duration,
17256 timeout_message: &'static str,
17257 task_failure_message: &'static str,
17258) -> Result<T, NodeFailure> {
17259 let joined = match timeout(deadline, &mut task).await {
17260 Ok(joined) => joined,
17261 Err(_) => {
17262 if commit_state
17263 .compare_exchange(
17264 WORKSPACE_ENTRY_CREATE_PENDING,
17265 WORKSPACE_ENTRY_CREATE_CANCELED,
17266 Ordering::AcqRel,
17267 Ordering::Acquire,
17268 )
17269 .is_ok()
17270 {
17271 return Err(failure(
17272 NodeFailureCode::RepositoryEntryCreateTimedOut,
17273 timeout_message,
17274 ));
17275 }
17276 debug_assert_eq!(
17277 commit_state.load(Ordering::Acquire),
17278 WORKSPACE_ENTRY_CREATE_COMMITTING,
17279 );
17280 task.await
17281 }
17282 };
17283 joined
17284 .map_err(|_| {
17285 failure(
17286 NodeFailureCode::RepositoryEntryCreateFailed,
17287 task_failure_message,
17288 )
17289 })?
17290 .map_err(workspace_entry_create_failure)
17291}
17292
17293fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17294 let code = match error.kind() {
17295 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17296 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17297 NodeFailureCode::RepositoryPathUnsafe
17298 }
17299 WorkspaceFileReadErrorKind::AlreadyExists => {
17300 NodeFailureCode::RepositoryEntryAlreadyExists
17301 }
17302 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17303 NodeFailureCode::RepositoryParentNotFound
17304 }
17305 WorkspaceFileReadErrorKind::ParentNotDirectory
17306 | WorkspaceFileReadErrorKind::NotRegularFile => {
17307 NodeFailureCode::RepositoryParentNotDirectory
17308 }
17309 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17310 NodeFailureCode::RepositoryEntryCreateFailed
17311 }
17312 WorkspaceFileReadErrorKind::RevisionConflict => {
17313 NodeFailureCode::RepositoryEntryCreateFailed
17314 }
17315 WorkspaceFileReadErrorKind::Canceled => {
17316 NodeFailureCode::RepositoryEntryCreateTimedOut
17317 }
17318 };
17319 failure(code, "repository entry create failed")
17320}
17321
17322fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17323 let value = digest(&SHA256, bytes)
17324 .as_ref()
17325 .iter()
17326 .map(|byte| format!("{byte:02x}"))
17327 .collect();
17328 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17329}
17330
17331fn managed_spawn_request_digest_v2(
17332 request: &ManagedWorktreeSpawnRequestV2,
17333) -> Result<String, NodeFailure> {
17334 let encoded = serde_json::to_vec(request).map_err(|_| {
17335 failure(
17336 NodeFailureCode::InvalidRequest,
17337 "managed worktree V2 request could not be canonicalized",
17338 )
17339 })?;
17340 let mut value = String::with_capacity(71);
17341 value.push_str("sha256:");
17342 for byte in digest(&SHA256, &encoded).as_ref() {
17343 use std::fmt::Write as _;
17344 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17345 }
17346 Ok(value)
17347}
17348
17349fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17350 let code = match error {
17351 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17352 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17353 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17354 NativeSessionPreviewError::UnsupportedProvider
17355 | NativeSessionPreviewError::PreviewUnavailable
17356 | NativeSessionPreviewError::SessionNotFound
17357 | NativeSessionPreviewError::AmbiguousSession => {
17358 NodeFailureCode::BackendOperationFailed
17359 }
17360 };
17361 failure(code, "native session preview failed")
17362}
17363
17364fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17365 let code = match error.kind() {
17366 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17367 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17368 };
17369 failure(code, "host directory browse failed")
17370}
17371
17372fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17373 let code = match error {
17374 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17375 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17376 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17377 NodeFailureCode::DeliveryStageConflict
17378 }
17379 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17380 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17381 NodeFailureCode::DeliveryChunkOutOfOrder
17382 }
17383 DeliveryStoreError::BlobDigestMismatch => {
17384 NodeFailureCode::DeliveryBlobDigestMismatch
17385 }
17386 DeliveryStoreError::BundleDigestMismatch => {
17387 NodeFailureCode::DeliveryBundleDigestMismatch
17388 }
17389 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17390 DeliveryStoreError::Unavailable
17391 | DeliveryStoreError::Corrupt
17392 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17393 };
17394 failure(code, "delivery operation failed")
17395}
17396
17397fn validate_workspace_root(
17398 workspace_id: &WorkspaceId,
17399 canonical_root: &str,
17400) -> Result<(), NodeServerError> {
17401 #[cfg(windows)]
17402 if canonical_root.starts_with(r"\\") {
17403 return Err(NodeServerError::InvalidWorkspaceRoot {
17404 workspace_id: workspace_id.clone(),
17405 path: canonical_root.to_owned(),
17406 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17407 });
17408 }
17409 if !platform::workspace_root_supported(canonical_root)
17410 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17411 {
17412 return Err(NodeServerError::InvalidWorkspaceRoot {
17413 workspace_id: workspace_id.clone(),
17414 path: canonical_root.to_owned(),
17415 message: "canonical path must be absolute, bounded, and free of control characters"
17416 .to_owned(),
17417 });
17418 }
17419 Ok(())
17420}
17421
17422fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17423 NodeFailure {
17424 code,
17425 message: node_failure_category(code).to_owned(),
17426 }
17427}
17428
17429fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17430 let code = match error {
17431 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17432 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17433 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17434 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17435 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17436 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17437 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17438 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17439 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17440 };
17441 failure(code, "harness MCP proxy operation failed")
17442}
17443
17444static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17448
17449fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17456 let trace = launch.trace.as_ref()?;
17457 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17458 if trace_dir.is_empty() {
17459 return None;
17460 }
17461 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17462 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17463 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17464}
17465
17466fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17467 let remaining = deadline.saturating_duration_since(Instant::now());
17468 if remaining.is_zero() {
17469 Err(failure(
17470 NodeFailureCode::SpawnDeadlineExceeded,
17471 "spawn deadline elapsed",
17472 ))
17473 } else {
17474 Ok(remaining)
17475 }
17476}
17477
17478fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17479 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17480 match deadline {
17481 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17482 None => Ok(bounded),
17483 }
17484}
17485
17486fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17487 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17488 failure(
17489 NodeFailureCode::SpawnDeadlineExceeded,
17490 "spawn dispatch exceeded the spawn deadline",
17491 )
17492 } else {
17493 error
17494 }
17495}
17496
17497fn spawn_runtime_capabilities(
17498 capabilities: &SpawnRequiredCapabilities,
17499) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17500 capabilities
17501 .iter()
17502 .map(|capability| match capability.as_str() {
17503 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17504 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17505 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17506 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17507 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17508 }
17509 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17510 _ => Err(failure(
17511 NodeFailureCode::UnsupportedSpawnCapability,
17512 "spawn required capability is unknown",
17513 )),
17514 })
17515 .collect()
17516}
17517
17518fn persistence_failure(_error: io::Error) -> NodeFailure {
17519 NodeFailure {
17520 code: NodeFailureCode::BackendOperationFailed,
17521 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17522 }
17523}
17524
17525fn node_failure_category(code: NodeFailureCode) -> &'static str {
17526 match code {
17527 NodeFailureCode::InvalidRequest => "invalid-request",
17528 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17529 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17530 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17531 NodeFailureCode::ReservationConflict => "reservation-conflict",
17532 NodeFailureCode::ReservationExpired => "reservation-expired",
17533 NodeFailureCode::BindingMismatch => "binding-mismatch",
17534 NodeFailureCode::NotActivated => "not-activated",
17535 NodeFailureCode::CallNotFound => "call-not-found",
17536 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17537 NodeFailureCode::ResponseTooLarge => "response-too-large",
17538 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17539 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17540 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17541 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17542 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17543 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17544 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17545 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17546 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17547 NodeFailureCode::Unauthorized => "unauthorized",
17548 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17549 NodeFailureCode::ControllerBusy => "controller-busy",
17550 NodeFailureCode::ControllerRequired => "controller-required",
17551 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17552 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17553 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17554 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17555 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17556 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17557 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17558 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17559 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17560 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17561 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17562 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17563 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17564 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17565 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17566 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17567 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17568 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17569 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17570 NodeFailureCode::GitReadFailed => "git-read-failed",
17571 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17572 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17573 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17574 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17575 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17576 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17577 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17578 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17579 NodeFailureCode::TurnInFlight => "turn-in-flight",
17580 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17581 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17582 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17583 "unsupported-network-allowlist-mapping"
17584 }
17585 NodeFailureCode::BrowserStationProbeUnavailable => {
17586 "browser-station-probe-unavailable"
17587 }
17588 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17589 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17590 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17591 "environment-profile-binding-mismatch"
17592 }
17593 NodeFailureCode::UnknownBundle => "unknown-bundle",
17594 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17595 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17596 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17597 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17598 NodeFailureCode::ContextPackMaterializationFailed => {
17599 "context-pack-materialization-failed"
17600 }
17601 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17602 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17603 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17604 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17605 NodeFailureCode::LastWorkspace => "last-workspace",
17606 NodeFailureCode::NotGitRepository => "not-git-repository",
17607 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17608 NodeFailureCode::WorktreeProtected => "worktree-protected",
17609 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17610 NodeFailureCode::WorktreeLocked => "worktree-locked",
17611 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17612 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17613 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17614 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17615 "managed-worktree-profile-revision-mismatch"
17616 }
17617 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17618 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17619 "standalone-workspace-recovery-required"
17620 }
17621 NodeFailureCode::UnknownSession => "unknown-session",
17622 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17623 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17624 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17625 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17626 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17627 NodeFailureCode::WorkspaceRegistrationRequired => {
17628 "workspace-registration-required"
17629 }
17630 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17631 NodeFailureCode::StaleGeneration => "stale-generation",
17632 NodeFailureCode::BackendBusy => "backend-busy",
17633 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17634 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17635 NodeFailureCode::ShuttingDown => "shutting-down",
17636 }
17637}
17638
17639fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17640 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17641}
17642
17643fn durable_state_load_error(error: io::Error) -> NodeServerError {
17644 let category = match session_registry::state_load_refusal(&error) {
17645 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17646 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17647 }
17648 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17649 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17650 }
17651 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17652 DURABLE_STATE_CONFLICT_ERROR
17653 }
17654 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17655 };
17656 durable_state_server_error(error, category)
17657}
17658
17659fn unix_time_ms() -> u64 {
17660 std::time::SystemTime::now()
17661 .duration_since(std::time::UNIX_EPOCH)
17662 .unwrap_or_default()
17663 .as_millis()
17664 .min(u64::MAX as u128) as u64
17665}
17666
17667fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17668 if record.provider_session.is_some() {
17669 ManagedSessionState::Dormant
17670 } else {
17671 ManagedSessionState::Unavailable
17672 }
17673}
17674
17675fn active_record_state(
17676 identity_admitted: bool,
17677 provider_session_is_known: bool,
17678) -> ManagedSessionState {
17679 if identity_admitted && !provider_session_is_known {
17680 ManagedSessionState::IdentityPending
17681 } else {
17682 ManagedSessionState::Live
17683 }
17684}
17685
17686fn managed_worktree_record_holders(
17687 records: &[ManagedSessionRecord],
17688) -> Vec<(SessionRecordId, WorkspaceId)> {
17689 records
17690 .iter()
17691 .filter(|record| record.provider_session.is_some())
17692 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17693 .collect()
17694}
17695
17696fn managed_spawn_record_ownership(
17697 binding: &SessionBinding,
17698 effective_runtime_policy: ProviderRuntimePolicy,
17699) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17700 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17701 if effective_runtime_policy.provider_session_identity {
17702 (binding.record_id.clone(), None)
17703 } else {
17704 (None, binding.record_id.clone())
17705 }
17706}
17707
17708#[derive(Debug, Error)]
17709pub enum NodeServerError {
17710 #[error("node endpoint must be a bounded absolute local transport path")]
17711 InvalidEndpoint,
17712 #[error("node access token must contain 1..=4096 bytes")]
17713 InvalidAccessToken,
17714 #[error("node HTTP observer must listen on a loopback address: {0}")]
17715 InvalidApiListen(std::net::SocketAddr),
17716 #[error("node browser bridge must listen on a loopback address: {0}")]
17717 InvalidBridgeListen(std::net::SocketAddr),
17718 #[error("node browser bridge token must contain 1..=4096 bytes")]
17719 InvalidBridgeToken,
17720 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17721 BridgeUnderlayRequiresBridgeListen,
17722 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17723 BridgeUnderlayRequiresBridgeToken,
17724 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17725 InvalidBridgeUnderlayToken,
17726 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17727 InvalidBridgeUnderlayKey,
17728 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17729 InvalidCallHome(std::net::SocketAddr),
17730 #[error("node could not reach its call-home relay: {0}")]
17731 CallHomeTransport(#[source] std::io::Error),
17732 #[error("node durable state path must be an absolute file path: {0}")]
17733 InvalidStatePath(String),
17734 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17735 InvalidHarnessMcpHelper,
17736 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17737 InvalidNetworkAllowlistCatalogPath(String),
17738 #[error("network allowlist catalog could not be read: {0}")]
17739 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17740 #[error("network allowlist catalog line {line}: {message}")]
17741 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17742 #[error("network allowlist catalog: {0}")]
17743 InvalidNetworkAllowlistCatalog(String),
17744 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17745 NetworkAllowlistCatalogCapacity { max: usize },
17746 #[error("the local state directory is unavailable; supply an explicit state path")]
17747 LocalStateDirectoryUnavailable,
17748 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17749 LocalRuntimeDirectoryUnavailable,
17750 #[error("node durable state failed: {0}")]
17751 DurableState(io::Error),
17752 #[error("node requires at least one configured workspace")]
17753 NoWorkspaces,
17754 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17755 InvalidWorkspaceRoot {
17756 workspace_id: WorkspaceId,
17757 path: String,
17758 message: String,
17759 },
17760 #[error("duplicate workspace ID: {0}")]
17761 DuplicateWorkspaceId(WorkspaceId),
17762 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17763 DuplicateWorkspaceRoot {
17764 first: WorkspaceId,
17765 second: WorkspaceId,
17766 root: String,
17767 },
17768 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17769 ManagedWorktreeProfileRequired(WorkspaceId),
17770 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17771 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17772 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17773 DuplicateManagedWorktreeProfile {
17774 workspace_id: WorkspaceId,
17775 profile_id: WorktreeProfileId,
17776 },
17777 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17778 ManagedWorktreeProfileCapacity {
17779 workspace_id: WorkspaceId,
17780 max: usize,
17781 },
17782 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17783 InvalidManagedWorktreeProfile {
17784 workspace_id: WorkspaceId,
17785 profile_id: WorktreeProfileId,
17786 message: String,
17787 },
17788 #[error("duplicate node environment profile: {0}")]
17789 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17790 #[error("node environment profile provider is unavailable: {0}")]
17791 EnvironmentProfileProviderUnavailable(AgentId),
17792 #[error("node environment profile capacity is {max}")]
17793 EnvironmentProfileCapacity { max: usize },
17794 #[error("node environment profiles reuse native profile ID '{0}'")]
17795 DuplicateNativeEnvironmentProfile(String),
17796 #[error("node native environment profile is invalid: {0}")]
17797 NativeEnvironmentProfile(String),
17798 #[error("node session-environment materialization root must be absolute")]
17799 InvalidSessionEnvironmentRoot,
17800 #[error("node session-environment materializer is unavailable")]
17801 SessionEnvironmentMaterializerRequired,
17802 #[error("node session-environment materializer failed to initialize")]
17803 SessionEnvironmentMaterializer,
17804 #[error("node bundle catalog is invalid: {0}")]
17805 BundleCatalog(String),
17806 #[error("node delivery store failed to initialize: {0}")]
17807 DeliveryStore(String),
17808 #[error("node context pack store failed to initialize")]
17809 ContextPackStore,
17810 #[error("active agent registry failed: {0}")]
17811 Registry(String),
17812 #[error("node provider contract manifest is invalid: {0}")]
17813 ProviderContractManifest(String),
17814 #[error("named pipe I/O failed: {0}")]
17815 Io(#[from] io::Error),
17816 #[error("node HTTP observer failed: {0}")]
17817 HttpApi(io::Error),
17818 #[error("node browser bridge failed: {0}")]
17819 BridgeApi(io::Error),
17820 #[error(transparent)]
17821 Frame(#[from] FrameError),
17822 #[error("node handshake failed: {0}")]
17823 Handshake(String),
17824 #[error("node authentication frame was not received before the bounded deadline")]
17825 AuthenticationTimedOut,
17826 #[error("node authentication primitive failed: {0}")]
17827 Authentication(String),
17828 #[error("node incarnation identity generation failed: {0}")]
17829 IncarnationIdentity(String),
17830 #[error("node pre-authentication limiter is closed")]
17831 PreauthClosed,
17832 #[error("node authenticated connection limit was reached")]
17833 AuthenticatedConnectionLimit,
17834 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17835 SignalStreamClosed,
17836 #[error("node stop-all dispatch failed: {0:?}")]
17837 ShutdownDispatch(NodeFailure),
17838 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17839 ShutdownTimedOut { active_native_sessions: usize },
17840}
17841
17842impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17843 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17844 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17845 match error {
17846 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17847 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17848 E::InvalidEntry { line, message } => {
17849 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17850 }
17851 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17852 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17853 }
17854 }
17855}
17856
17857#[cfg(test)]
17858mod agent_stream_blocked_tests {
17859 use super::*;
17860 use gate4agent_types::{
17861 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17862 ProviderSource,
17863 };
17864
17865 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17866 ProviderSource {
17867 family,
17868 binding: AdapterBinding::new(
17869 AdapterId::new(adapter).unwrap(),
17870 "fixture/v1",
17871 AdapterVerification::SyntheticFixture,
17872 )
17873 .unwrap(),
17874 }
17875 }
17876
17877 fn provider_control_event_at(
17878 family: AdapterFamily,
17879 adapter: &str,
17880 source_sequence: u64,
17881 provider_event: ProviderEvent,
17882 ) -> ControlEvent {
17883 ControlEvent {
17884 sequence: 41,
17885 command_id: None,
17886 instance_id: AgentInstanceId(7),
17887 generation: SessionGeneration(3),
17888 event: ControlEventKind::ProviderEvent {
17889 sequence: 8,
17890 source: provider_source(family, adapter),
17891 source_sequence,
17892 event: provider_event,
17893 },
17894 }
17895 }
17896
17897 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17898 provider_control_event_at(family, "fixture", 9, provider_event)
17899 }
17900
17901 fn denied_host_request(
17902 by: ProviderHostDecisionAuthority,
17903 reason: Option<String>,
17904 ) -> ControlEvent {
17905 provider_control_event_at(
17906 AdapterFamily::Acp,
17907 "claude-code",
17908 2,
17909 ProviderEvent::HostRequestObserved {
17910 method: "terminal/create".to_owned(),
17911 params_json: String::new(),
17912 decision: ProviderHostRequestDecision::Denied { by },
17913 outcome: ProviderHostRequestOutcome::Executed,
17914 reason,
17915 },
17916 )
17917 }
17918
17919 fn blocked(
17920 event: &ControlEvent,
17921 ) -> (
17922 Option<String>,
17923 String,
17924 BlockAuthorityV1,
17925 Option<String>,
17926 String,
17927 Option<String>,
17928 ) {
17929 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17930 chunk.validate().expect("minted chunk must validate");
17931 let AgentStreamChunkKindV1::Blocked {
17932 correlation_id,
17933 tool_class,
17934 authority,
17935 reason_kind,
17936 reason,
17937 help,
17938 } = chunk.kind
17939 else {
17940 panic!("expected AgentStreamChunkKindV1::Blocked");
17941 };
17942 (correlation_id, tool_class, authority, reason_kind, reason, help)
17943 }
17944
17945 #[test]
17949 fn the_enabled_provider_matrix_is_exact() {
17950 let registry = active_registry().unwrap();
17951 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17952 assert_eq!(
17953 providers
17954 .iter()
17955 .map(|contract| contract.provider.as_str())
17956 .collect::<Vec<_>>(),
17957 vec!["shell", "claude", "codex", "grok", "kimi"],
17959 );
17960 assert!(adapters.iter().all(|contract| {
17961 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17962 }));
17963 }
17964
17965 #[test]
17968 fn gate_denied_host_request_mints_a_blocked_chunk() {
17969 let gate_text =
17970 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17971 let event = denied_host_request(
17972 ProviderHostDecisionAuthority::Gate,
17973 Some(gate_text.to_owned()),
17974 );
17975 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17976 assert_eq!(chunk.source_sequence, 2);
17977 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17978 assert_eq!(authority, BlockAuthorityV1::HostGate);
17979 assert_eq!(reason, gate_text);
17980 assert_eq!(correlation_id, None);
17981 assert_eq!(reason_kind, None);
17982 assert_eq!(help, None);
17983 assert_eq!(tool_class, "Write");
17984 }
17985
17986 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.";
17987
17988 #[test]
17994 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17995 let event = provider_control_event(
17996 AdapterFamily::Acp,
17997 ProviderEvent::ToolCompleted {
17998 id: "toolu_9".to_owned(),
17999 output: CLASSIFIER_SAMPLE.to_owned(),
18000 is_error: true,
18001 duration_ms: None,
18002 agent_id: None,
18003 non_execution_kind: Some("permission-rule".to_owned()),
18004 },
18005 );
18006 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18007 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18008 assert_eq!(reason, "Blocked by classifier");
18009 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18010 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18011 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18012 }
18013
18014 #[test]
18019 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18020 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.";
18021 let event = provider_control_event(
18022 AdapterFamily::Acp,
18023 ProviderEvent::SessionEnded {
18024 result: "JSON-RPC error -32603: Internal error".to_owned(),
18025 cost_usd: None,
18026 is_error: true,
18027 stop_reason: Some(ProviderStopReason::ProviderError {
18028 code: -32603,
18029 message: message.to_owned(),
18030 vendor_code: Some("usageLimitExceeded".to_owned()),
18031 }),
18032 },
18033 );
18034 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18035 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18036 assert_eq!(reason, message);
18037 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18038 assert_eq!(tool_class, "Session");
18039 }
18040
18041 #[test]
18044 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18045 let event = provider_control_event(
18046 AdapterFamily::Acp,
18047 ProviderEvent::SessionEnded {
18048 result: "refusal".to_owned(),
18049 cost_usd: None,
18050 is_error: true,
18051 stop_reason: Some(ProviderStopReason::Refusal),
18052 },
18053 );
18054 let (_, _, authority, _, _, _) = blocked(&event);
18055 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18056 }
18057
18058 #[test]
18062 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18063 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18064 let event = provider_control_event(
18065 AdapterFamily::Acp,
18066 ProviderEvent::ToolCompleted {
18067 id: "toolu_1".to_owned(),
18068 output: "command exited with status 1".to_owned(),
18069 is_error: true,
18070 duration_ms: Some(12),
18071 agent_id: None,
18072 non_execution_kind,
18073 },
18074 );
18075 assert!(agent_stream_chunk(&event).is_none());
18076 }
18077 let narrative = provider_control_event(
18078 AdapterFamily::Acp,
18079 ProviderEvent::ToolCompleted {
18080 id: "toolu_2".to_owned(),
18081 output: "the harness blocked the write, approval needed".to_owned(),
18082 is_error: false,
18083 duration_ms: Some(3),
18084 agent_id: None,
18085 non_execution_kind: None,
18086 },
18087 );
18088 assert!(agent_stream_chunk(&narrative).is_none());
18089 }
18090
18091 #[test]
18095 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18096 for (decision, outcome) in [
18097 (
18098 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18099 ProviderHostRequestOutcome::Executed,
18100 ),
18101 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18102 (
18103 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18104 ProviderHostRequestOutcome::Failed {
18105 error: "terminal/create spawn failed: os error 3".to_owned(),
18106 },
18107 ),
18108 ] {
18109 let event = provider_control_event_at(
18110 AdapterFamily::Acp,
18111 "claude-code",
18112 3,
18113 ProviderEvent::HostRequestObserved {
18114 method: "fs/read_text_file".to_owned(),
18115 params_json: String::new(),
18116 decision: decision.clone(),
18117 outcome,
18118 reason: None,
18119 },
18120 );
18121 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18122 }
18123 }
18124
18125 #[test]
18129 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18130 for (by, expected_authority, expected_reason) in [
18131 (
18132 ProviderHostDecisionAuthority::Operator,
18133 BlockAuthorityV1::Operator,
18134 "declined by the operator",
18135 ),
18136 (
18137 ProviderHostDecisionAuthority::DeadlinePolicy,
18138 BlockAuthorityV1::HostDeadline,
18139 "denied by host policy after the operator deadline expired",
18140 ),
18141 ] {
18142 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18143 assert_eq!(authority, expected_authority);
18144 assert_eq!(reason, expected_reason);
18145 }
18146 }
18147
18148 #[test]
18152 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18153 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18154 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18155 ProviderHostDecisionAuthority::Policy,
18156 Some(oversized),
18157 ));
18158 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18159 }
18160}
18161
18162#[cfg(test)]
18163mod standalone_capability_platform_tests {
18164 use super::*;
18165
18166 #[test]
18167 fn standalone_workspace_capability_requires_windows_identity_guards() {
18168 let capabilities = baseline_capabilities().unwrap();
18169 let advertised = capabilities.iter().any(|capability| {
18170 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18171 });
18172 assert_eq!(advertised, cfg!(windows));
18173
18174 let request = NodeRequest::CreateStandaloneWorkspace {
18175 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18176 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18177 initial_branch: None,
18178 };
18179 assert_eq!(
18180 request_uses_unnegotiated_capability(&request, &capabilities),
18181 !cfg!(windows),
18182 );
18183 }
18184}
18185
18186
18187#[cfg(test)]
18188mod station_knobs_resolve_tests {
18189 use super::*;
18190 use gate4agent_catalog::EnvMutation;
18191 use gate4agent_runtime_native::{
18192 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18193 NativeLaunchProfileId,
18194 };
18195 use std::ffi::OsString;
18196 use std::sync::Arc;
18197
18198 struct EmptyEnvironmentResolver;
18199
18200 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18201 fn resolve_child_environment(
18202 &self,
18203 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18204 Ok(vec![EnvMutation {
18205 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18206 value: None,
18207 }])
18208 }
18209 }
18210
18211 fn agent(value: &str) -> AgentId {
18212 AgentId::new(value).unwrap()
18213 }
18214
18215 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18216 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18217 let profile_revision =
18218 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18219 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18220 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18221 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18222 provider: agent("claude"),
18223 mode: SessionMode::Pty,
18224 terminal_size: gate4agent_types::TerminalSize {
18225 rows: 24,
18226 columns: 80,
18227 },
18228 prompt: None,
18229 bundle_id: None,
18230 context_id: None,
18231 environment_profile_id: Some(profile_id.clone()),
18232 };
18233 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18234 let catalog = active_registry().unwrap();
18235 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18236 let control = runtime.native_launch_profile_control();
18237 let node_profile = NodeEnvironmentProfile::new(
18238 profile_id.clone(),
18239 profile_revision,
18240 agent("claude"),
18241 [NativeLaunchProfile::new(
18242 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18243 agent("claude"),
18244 TransportKind::Pty,
18245 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18246 Arc::new(EmptyEnvironmentResolver),
18247 )
18248 .unwrap()],
18249 )
18250 .unwrap();
18251 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18252 for native_profile in native_profiles {
18253 runtime.upsert_native_launch_profile(native_profile).unwrap();
18254 }
18255 let workspace = WorkspaceConfig::new(
18256 WorkspaceId::new("primary").unwrap(),
18257 std::env::current_dir().unwrap(),
18258 )
18259 .unwrap();
18260 let shared = NodeShared::new_with_incarnation(
18261 handle,
18262 "fixture-token".to_owned(),
18263 NodeId::new("node-station-knobs-test").unwrap(),
18264 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18265 vec![workspace],
18266 vec![agent("claude")],
18267 ProviderRuntimeStatuses::default(),
18268 None,
18269 Vec::new(),
18270 Vec::new(),
18271 spawn_profiles,
18272 Some(control),
18273 None,
18274 None,
18275 None,
18276 Vec::new(),
18277 Vec::new(),
18278 Vec::new(),
18279 Vec::new(),
18280 None,
18281 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18282 );
18283 shared
18284 .environment_profiles
18285 .write()
18286 .unwrap_or_else(|poisoned| poisoned.into_inner())
18287 .insert(binding.id.clone(), binding);
18288 (shared, spawn_defaults)
18289 }
18290
18291 #[test]
18294 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18295 let (shared, defaults) = station_knobs_fixture();
18296 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18297 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18298 let spec = crate::protocol::SpawnSpec {
18299 target: crate::protocol::SpawnTarget {
18300 node_id: shared.node_id.clone(),
18301 workspace_id: WorkspaceId::new("primary").unwrap(),
18302 worktree_id: None,
18303 },
18304 profile_id: defaults.profile_id.clone(),
18305 expected_profile_revision: defaults.revision.clone(),
18306 overrides: crate::protocol::SpawnOverrides {
18307 network_allowlist: Some(unknown),
18308 browser_profile_id: Some(browser),
18309 ..crate::protocol::SpawnOverrides::default()
18310 },
18311 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18312 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18313 "station-knobs-unknown-allowlist",
18314 )
18315 .unwrap(),
18316 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18317 };
18318 let resolved = spec.resolve(&defaults).unwrap();
18319 let failure = shared
18320 .resolve_environment_profile(&resolved)
18321 .expect_err("unknown allowlist must refuse");
18322 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18323 }
18324
18325 #[test]
18331 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18332 let (shared, defaults) = station_knobs_fixture();
18333 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18334 #[cfg(not(feature = "dig2-station-probe"))]
18335 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18336 shared
18337 .network_allowlist_catalog
18338 .write()
18339 .unwrap_or_else(|poisoned| poisoned.into_inner())
18340 .insert_id(allowlist.clone())
18341 .unwrap();
18342 let spec = crate::protocol::SpawnSpec {
18343 target: crate::protocol::SpawnTarget {
18344 node_id: shared.node_id.clone(),
18345 workspace_id: WorkspaceId::new("primary").unwrap(),
18346 worktree_id: None,
18347 },
18348 profile_id: defaults.profile_id.clone(),
18349 expected_profile_revision: defaults.revision.clone(),
18350 overrides: crate::protocol::SpawnOverrides {
18351 network_allowlist: Some(allowlist.clone()),
18352 #[cfg(not(feature = "dig2-station-probe"))]
18353 browser_profile_id: Some(browser.clone()),
18354 ..crate::protocol::SpawnOverrides::default()
18355 },
18356 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18357 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18358 .unwrap(),
18359 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18360 };
18361 let resolved = spec.resolve(&defaults).unwrap();
18362 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18363 #[cfg(not(feature = "dig2-station-probe"))]
18364 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18365 let receipt = shared
18366 .resolve_environment_profile(&resolved)
18367 .expect("resolve ok")
18368 .expect("env profile present");
18369 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18370 #[cfg(not(feature = "dig2-station-probe"))]
18371 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18372 #[cfg(feature = "dig2-station-probe")]
18373 assert_eq!(receipt.browser_profile_id, None);
18374 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18375
18376 let omitted = crate::protocol::SpawnSpec {
18377 overrides: crate::protocol::SpawnOverrides::default(),
18378 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18379 .unwrap(),
18380 ..spec
18381 };
18382 let resolved_omit = omitted.resolve(&defaults).unwrap();
18383 let bare = shared
18384 .resolve_environment_profile(&resolved_omit)
18385 .expect("resolve ok")
18386 .expect("env profile present");
18387 assert_eq!(bare.network_allowlist, None);
18388 assert_eq!(bare.browser_profile_id, None);
18389 }
18390
18391 #[cfg(feature = "dig2-station-probe")]
18396 #[test]
18397 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18398 let (shared, defaults) = station_knobs_fixture();
18399 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18400 let spec = crate::protocol::SpawnSpec {
18401 target: crate::protocol::SpawnTarget {
18402 node_id: shared.node_id.clone(),
18403 workspace_id: WorkspaceId::new("primary").unwrap(),
18404 worktree_id: None,
18405 },
18406 profile_id: defaults.profile_id.clone(),
18407 expected_profile_revision: defaults.revision.clone(),
18408 overrides: crate::protocol::SpawnOverrides {
18409 browser_profile_id: Some(browser),
18410 ..crate::protocol::SpawnOverrides::default()
18411 },
18412 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18413 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18414 "station-knobs-probe-refuse",
18415 )
18416 .unwrap(),
18417 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18418 };
18419 let resolved = spec.resolve(&defaults).unwrap();
18420 let failure = shared
18421 .resolve_environment_profile(&resolved)
18422 .expect_err("browser_profile with failing probe must refuse");
18423 #[cfg(windows)]
18424 {
18425 assert!(
18426 matches!(
18427 failure.code,
18428 NodeFailureCode::BrowserStationUnreachable
18429 | NodeFailureCode::BrowserStationProbeUnavailable
18430 ),
18431 "unexpected code {:?}",
18432 failure.code
18433 );
18434 }
18435 #[cfg(not(windows))]
18436 {
18437 assert_eq!(
18438 failure.code,
18439 NodeFailureCode::BrowserStationProbeUnavailable
18440 );
18441 }
18442 }
18443
18444 #[cfg(feature = "dig2-station-probe")]
18446 #[test]
18447 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18448 let (shared, defaults) = station_knobs_fixture();
18449 let spec = crate::protocol::SpawnSpec {
18450 target: crate::protocol::SpawnTarget {
18451 node_id: shared.node_id.clone(),
18452 workspace_id: WorkspaceId::new("primary").unwrap(),
18453 worktree_id: None,
18454 },
18455 profile_id: defaults.profile_id.clone(),
18456 expected_profile_revision: defaults.revision.clone(),
18457 overrides: crate::protocol::SpawnOverrides::default(),
18458 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18459 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18460 "station-knobs-probe-omit",
18461 )
18462 .unwrap(),
18463 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18464 };
18465 let resolved = spec.resolve(&defaults).unwrap();
18466 let receipt = shared
18467 .resolve_environment_profile(&resolved)
18468 .expect("omit browser_profile must not probe")
18469 .expect("env profile present");
18470 assert_eq!(receipt.browser_profile_id, None);
18471 }
18472
18473 #[cfg(feature = "dig2-station-probe")]
18477 #[test]
18478 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18479 let (shared, _defaults) = station_knobs_fixture();
18480 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18481 let workspace_id = WorkspaceId::new("primary").unwrap();
18482 let address_a = SessionAddress {
18483 workspace_id: workspace_id.clone(),
18484 session: SessionKey {
18485 instance_id: AgentInstanceId(101),
18486 generation: SessionGeneration(1),
18487 },
18488 };
18489 let address_b = SessionAddress {
18490 workspace_id: workspace_id.clone(),
18491 session: SessionKey {
18492 instance_id: AgentInstanceId(102),
18493 generation: SessionGeneration(1),
18494 },
18495 };
18496 let guard_a = crate::dig2_station_lease::acquire_lease(
18497 &shared.browser_station_leases,
18498 profile.clone(),
18499 address_a.session.instance_id,
18500 1,
18501 )
18502 .expect("first lease");
18503 let busy = crate::dig2_station_lease::acquire_lease(
18504 &shared.browser_station_leases,
18505 profile.clone(),
18506 address_b.session.instance_id,
18507 2,
18508 )
18509 .expect_err("second holder must refuse");
18510 assert!(matches!(
18511 busy,
18512 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18513 ));
18514 shared.bind_session_with_policy(
18516 &address_a,
18517 ProviderRuntimePolicy::raw_pty(),
18518 None,
18519 );
18520 guard_a.retain();
18521 assert!(shared.remove_binding(&address_a).is_some());
18522 crate::dig2_station_lease::acquire_lease(
18523 &shared.browser_station_leases,
18524 profile,
18525 address_b.session.instance_id,
18526 3,
18527 )
18528 .expect("reacquire after clear")
18529 .retain();
18530 assert!(
18531 crate::dig2_station_lease::release_lease_for_holder(
18532 &shared.browser_station_leases,
18533 address_b.session.instance_id,
18534 )
18535 );
18536 }
18537
18538 #[cfg(not(feature = "dig2-station-probe"))]
18541 #[test]
18542 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18543 let (shared, defaults) = station_knobs_fixture();
18544 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18545 let spec = crate::protocol::SpawnSpec {
18546 target: crate::protocol::SpawnTarget {
18547 node_id: shared.node_id.clone(),
18548 workspace_id: WorkspaceId::new("primary").unwrap(),
18549 worktree_id: None,
18550 },
18551 profile_id: defaults.profile_id.clone(),
18552 expected_profile_revision: defaults.revision.clone(),
18553 overrides: crate::protocol::SpawnOverrides {
18554 browser_profile_id: Some(browser.clone()),
18555 ..crate::protocol::SpawnOverrides::default()
18556 },
18557 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18558 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18559 .unwrap(),
18560 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18561 };
18562 let resolved = spec.resolve(&defaults).unwrap();
18563 let receipt = shared
18564 .resolve_environment_profile(&resolved)
18565 .expect("stub resolve")
18566 .expect("env profile");
18567 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18568 }
18569
18570
18571 #[test]
18574 fn network_allowlist_catalog_loads_from_temp_file() {
18575 let unique = std::time::SystemTime::now()
18576 .duration_since(std::time::UNIX_EPOCH)
18577 .unwrap()
18578 .as_nanos();
18579 let path = std::env::temp_dir().join(format!(
18580 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18581 std::process::id(),
18582 ));
18583 std::fs::write(
18584 &path,
18585 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18586 )
18587 .unwrap();
18588 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18589 .expect("temp catalog file must load");
18590 let _ = std::fs::remove_file(&path);
18591 assert_eq!(loaded.len(), 2);
18592 let (shared, defaults) = station_knobs_fixture();
18593 {
18594 let mut catalog = shared
18595 .network_allowlist_catalog
18596 .write()
18597 .unwrap_or_else(|poisoned| poisoned.into_inner());
18598 *catalog = loaded;
18599 }
18600 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18601 let spec = crate::protocol::SpawnSpec {
18602 target: crate::protocol::SpawnTarget {
18603 node_id: shared.node_id.clone(),
18604 workspace_id: WorkspaceId::new("primary").unwrap(),
18605 worktree_id: None,
18606 },
18607 profile_id: defaults.profile_id.clone(),
18608 expected_profile_revision: defaults.revision.clone(),
18609 overrides: crate::protocol::SpawnOverrides {
18610 network_allowlist: Some(allowlist.clone()),
18611 ..crate::protocol::SpawnOverrides::default()
18612 },
18613 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18614 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18615 "station-knobs-catalog-file",
18616 )
18617 .unwrap(),
18618 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18619 };
18620 let resolved = spec.resolve(&defaults).unwrap();
18621 let receipt = shared
18622 .resolve_environment_profile(&resolved)
18623 .expect("registered via file load")
18624 .expect("env profile present");
18625 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18626 }
18627
18628 #[test]
18631 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18632 let (shared, _defaults) = station_knobs_fixture();
18633 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18634 shared
18635 .network_allowlist_catalog
18636 .write()
18637 .unwrap_or_else(|poisoned| poisoned.into_inner())
18638 .insert_id(allowlist.clone())
18639 .unwrap();
18640 let snapshot = shared.snapshot();
18641 let inventory = snapshot
18642 .launch_inventory
18643 .expect("launch inventory present");
18644 let ids = inventory
18645 .network_allowlists
18646 .expect("non-empty catalog surfaces ids");
18647 assert_eq!(ids, vec![allowlist]);
18648 }
18649
18650 #[test]
18654 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18655 let (shared, defaults) = station_knobs_fixture();
18656 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18657 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18658 id: allowlist.clone(),
18659 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18660 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18661 peer: "127.0.0.1:443".to_owned(),
18662 }],
18663 provider_native: Some(
18664 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18665 codex_network_access: Some(true),
18666 },
18667 ),
18668 };
18669 shared
18670 .network_allowlist_catalog
18671 .write()
18672 .unwrap_or_else(|poisoned| poisoned.into_inner())
18673 .insert(entry)
18674 .unwrap();
18675 let spec = crate::protocol::SpawnSpec {
18676 target: crate::protocol::SpawnTarget {
18677 node_id: shared.node_id.clone(),
18678 workspace_id: WorkspaceId::new("primary").unwrap(),
18679 worktree_id: None,
18680 },
18681 profile_id: defaults.profile_id.clone(),
18682 expected_profile_revision: defaults.revision.clone(),
18683 overrides: crate::protocol::SpawnOverrides {
18684 network_allowlist: Some(allowlist),
18685 ..crate::protocol::SpawnOverrides::default()
18686 },
18687 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18688 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18689 "station-knobs-unsupported-mapping",
18690 )
18691 .unwrap(),
18692 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18693 };
18694 let resolved = spec.resolve(&defaults).unwrap();
18695 assert_eq!(resolved.provider.as_str(), "claude");
18696 let failure = shared
18697 .resolve_environment_profile(&resolved)
18698 .expect_err("claude + codex_network_access must refuse");
18699 assert_eq!(
18700 failure.code,
18701 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18702 );
18703 }
18704
18705 #[test]
18709 fn resolve_codex_network_access_overlay_moderate_only() {
18710 let (shared, defaults) = station_knobs_fixture();
18711 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18712 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18713 id: allowlist.clone(),
18714 permits: Vec::new(),
18715 provider_native: Some(
18716 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18717 codex_network_access: Some(true),
18718 },
18719 ),
18720 };
18721 shared
18722 .network_allowlist_catalog
18723 .write()
18724 .unwrap_or_else(|poisoned| poisoned.into_inner())
18725 .insert(entry)
18726 .unwrap();
18727
18728 let overlay = shared
18729 .resolve_codex_network_access_overlay_args(
18730 &agent("codex"),
18731 SessionMode::Pty,
18732 ApprovalLevel::Moderate,
18733 Some(&allowlist),
18734 )
18735 .expect("Moderate Codex maps network_access");
18736 assert_eq!(
18737 overlay,
18738 vec![
18739 std::ffi::OsString::from("-c"),
18740 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18741 ]
18742 );
18743
18744 let acp = shared
18745 .resolve_codex_network_access_overlay_args(
18746 &agent("codex"),
18747 SessionMode::Acp,
18748 ApprovalLevel::Moderate,
18749 Some(&allowlist),
18750 )
18751 .unwrap();
18752 assert_eq!(acp, overlay);
18753
18754 let inline = shared
18755 .resolve_codex_network_access_overlay_args(
18756 &agent("codex"),
18757 SessionMode::Inline,
18758 ApprovalLevel::Moderate,
18759 Some(&allowlist),
18760 )
18761 .unwrap_err();
18762 assert_eq!(
18763 inline.code,
18764 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18765 );
18766
18767 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18768 let spec = crate::protocol::SpawnSpec {
18769 target: crate::protocol::SpawnTarget {
18770 node_id: shared.node_id.clone(),
18771 workspace_id: WorkspaceId::new("primary").unwrap(),
18772 worktree_id: None,
18773 },
18774 profile_id: defaults.profile_id.clone(),
18775 expected_profile_revision: defaults.revision.clone(),
18776 overrides: crate::protocol::SpawnOverrides {
18777 provider: crate::protocol::SpawnOverride::Set {
18778 value: agent("codex"),
18779 },
18780 approval_level: Some(level),
18781 network_allowlist: Some(allowlist.clone()),
18782 ..crate::protocol::SpawnOverrides::default()
18783 },
18784 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18785 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18786 "codex-net-refuse-{}",
18787 match level {
18788 ApprovalLevel::ReadOnly => "ro",
18789 ApprovalLevel::FullAuto => "fa",
18790 _ => "other",
18791 }
18792 ))
18793 .unwrap(),
18794 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18795 };
18796 let resolved = spec.resolve(&defaults).unwrap();
18797 let failure = shared
18798 .resolve_environment_profile(&resolved)
18799 .expect_err("RO/FullAuto must refuse");
18800 assert_eq!(
18801 failure.code,
18802 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18803 );
18804 }
18805 }
18806
18807 #[test]
18811 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18812 let (shared, _defaults) = station_knobs_fixture();
18813 let allowlist =
18814 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18815 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18816 id: allowlist.clone(),
18817 permits: vec![
18818 crate::network_allowlist_catalog::NetworkPermitSketch {
18819 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18820 peer: "github.com:443".to_owned(),
18821 },
18822 crate::network_allowlist_catalog::NetworkPermitSketch {
18823 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18824 peer: "*.npmjs.org".to_owned(),
18825 },
18826 ],
18827 provider_native: None,
18828 };
18829 shared
18830 .network_allowlist_catalog
18831 .write()
18832 .unwrap_or_else(|poisoned| poisoned.into_inner())
18833 .insert(entry)
18834 .unwrap();
18835
18836 let unique = std::time::SystemTime::now()
18837 .duration_since(std::time::UNIX_EPOCH)
18838 .unwrap()
18839 .as_nanos();
18840 let provider_home = std::env::temp_dir().join(format!(
18841 "gate4agent-claude-provider-home-{}-{unique}",
18842 std::process::id(),
18843 ));
18844 std::fs::create_dir_all(&provider_home).unwrap();
18845
18846 let overlay = shared
18847 .resolve_network_allowlist_overlay_args(
18848 &agent("claude"),
18849 SessionMode::Pty,
18850 ApprovalLevel::Moderate,
18851 Some(&allowlist),
18852 Some(provider_home.as_path()),
18853 )
18854 .expect("Claude PTY settings overlay on unix");
18855 let settings_path = provider_home.join(
18856 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18857 );
18858 assert_eq!(
18859 overlay,
18860 vec![
18861 std::ffi::OsString::from("--settings"),
18862 std::ffi::OsString::from(settings_path.as_os_str()),
18863 ]
18864 );
18865 let body = std::fs::read_to_string(&settings_path).unwrap();
18866 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18867 assert_eq!(value["sandbox"]["enabled"], true);
18868 assert_eq!(
18869 value["sandbox"]["network"]["allowedDomains"],
18870 serde_json::json!(["github.com", "*.npmjs.org"])
18871 );
18872 assert!(settings_path.starts_with(&provider_home));
18873
18874 let acp = shared
18875 .resolve_network_allowlist_overlay_args(
18876 &agent("claude"),
18877 SessionMode::Acp,
18878 ApprovalLevel::Moderate,
18879 Some(&allowlist),
18880 Some(provider_home.as_path()),
18881 )
18882 .expect_err("Claude ACP must refuse settings overlay");
18883 assert_eq!(
18884 acp.code,
18885 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18886 );
18887
18888 let no_home = shared
18889 .resolve_network_allowlist_overlay_args(
18890 &agent("claude"),
18891 SessionMode::Pty,
18892 ApprovalLevel::Moderate,
18893 Some(&allowlist),
18894 None,
18895 )
18896 .expect_err("Claude overlay requires ProviderHome");
18897 assert_eq!(
18898 no_home.code,
18899 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18900 );
18901
18902 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18903 shared
18904 .network_allowlist_catalog
18905 .write()
18906 .unwrap_or_else(|poisoned| poisoned.into_inner())
18907 .insert_id(bare.clone())
18908 .unwrap();
18909 let empty = shared
18910 .resolve_network_allowlist_overlay_args(
18911 &agent("claude"),
18912 SessionMode::Pty,
18913 ApprovalLevel::Moderate,
18914 Some(&bare),
18915 Some(provider_home.as_path()),
18916 )
18917 .unwrap();
18918 assert!(empty.is_empty());
18919
18920 let _ = std::fs::remove_dir_all(&provider_home);
18921 }
18922
18923 #[test]
18925 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18926 let (shared, _defaults) = station_knobs_fixture();
18927 let allowlist =
18928 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18929 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18930 id: allowlist.clone(),
18931 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18932 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18933 peer: "example.com:443".to_owned(),
18934 }],
18935 provider_native: None,
18936 };
18937 shared
18938 .network_allowlist_catalog
18939 .write()
18940 .unwrap_or_else(|poisoned| poisoned.into_inner())
18941 .insert(entry)
18942 .unwrap();
18943 for provider in ["kimi", "grok"] {
18944 let overlay = shared
18945 .resolve_network_allowlist_overlay_args(
18946 &agent(provider),
18947 SessionMode::Pty,
18948 ApprovalLevel::Moderate,
18949 Some(&allowlist),
18950 None,
18951 )
18952 .unwrap();
18953 assert!(
18954 overlay.is_empty(),
18955 "{provider} must not invent network argv from permits"
18956 );
18957 }
18958 }
18959
18960 #[test]
18962 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18963 let unique = std::time::SystemTime::now()
18964 .duration_since(std::time::UNIX_EPOCH)
18965 .unwrap()
18966 .as_nanos();
18967 let path = std::env::temp_dir().join(format!(
18968 "gate4agent-node-allowlist-json-{}-{unique}.json",
18969 std::process::id(),
18970 ));
18971 std::fs::write(
18972 &path,
18973 r#"{
18974 "schema_version": 2,
18975 "entries": [
18976 {
18977 "id": "egress-default",
18978 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18979 },
18980 { "id": "lab-egress" }
18981 ]
18982 }"#,
18983 )
18984 .unwrap();
18985 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18986 .expect("json v2 catalog must load");
18987 let _ = std::fs::remove_file(&path);
18988 assert_eq!(loaded.len(), 2);
18989 let entry = loaded
18990 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18991 .unwrap();
18992 assert_eq!(entry.permits.len(), 1);
18993 let (shared, defaults) = station_knobs_fixture();
18994 {
18995 let mut catalog = shared
18996 .network_allowlist_catalog
18997 .write()
18998 .unwrap_or_else(|poisoned| poisoned.into_inner());
18999 *catalog = loaded;
19000 }
19001 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19002 let spec = crate::protocol::SpawnSpec {
19003 target: crate::protocol::SpawnTarget {
19004 node_id: shared.node_id.clone(),
19005 workspace_id: WorkspaceId::new("primary").unwrap(),
19006 worktree_id: None,
19007 },
19008 profile_id: defaults.profile_id.clone(),
19009 expected_profile_revision: defaults.revision.clone(),
19010 overrides: crate::protocol::SpawnOverrides {
19011 network_allowlist: Some(allowlist.clone()),
19012 ..crate::protocol::SpawnOverrides::default()
19013 },
19014 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19015 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19016 "station-knobs-catalog-json",
19017 )
19018 .unwrap(),
19019 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19020 };
19021 let resolved = spec.resolve(&defaults).unwrap();
19022 let receipt = shared
19023 .resolve_environment_profile(&resolved)
19024 .expect("permits-only entry ok for claude")
19025 .expect("env profile present");
19026 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19027 let inventory = shared
19028 .snapshot()
19029 .launch_inventory
19030 .expect("launch inventory present");
19031 let ids = inventory.network_allowlists.expect("ids surface");
19032 assert_eq!(
19033 ids,
19034 vec![
19035 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19036 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19037 ]
19038 );
19039 }
19040
19041
19042}
19043
19044
19045#[cfg(test)]
19046mod workspace_environment_collision_tests {
19047 use super::*;
19048 use crate::session_environment::{
19049 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19050 };
19051 use gate4agent_catalog::EnvMutation;
19052 use gate4agent_runtime_native::{
19053 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19054 NativeLaunchProfileId,
19055 };
19056 use std::ffi::OsString;
19057 use std::sync::Arc;
19058
19059 struct EmptyEnvironmentResolver;
19060
19061 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19062 fn resolve_child_environment(
19063 &self,
19064 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19065 Ok(vec![EnvMutation {
19066 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19067 value: None,
19068 }])
19069 }
19070 }
19071
19072 fn agent(value: &str) -> AgentId {
19073 AgentId::new(value).unwrap()
19074 }
19075
19076 fn claude_config_dir_collision_fixture() -> (
19078 NodeShared,
19079 ResolvedEnvironmentProfileReceipt,
19080 ResolvedEnvironmentProfileReceipt,
19081 ) {
19082 let catalog = active_registry().unwrap();
19083 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19084 let control = runtime.native_launch_profile_control();
19085 let workspace = WorkspaceConfig::new(
19086 WorkspaceId::new("primary").unwrap(),
19087 std::env::current_dir().unwrap(),
19088 )
19089 .unwrap();
19090 let shared = NodeShared::new_with_incarnation(
19091 handle,
19092 "fixture-token".to_owned(),
19093 NodeId::new("node-env-collision-test").unwrap(),
19094 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19095 vec![workspace],
19096 vec![agent("claude")],
19097 ProviderRuntimeStatuses::default(),
19098 None,
19099 Vec::new(),
19100 Vec::new(),
19101 SpawnProfileRegistry::default(),
19102 Some(control),
19103 None,
19104 None,
19105 None,
19106 Vec::new(),
19107 Vec::new(),
19108 Vec::new(),
19109 Vec::new(),
19110 None,
19111 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19112 );
19113
19114 let mut receipts = Vec::new();
19115 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19116 let profile_id =
19117 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19118 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19119 "claude-config-dir-{suffix}-r1"
19120 ))
19121 .unwrap();
19122 let materialization = NodeSessionMaterializationProfile::new(
19123 Vec::new(),
19124 vec![NodeSessionPathBinding::new(
19125 "CLAUDE_CONFIG_DIR",
19126 NodeSessionPathClass::ProviderHome,
19127 )
19128 .unwrap()],
19129 Vec::new(),
19130 )
19131 .unwrap();
19132 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19133 profile_id.clone(),
19134 profile_revision.clone(),
19135 agent("claude"),
19136 [NativeLaunchProfile::new(
19137 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19138 .unwrap(),
19139 agent("claude"),
19140 TransportKind::Pty,
19141 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19142 Arc::new(EmptyEnvironmentResolver),
19143 )
19144 .unwrap()],
19145 Some(materialization),
19146 )
19147 .unwrap();
19148 let (binding, native_profiles, materialization) = node_profile.into_parts();
19149 for native_profile in native_profiles {
19150 runtime.upsert_native_launch_profile(native_profile).unwrap();
19151 }
19152 shared
19153 .environment_profiles
19154 .write()
19155 .unwrap_or_else(|poisoned| poisoned.into_inner())
19156 .insert(binding.id.clone(), binding);
19157 shared
19158 .environment_materialization_profiles
19159 .write()
19160 .unwrap_or_else(|poisoned| poisoned.into_inner())
19161 .insert(profile_id.clone(), materialization.unwrap());
19162 receipts.push(ResolvedEnvironmentProfileReceipt {
19163 profile_id,
19164 profile_revision,
19165 network_allowlist: None,
19166 browser_profile_id: None,
19167 });
19168 }
19169 let profile_b = receipts.pop().unwrap();
19170 let profile_a = receipts.pop().unwrap();
19171 (shared, profile_a, profile_b)
19172 }
19173
19174 #[test]
19175 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19176 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19177 let primary = WorkspaceId::new("primary").unwrap();
19178 let worktree = WorkspaceId::new("worktree-b").unwrap();
19179 let address = SessionAddress {
19180 workspace_id: primary.clone(),
19181 session: SessionKey {
19182 instance_id: AgentInstanceId(41),
19183 generation: SessionGeneration(1),
19184 },
19185 };
19186 shared.bind_session_with_policy(
19187 &address,
19188 ProviderRuntimePolicy::raw_pty(),
19189 Some(profile_a.clone()),
19190 );
19191
19192 let refused = shared
19193 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19194 .unwrap_err();
19195 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19196
19197 let refused_none = shared
19199 .ensure_workspace_environment_profile_compatible(&primary, None)
19200 .unwrap_err();
19201 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19202
19203 shared
19205 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19206 .unwrap();
19207
19208 shared
19210 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19211 .unwrap();
19212 }
19213
19214 #[tokio::test]
19215 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19216 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19217 let primary = WorkspaceId::new("primary").unwrap();
19218 let address = SessionAddress {
19219 workspace_id: primary.clone(),
19220 session: SessionKey {
19221 instance_id: AgentInstanceId(41),
19222 generation: SessionGeneration(1),
19223 },
19224 };
19225 shared.bind_session_with_policy(
19226 &address,
19227 ProviderRuntimePolicy::raw_pty(),
19228 Some(profile_a),
19229 );
19230
19231 let failure = shared
19232 .spawn_session_with_deadline(
19233 primary,
19234 agent("claude"),
19235 SessionMode::Pty,
19236 gate4agent_types::TerminalSize {
19237 rows: 24,
19238 columns: 80,
19239 },
19240 None,
19241 Some(profile_b),
19242 None,
19243 None,
19244 None,
19245 None,
19246 SpawnRecordPolicy::ProviderIdentityOnly,
19247 Some(Instant::now() + Duration::from_secs(30)),
19248 &[],
19249 None,
19250 ApprovalLevel::default(),
19251 None,
19252 None,
19253 )
19254 .await
19255 .unwrap_err();
19256 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19257 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19259 assert!(shared.handle.snapshot().sessions.is_empty());
19260 }
19261}
19262
19263#[cfg(test)]
19264mod spawn_characterization_tests {
19265 use super::*;
19266 use crate::session_environment::{
19267 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19268 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19269 NodeSessionPathClass,
19270 };
19271 use gate4agent_catalog::EnvMutation;
19272 use gate4agent_runtime_native::{
19273 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19274 NativeLaunchProfile, NativeLaunchProfileId,
19275 };
19276 use std::ffi::OsString;
19277 use std::sync::Arc;
19278
19279 struct EmptyEnvironmentResolver;
19280
19281 struct FixtureSecretResolver {
19282 deny: Arc<AtomicBool>,
19283 }
19284
19285 impl NodeSecretResolver for FixtureSecretResolver {
19286 fn resolve(
19287 &self,
19288 _reference: &NodeSecretReference,
19289 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19290 if self.deny.load(Ordering::Acquire) {
19291 Err(NodeSecretResolveError::Denied)
19292 } else {
19293 NodeSecretValue::text("fixture-secret")
19294 .map_err(|_| NodeSecretResolveError::Unavailable)
19295 }
19296 }
19297 }
19298
19299 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19300 fn resolve_child_environment(
19301 &self,
19302 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19303 Ok(vec![EnvMutation {
19304 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19305 value: None,
19306 }])
19307 }
19308 }
19309
19310 fn agent(value: &str) -> AgentId {
19311 AgentId::new(value).unwrap()
19312 }
19313
19314 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19315 let unique = std::time::SystemTime::now()
19316 .duration_since(std::time::UNIX_EPOCH)
19317 .unwrap()
19318 .as_nanos();
19319 std::env::temp_dir().join(format!(
19320 "gate4agent-node-{label}-{}-{unique}",
19321 std::process::id(),
19322 ))
19323 }
19324
19325 fn materialization_test_shared(
19326 deny_secret: bool,
19327 ) -> (
19328 NodeShared,
19329 NativeRuntime,
19330 ResolvedEnvironmentProfileReceipt,
19331 PathBuf,
19332 Arc<AtomicBool>,
19333 ) {
19334 let workspace = WorkspaceConfig::new(
19335 WorkspaceId::new("primary").unwrap(),
19336 std::env::current_dir().unwrap(),
19337 )
19338 .unwrap();
19339 materialization_test_shared_with_workspace(deny_secret, workspace)
19340 }
19341
19342 fn materialization_test_shared_with_workspace(
19343 deny_secret: bool,
19344 workspace: WorkspaceConfig,
19345 ) -> (
19346 NodeShared,
19347 NativeRuntime,
19348 ResolvedEnvironmentProfileReceipt,
19349 PathBuf,
19350 Arc<AtomicBool>,
19351 ) {
19352 let root = temporary_workspace_root(if deny_secret {
19353 "materialization-denied"
19354 } else {
19355 "materialization-ready"
19356 });
19357 std::fs::create_dir_all(&root).unwrap();
19358 let materialization_root = root.join("session-environments");
19359 let deny = Arc::new(AtomicBool::new(deny_secret));
19360 let resolver = Arc::new(FixtureSecretResolver {
19361 deny: Arc::clone(&deny),
19362 });
19363 let materializer = SessionEnvironmentMaterializer::new(
19364 materialization_root,
19365 resolver,
19366 )
19367 .unwrap();
19368 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19369 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19370 "materialized-claude-r1",
19371 )
19372 .unwrap();
19373 let materialization = NodeSessionMaterializationProfile::new(
19374 vec![NodeSessionEnvironmentMutation::SetSecret {
19375 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19376 reference: NodeSecretReference::new("fixture-token").unwrap(),
19377 }],
19378 vec![NodeSessionPathBinding::new(
19379 "GATE4AGENT_PROVIDER_HOME",
19380 NodeSessionPathClass::ProviderHome,
19381 )
19382 .unwrap()],
19383 vec![NodeSessionFile::secret(
19384 NodeSessionPathClass::Config,
19385 "auth/token",
19386 NodeSecretReference::new("fixture-token").unwrap(),
19387 )
19388 .unwrap()],
19389 )
19390 .unwrap();
19391 let catalog = active_registry().unwrap();
19392 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19393 let control = runtime.native_launch_profile_control();
19394 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19395 profile_id.clone(),
19396 profile_revision.clone(),
19397 agent("claude"),
19398 [NativeLaunchProfile::new(
19399 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19400 agent("claude"),
19401 TransportKind::Pty,
19402 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19403 Arc::new(EmptyEnvironmentResolver),
19404 )
19405 .unwrap()],
19406 Some(materialization),
19407 )
19408 .unwrap();
19409 let (binding, native_profiles, materialization) = node_profile.into_parts();
19410 for native_profile in native_profiles {
19411 runtime.upsert_native_launch_profile(native_profile).unwrap();
19412 }
19413 let shared = NodeShared::new_with_incarnation(
19414 handle,
19415 "fixture-token".to_owned(),
19416 NodeId::new("node-materialization-test").unwrap(),
19417 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19418 vec![workspace],
19419 vec![agent("claude")],
19420 ProviderRuntimeStatuses::default(),
19421 None,
19422 Vec::new(),
19423 Vec::new(),
19424 SpawnProfileRegistry::default(),
19425 Some(control),
19426 None,
19427 Some(materializer),
19428 None,
19429 Vec::new(),
19430 Vec::new(),
19431 Vec::new(),
19432 Vec::new(),
19433 None,
19434 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19435 );
19436 shared
19437 .environment_profiles
19438 .write()
19439 .unwrap_or_else(|poisoned| poisoned.into_inner())
19440 .insert(binding.id.clone(), binding);
19441 shared
19442 .environment_materialization_profiles
19443 .write()
19444 .unwrap_or_else(|poisoned| poisoned.into_inner())
19445 .insert(profile_id.clone(), materialization.unwrap());
19446 (
19447 shared,
19448 runtime,
19449 ResolvedEnvironmentProfileReceipt {
19450 profile_id,
19451 profile_revision,
19452 network_allowlist: None,
19453 browser_profile_id: None,
19454 },
19455 root,
19456 deny,
19457 )
19458 }
19459
19460
19461 async fn characterization_spawn(
19464 shared: &NodeShared,
19465 mode: SessionMode,
19466 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19467 required: &[ProviderRuntimeCapability],
19468 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19469 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19470 shared
19471 .spawn_session_with_deadline(
19472 WorkspaceId::new("primary").unwrap(),
19473 agent("claude"),
19474 mode,
19475 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19476 None,
19477 environment_profile,
19478 None,
19479 None,
19480 None,
19481 Some(ProviderRuntimePolicy::raw_pty()),
19482 SpawnRecordPolicy::ProviderIdentityOnly,
19483 Some(Instant::now() + Duration::from_secs(30)),
19484 required,
19485 harness_mcp,
19486 ApprovalLevel::default(),
19487 None,
19488 None,
19489 )
19490 .await
19491 }
19492
19493 fn assert_no_session_state(shared: &NodeShared) {
19494 assert!(shared.handle.snapshot().sessions.is_empty());
19495 assert!(shared
19496 .session_bindings
19497 .lock()
19498 .unwrap_or_else(|poisoned| poisoned.into_inner())
19499 .is_empty());
19500 assert!(shared
19501 .materializations
19502 .lock()
19503 .unwrap_or_else(|poisoned| poisoned.into_inner())
19504 .is_empty());
19505 }
19506
19507 #[tokio::test]
19508 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19509 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19510 let error = characterization_spawn(
19511 &shared,
19512 SessionMode::Pty,
19513 None,
19514 &[ProviderRuntimeCapability::SemanticReadiness],
19515 None,
19516 )
19517 .await
19518 .unwrap_err();
19519 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19520 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19521 assert_no_session_state(&shared);
19522 drop(shared);
19523 drop(runtime);
19524 std::fs::remove_dir_all(root).unwrap();
19525 }
19526
19527 #[tokio::test]
19528 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19529 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19530 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19531 .await
19532 .unwrap_err();
19533 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19534 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19535 assert_no_session_state(&shared);
19536 drop(shared);
19537 drop(runtime);
19538 std::fs::remove_dir_all(root).unwrap();
19539 }
19540
19541 #[test]
19542 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19543 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19544 let address = SessionAddress {
19545 workspace_id: WorkspaceId::new("primary").unwrap(),
19546 session: SessionKey {
19547 instance_id: AgentInstanceId(77),
19548 generation: SessionGeneration(1),
19549 },
19550 };
19551 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19552 let removed = shared.remove_binding(&address).expect("binding removed");
19553 assert!(removed.environment_profile.is_some());
19554 assert!(shared.remove_binding(&address).is_none());
19555 assert_no_session_state(&shared);
19556 drop(shared);
19557 drop(runtime);
19558 std::fs::remove_dir_all(root).unwrap();
19559 }
19560
19561 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19562 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19563 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19564 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19565 .expect("review current test executable as fixture helper");
19566 let registry = HarnessMcpProxyRegistry::new(helper);
19567 let spec = crate::protocol::SpawnSpec {
19568 target: crate::protocol::SpawnTarget {
19569 node_id: NodeId::new("node-characterization").unwrap(),
19570 workspace_id: WorkspaceId::new("primary").unwrap(),
19571 worktree_id: None,
19572 },
19573 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19574 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19575 overrides: crate::protocol::SpawnOverrides::default(),
19576 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19577 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19578 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19579 };
19580 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19581 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19582 let launch = HarnessMcpLaunchV1 {
19583 server_name: "fixture-harness".to_owned(),
19584 args: vec!["--session-proxy".to_owned()],
19585 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19586 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19587 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19588 trace: None,
19589 scrub_env: Vec::new(),
19590 };
19591 let expires = unix_time_ms().saturating_add(30_000);
19592 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19593 registry
19594 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19595 .unwrap()
19596 }
19597
19598 #[tokio::test]
19599 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19600 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19601 let prepared = characterization_prepared_harness("claude").await;
19602 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19603 .await
19604 .unwrap_err();
19605 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19607 let mismatched = characterization_prepared_harness("codex").await;
19608 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19609 .await
19610 .unwrap_err();
19611 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19612 assert_no_session_state(&shared);
19613 drop(shared);
19614 drop(runtime);
19615 std::fs::remove_dir_all(root).unwrap();
19616 }
19617
19618 #[tokio::test]
19619 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19620 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19621 shared.native_launch_profile_control = None;
19622 let prepared = characterization_prepared_harness("claude").await;
19623 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19624 .await
19625 .unwrap_err();
19626 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19627 assert_no_session_state(&shared);
19628 drop(shared);
19629 drop(runtime);
19630 std::fs::remove_dir_all(root).unwrap();
19631 }
19632}
19633
19634#[cfg(all(test, windows))]
19635mod tests {
19636 use super::*;
19637 use gate4agent_types::ProviderActivity;
19638 use crate::session_environment::{
19639 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19640 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19641 NodeSessionPathClass,
19642 };
19643 use gate4agent_catalog::EnvMutation;
19644 use gate4agent_runtime_native::{
19645 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19646 NativeLaunchProfile, NativeLaunchProfileId,
19647 };
19648 use std::ffi::OsString;
19649 use std::sync::Arc;
19650
19651 struct EmptyEnvironmentResolver;
19652
19653 struct FixtureSecretResolver {
19654 deny: Arc<AtomicBool>,
19655 }
19656
19657 impl NodeSecretResolver for FixtureSecretResolver {
19658 fn resolve(
19659 &self,
19660 _reference: &NodeSecretReference,
19661 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19662 if self.deny.load(Ordering::Acquire) {
19663 Err(NodeSecretResolveError::Denied)
19664 } else {
19665 NodeSecretValue::text("fixture-secret")
19666 .map_err(|_| NodeSecretResolveError::Unavailable)
19667 }
19668 }
19669 }
19670
19671 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19672 fn resolve_child_environment(
19673 &self,
19674 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19675 Ok(vec![EnvMutation {
19676 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19677 value: None,
19678 }])
19679 }
19680 }
19681
19682 fn agent(value: &str) -> AgentId {
19683 AgentId::new(value).unwrap()
19684 }
19685
19686 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19687 let catalog = active_registry().unwrap();
19688 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19689 let workspace = WorkspaceConfig::new(
19690 WorkspaceId::new("primary").unwrap(),
19691 std::env::current_dir().unwrap(),
19692 )
19693 .unwrap()
19694 .with_worktree_service_mode(mode);
19695 NodeShared::new(
19696 handle,
19697 "fixture-token".to_owned(),
19698 NodeId::new("node-terminal-test").unwrap(),
19699 vec![workspace],
19700 vec![agent("claude")],
19701 )
19702 }
19703
19704 fn terminal_test_shared() -> NodeShared {
19705 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19706 }
19707
19708 #[cfg(feature = "fixture")]
19709 #[test]
19710 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19711 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19712 assert_eq!(probe.latest_code(), None);
19713
19714 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19715 assert_eq!(
19716 probe.latest_code(),
19717 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19718 );
19719
19720 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19721 assert_eq!(
19722 probe.latest_code(),
19723 Some(NodeFailureCode::SpawnDeadlineExceeded),
19724 );
19725
19726 probe.record(None);
19727 assert_eq!(probe.latest_code(), None);
19728 }
19729
19730 #[test]
19731 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19732 let source = temporary_workspace_root("launch-inventory-source");
19733 let allocation = temporary_workspace_root("launch-inventory-allocation");
19734 std::fs::create_dir_all(&source).unwrap();
19735 std::fs::create_dir_all(&allocation).unwrap();
19736 let workspace_id = WorkspaceId::new("primary").unwrap();
19737 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19738 .unwrap()
19739 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19740 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19741 workspace = workspace
19742 .with_managed_worktree_profile(
19743 ManagedWorktreeProfile::new(
19744 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19745 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19746 &allocation,
19747 "gate4agent",
19748 "HEAD",
19749 ManagedWorktreeRetention::RemoveWhenReleased,
19750 )
19751 .unwrap(),
19752 )
19753 .unwrap();
19754 }
19755 let overflow = workspace.clone().with_managed_worktree_profile(
19756 ManagedWorktreeProfile::new(
19757 WorktreeProfileId::new("overflow").unwrap(),
19758 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19759 &allocation,
19760 "gate4agent",
19761 "HEAD",
19762 ManagedWorktreeRetention::Retain,
19763 )
19764 .unwrap(),
19765 );
19766 assert!(matches!(
19767 overflow,
19768 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19769 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19770 ..
19771 }),
19772 ));
19773
19774 let catalog = active_registry().unwrap();
19775 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19776 let shared = NodeShared::new(
19777 handle,
19778 "fixture-token".to_owned(),
19779 NodeId::new("node-launch-inventory").unwrap(),
19780 vec![workspace],
19781 vec![agent("claude")],
19782 );
19783 let snapshot = shared.snapshot();
19784 let launch = snapshot.launch_inventory.as_ref().unwrap();
19785 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19786 assert!(launch.bundles.as_ref().unwrap().is_empty());
19787 let profiles = snapshot.workspaces[0]
19788 .managed_worktree_profiles
19789 .as_ref()
19790 .unwrap();
19791 assert_eq!(
19792 snapshot.workspaces[0].worktree_service_mode,
19793 Some(WorktreeServiceMode::Managed),
19794 );
19795 assert_eq!(
19796 profiles.profiles.len(),
19797 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19798 );
19799 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19800 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19801 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19802 }
19803 let legacy = snapshot_for_wire(
19804 &shared,
19805 true,
19806 true,
19807 false,
19808 false,
19809 true,
19810 false,
19811 true,
19812 false,
19813 true,
19814 );
19815 assert!(legacy.launch_inventory.is_none());
19816 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19817
19818 drop(shared);
19819 std::fs::remove_dir_all(source).unwrap();
19820 std::fs::remove_dir_all(allocation).unwrap();
19821 }
19822
19823 #[test]
19824 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19825 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19826 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19827 }));
19828 let source = gate4agent_types::ProviderSource {
19829 family: AdapterFamily::PtySemantic,
19830 binding: AdapterBinding::new(
19831 gate4agent_types::AdapterId::new("claude").unwrap(),
19832 "fixture/v1",
19833 gate4agent_types::AdapterVerification::SyntheticFixture,
19834 )
19835 .unwrap(),
19836 };
19837 let provider = ProviderSnapshot {
19838 sequence: 17,
19839 session: Some(ProviderSessionIdentity {
19840 key: ProviderSessionKey::SessionId,
19841 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19842 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19843 }),
19844 model: Some("PRIVATE_MODEL".to_owned()),
19845 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19846 completed_turns: 3,
19847 usage: gate4agent_types::TokenUsage {
19848 input_tokens: 10,
19849 output_tokens: 20,
19850 cache_read_tokens: 30,
19851 cache_write_tokens: 40,
19852 reasoning_tokens: 50,
19853 context_window: Some(200_000),
19854 },
19855 lead_activity: ProviderActivity::WaitingForInput,
19856 activity: ProviderActivity::WaitingForInput,
19857 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19858 active_tools: (0..9)
19859 .map(|index| gate4agent_types::ActiveProviderTool {
19860 id: format!("private-tool-id-{index}"),
19861 name: if index == 0 {
19862 format!(" Read\u{0000}{}", "x".repeat(80))
19863 } else {
19864 format!("Tool-{index}")
19865 },
19866 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19867 })
19868 .collect(),
19869 interactions: vec![gate4agent_types::ProviderInteraction {
19870 id: gate4agent_types::ProviderInteractionId(17),
19871 source: source.clone(),
19872 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19873 interaction_kind: ProviderInteractionKind::Approval,
19874 tool_name: " Approve\u{0007}Tool ".to_owned(),
19875 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19876 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19877 resume_lead_activity: Some(ProviderActivity::Working),
19878 status: ProviderInteractionStatus::Pending,
19879 }],
19880 subagents: vec![gate4agent_types::ProviderSubagent {
19881 source: source.clone(),
19882 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19883 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19884 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19885 }],
19886 sources: vec![gate4agent_types::ProviderSourceCursor {
19887 source,
19888 sequence: 17,
19889 gap_count: 2,
19890 stale: true,
19891 }],
19892 last_event: Some(ProviderEvent::Error {
19893 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19894 }),
19895 gap_count: 2,
19896 stale: true,
19897 };
19898 let address = terminal_address(3);
19899 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19900 assert_eq!(entry.address, address);
19901 assert_eq!(entry.progress.provider_sequence, 17);
19902 assert_eq!(entry.progress.active_tool_count, 9);
19903 assert_eq!(
19904 entry.progress.active_tool_labels.len(),
19905 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19906 );
19907 assert_eq!(entry.progress.subagent_count, 1);
19908 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19909 assert!(entry.progress.truncated);
19910 let encoded = serde_json::to_string(&entry).unwrap();
19911 for forbidden in [
19912 "PRIVATE_PROVIDER_SESSION",
19913 "PRIVATE_PROVIDER_PATH",
19914 "PRIVATE_MODEL",
19915 "PRIVATE_TOOL_INVENTORY",
19916 "PRIVATE_CURRENT_PROMPT",
19917 "PRIVATE_TOOL_INPUT_JSON",
19918 "PRIVATE_PROVIDER_REQUEST",
19919 "PRIVATE_INTERACTION_PROMPT",
19920 "PRIVATE_INTERACTION_AGENT",
19921 "PRIVATE_SUBAGENT_ID",
19922 "PRIVATE_SUBAGENT_TYPE",
19923 "PRIVATE_SUBAGENT_DESCRIPTION",
19924 "PRIVATE_PROVIDER_ERROR",
19925 "input_json",
19926 "current_prompt",
19927 "session_id",
19928 ] {
19929 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19930 }
19931
19932 let snapshot = NodeSnapshot {
19933 node_id: NodeId::new("node-progress-test").unwrap(),
19934 enabled_providers: vec![agent("claude")],
19935 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19936 workspaces: Vec::new(),
19937 session_records: Vec::new(),
19938 managed_worktrees: Vec::new(),
19939 launch_inventory: None,
19940 agent_progress: vec![entry],
19941 };
19942 let mut legacy_reply = ResponseEnvelope {
19943 request_id: 1,
19944 result: Ok(NodeResponse::Snapshot {
19945 event_sequence: 0,
19946 controller: None,
19947 snapshot: snapshot.clone(),
19948 }),
19949 };
19950 project_response_without_agent_progress(&mut legacy_reply);
19951 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19952 panic!("agent progress projection changed response kind");
19953 };
19954 assert!(legacy.agent_progress.is_empty());
19955 assert_eq!(snapshot.agent_progress.len(), 1);
19956 }
19957
19958 fn terminal_address(generation: u64) -> SessionAddress {
19959 SessionAddress {
19960 workspace_id: WorkspaceId::new("primary").unwrap(),
19961 session: SessionKey {
19962 instance_id: AgentInstanceId(41),
19963 generation: SessionGeneration(generation),
19964 },
19965 }
19966 }
19967
19968 fn spawn_spec_fixture() -> SpawnSpec {
19969 SpawnSpec {
19970 target: crate::protocol::SpawnTarget {
19971 node_id: NodeId::new("node-terminal-test").unwrap(),
19972 workspace_id: WorkspaceId::new("primary").unwrap(),
19973 worktree_id: None,
19974 },
19975 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19976 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19977 .unwrap(),
19978 overrides: crate::protocol::SpawnOverrides::default(),
19979 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19980 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19981 required_capabilities: SpawnRequiredCapabilities::default(),
19982 }
19983 }
19984
19985 fn environment_profile_test_shared(
19986 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19987 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19988 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19989 "local-claude-r1",
19990 )
19991 .unwrap();
19992 let spawn_profiles = SpawnProfileRegistry::new([
19993 crate::protocol::SpawnProfileDefaults {
19994 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19995 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19996 provider: agent("claude"),
19997 mode: SessionMode::Pty,
19998 terminal_size: gate4agent_types::TerminalSize {
19999 rows: 24,
20000 columns: 80,
20001 },
20002 prompt: None,
20003 bundle_id: None,
20004 context_id: None,
20005 environment_profile_id: Some(profile_id.clone()),
20006 },
20007 ])
20008 .unwrap();
20009 let catalog = active_registry().unwrap();
20010 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20011 let control = runtime.native_launch_profile_control();
20012 let node_profile = NodeEnvironmentProfile::new(
20013 profile_id.clone(),
20014 profile_revision.clone(),
20015 agent("claude"),
20016 [NativeLaunchProfile::new(
20017 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20018 agent("claude"),
20019 TransportKind::Pty,
20020 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20021 Arc::new(EmptyEnvironmentResolver),
20022 )
20023 .unwrap()],
20024 )
20025 .unwrap();
20026 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20027 for native_profile in native_profiles {
20028 runtime.upsert_native_launch_profile(native_profile).unwrap();
20029 }
20030 let workspace = WorkspaceConfig::new(
20031 WorkspaceId::new("primary").unwrap(),
20032 std::env::current_dir().unwrap(),
20033 )
20034 .unwrap();
20035 let shared = NodeShared::new_with_incarnation(
20036 handle,
20037 "fixture-token".to_owned(),
20038 NodeId::new("node-terminal-test").unwrap(),
20039 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20040 vec![workspace],
20041 vec![agent("claude")],
20042 ProviderRuntimeStatuses::default(),
20043 None,
20044 Vec::new(),
20045 Vec::new(),
20046 spawn_profiles,
20047 Some(control),
20048 None,
20049 None,
20050 None,
20051 Vec::new(),
20052 Vec::new(),
20053 Vec::new(),
20054 Vec::new(),
20055 None,
20056 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20057 );
20058 shared
20059 .environment_profiles
20060 .write()
20061 .unwrap_or_else(|poisoned| poisoned.into_inner())
20062 .insert(binding.id.clone(), binding);
20063 (
20064 shared,
20065 runtime,
20066 ResolvedEnvironmentProfileReceipt {
20067 profile_id,
20068 profile_revision,
20069 network_allowlist: None,
20070 browser_profile_id: None,
20071 },
20072 )
20073 }
20074
20075 fn materialization_test_shared(
20076 deny_secret: bool,
20077 ) -> (
20078 NodeShared,
20079 NativeRuntime,
20080 ResolvedEnvironmentProfileReceipt,
20081 PathBuf,
20082 Arc<AtomicBool>,
20083 ) {
20084 let workspace = WorkspaceConfig::new(
20085 WorkspaceId::new("primary").unwrap(),
20086 std::env::current_dir().unwrap(),
20087 )
20088 .unwrap();
20089 materialization_test_shared_with_workspace(deny_secret, workspace)
20090 }
20091
20092 fn materialization_test_shared_with_workspace(
20093 deny_secret: bool,
20094 workspace: WorkspaceConfig,
20095 ) -> (
20096 NodeShared,
20097 NativeRuntime,
20098 ResolvedEnvironmentProfileReceipt,
20099 PathBuf,
20100 Arc<AtomicBool>,
20101 ) {
20102 let root = temporary_workspace_root(if deny_secret {
20103 "materialization-denied"
20104 } else {
20105 "materialization-ready"
20106 });
20107 std::fs::create_dir_all(&root).unwrap();
20108 let materialization_root = root.join("session-environments");
20109 let deny = Arc::new(AtomicBool::new(deny_secret));
20110 let resolver = Arc::new(FixtureSecretResolver {
20111 deny: Arc::clone(&deny),
20112 });
20113 let materializer = SessionEnvironmentMaterializer::new(
20114 materialization_root,
20115 resolver,
20116 )
20117 .unwrap();
20118 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20119 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20120 "materialized-claude-r1",
20121 )
20122 .unwrap();
20123 let materialization = NodeSessionMaterializationProfile::new(
20124 vec![NodeSessionEnvironmentMutation::SetSecret {
20125 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20126 reference: NodeSecretReference::new("fixture-token").unwrap(),
20127 }],
20128 vec![NodeSessionPathBinding::new(
20129 "GATE4AGENT_PROVIDER_HOME",
20130 NodeSessionPathClass::ProviderHome,
20131 )
20132 .unwrap()],
20133 vec![NodeSessionFile::secret(
20134 NodeSessionPathClass::Config,
20135 "auth/token",
20136 NodeSecretReference::new("fixture-token").unwrap(),
20137 )
20138 .unwrap()],
20139 )
20140 .unwrap();
20141 let catalog = active_registry().unwrap();
20142 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20143 let control = runtime.native_launch_profile_control();
20144 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20145 profile_id.clone(),
20146 profile_revision.clone(),
20147 agent("claude"),
20148 [NativeLaunchProfile::new(
20149 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20150 agent("claude"),
20151 TransportKind::Pty,
20152 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20153 Arc::new(EmptyEnvironmentResolver),
20154 )
20155 .unwrap()],
20156 Some(materialization),
20157 )
20158 .unwrap();
20159 let (binding, native_profiles, materialization) = node_profile.into_parts();
20160 for native_profile in native_profiles {
20161 runtime.upsert_native_launch_profile(native_profile).unwrap();
20162 }
20163 let shared = NodeShared::new_with_incarnation(
20164 handle,
20165 "fixture-token".to_owned(),
20166 NodeId::new("node-materialization-test").unwrap(),
20167 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20168 vec![workspace],
20169 vec![agent("claude")],
20170 ProviderRuntimeStatuses::default(),
20171 None,
20172 Vec::new(),
20173 Vec::new(),
20174 SpawnProfileRegistry::default(),
20175 Some(control),
20176 None,
20177 Some(materializer),
20178 None,
20179 Vec::new(),
20180 Vec::new(),
20181 Vec::new(),
20182 Vec::new(),
20183 None,
20184 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20185 );
20186 shared
20187 .environment_profiles
20188 .write()
20189 .unwrap_or_else(|poisoned| poisoned.into_inner())
20190 .insert(binding.id.clone(), binding);
20191 shared
20192 .environment_materialization_profiles
20193 .write()
20194 .unwrap_or_else(|poisoned| poisoned.into_inner())
20195 .insert(profile_id.clone(), materialization.unwrap());
20196 (
20197 shared,
20198 runtime,
20199 ResolvedEnvironmentProfileReceipt {
20200 profile_id,
20201 profile_revision,
20202 network_allowlist: None,
20203 browser_profile_id: None,
20204 },
20205 root,
20206 deny,
20207 )
20208 }
20209
20210 #[cfg(feature = "fixture")]
20211 #[derive(Clone, Copy)]
20212 enum CodexHomeProfile {
20213 Missing,
20214 WrongClass,
20215 Exact,
20216 }
20217
20218 #[cfg(feature = "fixture")]
20219 fn codex_bundle_test_shared(
20220 home_profile: CodexHomeProfile,
20221 ) -> (
20222 NodeShared,
20223 NativeRuntime,
20224 ResolvedEnvironmentProfileReceipt,
20225 NodeBundle,
20226 PathBuf,
20227 ) {
20228 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20229 const CLAUDE_MANIFEST: &[u8] =
20230 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20231 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20232 const DIGEST: &str =
20233 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20234
20235 let (shared, mut runtime, _claude_receipt, root, _deny) =
20236 materialization_test_shared(false);
20237 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20238 CodexHomeProfile::Missing => "codex-home-missing",
20239 CodexHomeProfile::WrongClass => "codex-home-wrong",
20240 CodexHomeProfile::Exact => "codex-home-exact",
20241 })
20242 .unwrap();
20243 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20244 "codex-home-r1",
20245 )
20246 .unwrap();
20247 let materialization = match home_profile {
20248 CodexHomeProfile::Missing => None,
20249 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20250 Some(NodeSessionMaterializationProfile::new(
20251 Vec::new(),
20252 vec![NodeSessionPathBinding::new(
20253 "CODEX_HOME",
20254 if matches!(home_profile, CodexHomeProfile::Exact) {
20255 NodeSessionPathClass::ProviderHome
20256 } else {
20257 NodeSessionPathClass::Config
20258 },
20259 )
20260 .unwrap()],
20261 Vec::new(),
20262 )
20263 .unwrap())
20264 }
20265 };
20266 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20267 profile_id.clone(),
20268 profile_revision.clone(),
20269 agent("codex"),
20270 [NativeLaunchProfile::new(
20271 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20272 agent("codex"),
20273 TransportKind::Pty,
20274 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20275 Arc::new(EmptyEnvironmentResolver),
20276 )
20277 .unwrap()],
20278 materialization,
20279 )
20280 .unwrap();
20281 let (binding, native_profiles, materialization) = node_profile.into_parts();
20282 for native_profile in native_profiles {
20283 runtime.upsert_native_launch_profile(native_profile).unwrap();
20284 }
20285 shared
20286 .environment_profiles
20287 .write()
20288 .unwrap_or_else(|poisoned| poisoned.into_inner())
20289 .insert(binding.id.clone(), binding);
20290 if let Some(materialization) = materialization {
20291 shared
20292 .environment_materialization_profiles
20293 .write()
20294 .unwrap_or_else(|poisoned| poisoned.into_inner())
20295 .insert(profile_id.clone(), materialization);
20296 }
20297
20298 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20299 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20300 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20301 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20302 std::fs::write(
20303 bundle_source.join(".claude-plugin/plugin.json"),
20304 CLAUDE_MANIFEST,
20305 )
20306 .unwrap();
20307 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20308 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20309 let bundle = NodeBundle::new(
20310 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20311 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20312 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20313 &bundle_source,
20314 )
20315 .unwrap();
20316 *shared
20317 .bundle_catalog
20318 .write()
20319 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20320 BundleCatalog::new([bundle.clone()]).unwrap();
20321 (
20322 shared,
20323 runtime,
20324 ResolvedEnvironmentProfileReceipt {
20325 profile_id,
20326 profile_revision,
20327 network_allowlist: None,
20328 browser_profile_id: None,
20329 },
20330 bundle,
20331 root,
20332 )
20333 }
20334
20335 #[cfg(feature = "fixture")]
20336 #[test]
20337 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20338 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20339 let (shared, runtime, receipt, bundle, root) =
20340 codex_bundle_test_shared(home_profile);
20341 let error = shared
20342 .bundle_layout(
20343 &agent("codex"),
20344 SessionMode::Pty,
20345 Some(&receipt),
20346 &bundle,
20347 )
20348 .unwrap_err();
20349 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20350 assert!(shared
20351 .materializations
20352 .lock()
20353 .unwrap_or_else(|poisoned| poisoned.into_inner())
20354 .is_empty());
20355 let materialization_root = root.join("session-environments");
20356 assert!(!std::fs::read_dir(&materialization_root)
20357 .unwrap()
20358 .filter_map(Result::ok)
20359 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20360 drop(shared);
20361 drop(runtime);
20362 let _ = std::fs::remove_dir_all(root);
20363 }
20364 }
20365
20366 #[cfg(feature = "fixture")]
20367 #[test]
20368 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20369 let (shared, runtime, receipt, bundle, root) =
20370 codex_bundle_test_shared(CodexHomeProfile::Exact);
20371 let address = SessionAddress {
20372 workspace_id: WorkspaceId::new("primary").unwrap(),
20373 session: SessionKey {
20374 instance_id: AgentInstanceId(310),
20375 generation: SessionGeneration(1),
20376 },
20377 };
20378 let (overlay, guard) = shared
20379 .prepare_session_materialization(
20380 &address,
20381 &agent("codex"),
20382 SessionMode::Pty,
20383 Some(&receipt),
20384 Some(&bundle.receipt()),
20385 None,
20386 None,
20387 )
20388 .unwrap()
20389 .unwrap();
20390 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20391 let ownership = shared
20392 .materializations
20393 .lock()
20394 .unwrap_or_else(|poisoned| poisoned.into_inner())
20395 .get(guard.id().unwrap())
20396 .unwrap()
20397 .clone();
20398 let (profile, _, _) = shared
20399 .materialization_profile(
20400 &agent("codex"),
20401 SessionMode::Pty,
20402 Some(&receipt),
20403 Some(&bundle.receipt()),
20404 None,
20405 )
20406 .unwrap()
20407 .unwrap();
20408 let environment = shared
20409 .session_environment_materializer
20410 .as_ref()
20411 .unwrap()
20412 .resolve_environment(&ownership, &profile)
20413 .unwrap();
20414 assert!(environment.iter().any(|mutation| {
20415 mutation.key == OsString::from("CODEX_HOME")
20416 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20417 }));
20418 assert_eq!(
20419 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20420 .unwrap(),
20421 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20422 );
20423 assert!(std::fs::read_dir(ownership.bundle_root())
20424 .unwrap()
20425 .next()
20426 .is_none());
20427 drop(guard);
20428 drop(shared);
20429 drop(runtime);
20430 let _ = std::fs::remove_dir_all(root);
20431 }
20432
20433 #[cfg(feature = "fixture")]
20434 #[test]
20435 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20436 let (shared, runtime, receipt, bundle, root) =
20437 codex_bundle_test_shared(CodexHomeProfile::Exact);
20438 let bundle_receipt = bundle.receipt();
20439 let address = SessionAddress {
20440 workspace_id: WorkspaceId::new("primary").unwrap(),
20441 session: SessionKey {
20442 instance_id: AgentInstanceId(311),
20443 generation: SessionGeneration(1),
20444 },
20445 };
20446 let (_overlay, guard) = shared
20447 .prepare_session_materialization(
20448 &address,
20449 &agent("codex"),
20450 SessionMode::Pty,
20451 Some(&receipt),
20452 Some(&bundle_receipt),
20453 None,
20454 None,
20455 )
20456 .unwrap()
20457 .unwrap();
20458 let materialization_id = guard.id().unwrap().clone();
20459 let skill_path = shared
20460 .materializations
20461 .lock()
20462 .unwrap_or_else(|poisoned| poisoned.into_inner())
20463 .get(&materialization_id)
20464 .unwrap()
20465 .provider_home()
20466 .join("skills/review-code/SKILL.md");
20467 let record_id = shared
20468 .bind_spawn_session_with_materialization(
20469 &address,
20470 agent("codex"),
20471 SessionMode::Pty,
20472 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20473 SpawnRecordPolicy::ProviderIdentityOnly,
20474 Some(receipt.clone()),
20475 Some(bundle_receipt.clone()),
20476 None,
20477 Some(materialization_id.clone()),
20478 )
20479 .unwrap()
20480 .unwrap();
20481 guard.retain();
20482 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20483
20484 let error = match shared.resolve_record_materialization(
20485 &record_id,
20486 &agent("codex"),
20487 SessionMode::Pty,
20488 Some(&receipt),
20489 Some(&bundle_receipt),
20490 None,
20491 ) {
20492 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20493 Err(error) => error,
20494 };
20495 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20496 assert_eq!(
20497 shared
20498 .materializations
20499 .lock()
20500 .unwrap_or_else(|poisoned| poisoned.into_inner())
20501 .get(&materialization_id)
20502 .unwrap()
20503 .state(),
20504 MaterializationState::RecoveryRequired,
20505 );
20506 drop(shared);
20507 drop(runtime);
20508 let _ = std::fs::remove_dir_all(root);
20509 }
20510
20511 fn bind_record_owned_materialization_fixture(
20512 shared: &NodeShared,
20513 receipt: &ResolvedEnvironmentProfileReceipt,
20514 instance_id: u64,
20515 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20516 let address = SessionAddress {
20517 workspace_id: WorkspaceId::new("primary").unwrap(),
20518 session: SessionKey {
20519 instance_id: AgentInstanceId(instance_id),
20520 generation: SessionGeneration(1),
20521 },
20522 };
20523 let (overlay, materialization_guard) = shared
20524 .prepare_session_materialization(
20525 &address,
20526 &agent("claude"),
20527 SessionMode::Pty,
20528 Some(receipt),
20529 None,
20530 None,
20531 None,
20532 )
20533 .unwrap()
20534 .unwrap();
20535 let materialization_id = materialization_guard.id().unwrap().clone();
20536 let materialization_root = shared
20537 .materializations
20538 .lock()
20539 .unwrap_or_else(|poisoned| poisoned.into_inner())
20540 .get(&materialization_id)
20541 .unwrap()
20542 .root()
20543 .to_path_buf();
20544 let selection = shared
20545 .select_environment_profile(
20546 address.session.instance_id,
20547 &agent("claude"),
20548 SessionMode::Pty,
20549 Some(receipt),
20550 )
20551 .unwrap()
20552 .unwrap();
20553 assert!(shared
20554 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20555 .unwrap()
20556 .is_none());
20557 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20558 let record_id = shared
20559 .bind_spawn_session_with_materialization(
20560 &address,
20561 agent("claude"),
20562 SessionMode::Pty,
20563 policy,
20564 SpawnRecordPolicy::ProviderIdentityOnly,
20565 Some(receipt.clone()),
20566 None,
20567 None,
20568 Some(materialization_id.clone()),
20569 )
20570 .unwrap()
20571 .unwrap();
20572 selection.retain();
20573 materialization_guard.retain();
20574 (address, record_id, materialization_id, materialization_root)
20575 }
20576
20577 #[test]
20578 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20579 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20580 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20581 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20582 }));
20583 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20584 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20585 let mut spec = spawn_spec_fixture();
20586 spec.expected_profile_revision =
20587 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20588 assert!(matches!(
20589 &spec.overrides.environment_profile_id,
20590 crate::protocol::SpawnOverride::Inherit,
20591 ));
20592 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20593 .requires_child_environment_profile_capability());
20594 assert!(request_requires_child_environment_profile(
20595 &shared,
20596 &NodeRequest::SpawnSpec { spec: spec.clone() },
20597 ));
20598
20599 let address = terminal_address(1);
20600 shared.bind_session_with_policy(
20601 &address,
20602 ProviderRuntimePolicy::raw_pty(),
20603 Some(environment_profile.clone()),
20604 );
20605 assert!(request_requires_child_environment_profile(
20606 &shared,
20607 &NodeRequest::Input {
20608 session: address.clone(),
20609 text: "x".to_owned(),
20610 },
20611 ));
20612
20613 let mut record = record("claude", "profile-record");
20614 record.active_session = Some(address.clone());
20615 record.environment_profile = Some(environment_profile.clone());
20616 let record_id = record.record_id.clone();
20617 shared.insert_record(record.clone()).unwrap();
20618 assert!(request_requires_child_environment_profile(
20619 &shared,
20620 &NodeRequest::ResumeSessionRecord {
20621 record_id,
20622 terminal_size: gate4agent_types::TerminalSize {
20623 rows: 24,
20624 columns: 80,
20625 },
20626 initial_prompt: None,
20627 },
20628 ));
20629
20630 let snapshot = snapshot_for_wire(
20631 &shared, false, true, true, true, false, false, false, false, true,
20632 );
20633 assert_eq!(snapshot.session_records.len(), 1);
20634 assert!(snapshot.session_records[0].environment_profile.is_none());
20635 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20636 sequence: 1,
20637 event: NodeEvent::SessionRecordUpserted { record },
20638 });
20639 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20640 panic!("record projection changed the event kind");
20641 };
20642 assert!(record.environment_profile.is_none());
20643
20644 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20645 let mut reply = ResponseEnvelope {
20646 request_id: 1,
20647 result: Ok(NodeResponse::SpawnSpecAccepted {
20648 receipt: resolved.receipt_with_environment(
20649 shared.incarnation_id,
20650 address,
20651 Some(environment_profile),
20652 ),
20653 }),
20654 };
20655 project_response_without_child_environment_profile(&mut reply);
20656 assert_eq!(
20657 reply.result.unwrap_err().code,
20658 NodeFailureCode::UnsupportedCapability,
20659 );
20660 }
20661
20662 #[test]
20663 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20664 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20665 let snapshot = shared.snapshot();
20666 let profiles = snapshot
20667 .launch_inventory
20668 .as_ref()
20669 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20670 .unwrap();
20671 assert_eq!(profiles.len(), 1);
20672 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20673 assert!(snapshot.requires_child_environment_profile_capability());
20674
20675 let mut legacy = snapshot.clone();
20676 clear_snapshot_child_environment_profiles(&mut legacy);
20677 assert!(legacy
20678 .launch_inventory
20679 .as_ref()
20680 .unwrap()
20681 .spawn_profiles
20682 .as_ref()
20683 .unwrap()[0]
20684 .environment_profile
20685 .is_none());
20686 assert!(!legacy.requires_child_environment_profile_capability());
20687
20688 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20689 let bindings = shared
20690 .environment_profiles
20691 .read()
20692 .unwrap_or_else(|poisoned| poisoned.into_inner())
20693 .clone();
20694 let mut provider_mismatch = bindings.clone();
20695 provider_mismatch
20696 .values_mut()
20697 .next()
20698 .unwrap()
20699 .provider = agent("codex");
20700 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20701 let mut mode_mismatch = profile;
20702 mode_mismatch.mode = SessionMode::Inline;
20703 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20704
20705 shared
20706 .environment_profiles
20707 .write()
20708 .unwrap_or_else(|poisoned| poisoned.into_inner())
20709 .clear();
20710 assert!(shared
20711 .snapshot()
20712 .launch_inventory
20713 .unwrap()
20714 .spawn_profiles
20715 .unwrap()
20716 .is_empty());
20717 }
20718
20719 #[test]
20720 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20721 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20722 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20723 }));
20724 let shared = terminal_test_shared();
20725 let bundle = ResolvedBundleReceipt {
20726 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20727 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20728 digest: crate::protocol::SpawnBundleDigest::new(format!(
20729 "sha256:{}",
20730 "0".repeat(64),
20731 ))
20732 .unwrap(),
20733 };
20734 let mut bundled = record("claude", "bundle-record");
20735 bundled.bundle = Some(bundle.clone());
20736 let record_id = bundled.record_id.clone();
20737 shared.insert_record(bundled.clone()).unwrap();
20738 assert!(request_requires_session_bundle(
20739 &shared,
20740 &NodeRequest::ForgetSessionRecord {
20741 record_id: record_id.clone(),
20742 },
20743 ));
20744
20745 let snapshot = snapshot_for_wire(
20746 &shared, false, true, true, true, true, false, false, false, true,
20747 );
20748 assert!(snapshot.session_records[0].bundle.is_none());
20749 let launch = snapshot.launch_inventory.as_ref().unwrap();
20750 assert!(launch.spawn_profiles.is_some());
20751 assert!(launch.bundles.is_none());
20752 let event = project_event_without_session_bundle(NodeEventEnvelope {
20753 sequence: 1,
20754 event: NodeEvent::SessionRecordUpserted { record: bundled },
20755 });
20756 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20757 panic!("record projection changed the event kind");
20758 };
20759 assert!(record.bundle.is_none());
20760
20761 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20762 let mut reply = ResponseEnvelope {
20763 request_id: 1,
20764 result: Ok(NodeResponse::SpawnSpecAccepted {
20765 receipt: resolved.receipt_with_materialization(
20766 shared.incarnation_id,
20767 terminal_address(17),
20768 None,
20769 Some(bundle),
20770 None,
20771 ),
20772 }),
20773 };
20774 project_response_without_session_bundle(&mut reply);
20775 assert_eq!(
20776 reply.result.unwrap_err().code,
20777 NodeFailureCode::UnsupportedCapability,
20778 );
20779 }
20780
20781 #[test]
20782 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20783 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20784 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20785 }));
20786 let context_id = SpawnContextId::new("context-review").unwrap();
20787 let context = ResolvedContextPackReceipt {
20788 id: context_id.clone(),
20789 digest: crate::protocol::SpawnContextDigest::new(format!(
20790 "sha256:{}",
20791 "c".repeat(64),
20792 ))
20793 .unwrap(),
20794 lineage: ContextPackLineageReceipt {
20795 source_node_id: NodeId::new("node-source").unwrap(),
20796 source_session: terminal_address(1),
20797 source_provider: agent("kimi"),
20798 },
20799 source_message_count: 2,
20800 retained_message_count: 2,
20801 byte_len: 256,
20802 truncated: false,
20803 };
20804 assert!(context.is_valid());
20805 let mut bound_record = record("codex", "context-record");
20806 bound_record.context_id = Some(context_id);
20807 bound_record.context = Some(context.clone());
20808 let session = gate4agent_types::SessionSnapshot {
20809 instance_id: AgentInstanceId(91),
20810 agent_id: agent("kimi"),
20811 transport: TransportKind::Pty,
20812 generation: SessionGeneration(3),
20813 status: SessionStatus::Running,
20814 pending_operation: None,
20815 pending_input: None,
20816 process_id: Some(9001),
20817 terminal_size: None,
20818 terminal_frame: None,
20819 terminal_stale: None,
20820 session_options: None,
20821 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20822 history: gate4agent_types::HistorySnapshot {
20823 pending: None,
20824 candidates: vec![HistoryCandidateSummary {
20825 id: "candidate-opaque".to_owned(),
20826 session_id_hint: "session-hint".to_owned(),
20827 modified_at_unix_ms: Some(1),
20828 }],
20829 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20830 loaded: Some(HistorySessionRecord {
20831 session_id: "private-vendor-session".to_owned(),
20832 title: Some("private title".to_owned()),
20833 cwd: Some(r"C:\private\history-root".to_owned()),
20834 model: Some("private-model".to_owned()),
20835 message_count: 2,
20836 completed_turn_count: None,
20837 total_tokens: 19,
20838 messages: vec![
20839 gate4agent_types::HistoryMessageRecord {
20840 role: gate4agent_types::HistoryMessageRole::User,
20841 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20842 },
20843 gate4agent_types::HistoryMessageRecord {
20844 role: gate4agent_types::HistoryMessageRole::Assistant,
20845 text: "private answer".to_owned(),
20846 },
20847 ],
20848 }),
20849 last_error: None,
20850 },
20851 resume: gate4agent_types::ResumeSnapshot::default(),
20852 foreground: gate4agent_types::ForegroundSnapshot::default(),
20853 provider: gate4agent_types::ProviderSnapshot::default(),
20854 screen_state: gate4agent_types::PtyScreenState::default(),
20855 };
20856 let original = NodeSnapshot {
20857 node_id: NodeId::new("node-terminal-test").unwrap(),
20858 enabled_providers: vec![agent("kimi"), agent("codex")],
20859 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20860 workspaces: vec![WorkspaceSnapshot {
20861 workspace_id: WorkspaceId::new("primary").unwrap(),
20862 canonical_root: opaque_windows_path(
20863 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20864 ),
20865 sessions: vec![session],
20866 worktree_service_mode: None,
20867 managed_worktree_profiles: None,
20868 }],
20869 session_records: vec![bound_record],
20870 managed_worktrees: Vec::new(),
20871 launch_inventory: None,
20872 agent_progress: Vec::new(),
20873 };
20874
20875 let mut negotiated = original.clone();
20876 project_snapshot_history_for_wire(&mut negotiated, true);
20877 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20878 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20879 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20880 assert!(!serde_json::to_string(&negotiated)
20881 .unwrap()
20882 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20883
20884 let mut legacy = original;
20885 project_snapshot_history_for_wire(&mut legacy, false);
20886 clear_snapshot_context_packs(&mut legacy);
20887 assert_eq!(
20888 legacy.workspaces[0].sessions[0].history,
20889 gate4agent_types::HistorySnapshot::default(),
20890 );
20891 assert!(legacy.session_records[0].context_id.is_none());
20892 assert!(legacy.session_records[0].context.is_none());
20893 let encoded = serde_json::to_string(&legacy).unwrap();
20894 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20895 assert!(!encoded.contains("context-review"));
20896
20897 let mut reply = ResponseEnvelope {
20898 request_id: 7,
20899 result: Ok(NodeResponse::ContextPackExported { context }),
20900 };
20901 project_response_without_context_pack(&mut reply);
20902 assert_eq!(
20903 reply.result.unwrap_err().code,
20904 NodeFailureCode::UnsupportedCapability,
20905 );
20906 }
20907
20908 #[test]
20909 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20910 let shared = terminal_test_shared();
20911 let history = HistorySessionRecord {
20912 session_id: "fixture-history".to_owned(),
20913 title: None,
20914 cwd: None,
20915 model: None,
20916 message_count: 1,
20917 completed_turn_count: None,
20918 total_tokens: 0,
20919 messages: vec![gate4agent_types::HistoryMessageRecord {
20920 role: gate4agent_types::HistoryMessageRole::User,
20921 text: "bounded handoff".to_owned(),
20922 }],
20923 };
20924 let pack = NodeContextPack::export(
20925 ContextPackLineageReceipt {
20926 source_node_id: shared.node_id.clone(),
20927 source_session: terminal_address(1),
20928 source_provider: agent("claude"),
20929 },
20930 &history,
20931 )
20932 .unwrap();
20933 let context = shared
20934 .context_catalog
20935 .write()
20936 .unwrap_or_else(|poisoned| poisoned.into_inner())
20937 .insert(pack)
20938 .unwrap();
20939 let mut retained = record("codex", "context-retained-record");
20940 retained.context_id = Some(context.id.clone());
20941 retained.context = Some(context.clone());
20942 let record_id = retained.record_id.clone();
20943 shared.insert_record(retained).unwrap();
20944 assert_eq!(
20945 shared.forget_context_pack(&context.id).unwrap_err().code,
20946 NodeFailureCode::ContextPackBusy,
20947 );
20948 assert!(shared
20949 .context_catalog
20950 .read()
20951 .unwrap_or_else(|poisoned| poisoned.into_inner())
20952 .get(&context.id)
20953 .is_some());
20954 shared
20955 .session_records
20956 .lock()
20957 .unwrap_or_else(|poisoned| poisoned.into_inner())
20958 .records
20959 .remove(&record_id);
20960
20961 let mut spec = spawn_spec_fixture();
20962 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20963 value: context.id.clone(),
20964 };
20965 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20966 let receipt = resolved.receipt_with_materialization(
20967 shared.incarnation_id,
20968 terminal_address(2),
20969 None,
20970 None,
20971 Some(context.clone()),
20972 );
20973 assert!(receipt.context_binding_is_valid());
20974 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20975
20976 shared.forget_context_pack(&context.id).unwrap();
20977 assert!(shared
20978 .context_catalog
20979 .read()
20980 .unwrap_or_else(|poisoned| poisoned.into_inner())
20981 .get(&context.id)
20982 .is_none());
20983 assert!(shared
20984 .replay_spawn_spec(&spec, Instant::now())
20985 .unwrap()
20986 .is_none());
20987 }
20988
20989 #[test]
20990 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20991 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20992 let address = terminal_address(1);
20993 let selection = shared
20994 .select_environment_profile(
20995 address.session.instance_id,
20996 &agent("claude"),
20997 SessionMode::Pty,
20998 Some(&environment_profile),
20999 )
21000 .unwrap()
21001 .unwrap();
21002 shared.bind_session_with_policy(
21003 &address,
21004 ProviderRuntimePolicy::raw_pty(),
21005 Some(environment_profile.clone()),
21006 );
21007 selection.retain();
21008 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21009 assert!(matches!(
21010 runtime.remove_native_launch_profile(&native_id),
21011 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21012 ));
21013 assert!(shared.remove_binding(&address).is_some());
21014 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21015
21016 let mismatched = ResolvedEnvironmentProfileReceipt {
21017 profile_id: environment_profile.profile_id,
21018 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21019 "local-claude-r2",
21020 )
21021 .unwrap(),
21022 network_allowlist: None,
21023 browser_profile_id: None,
21024 };
21025 let error = match shared.select_environment_profile(
21026 AgentInstanceId(42),
21027 &agent("claude"),
21028 SessionMode::Pty,
21029 Some(&mismatched),
21030 ) {
21031 Ok(_) => panic!("mismatched environment profile revision was selected"),
21032 Err(error) => error,
21033 };
21034 assert_eq!(
21035 error.code,
21036 NodeFailureCode::EnvironmentProfileBindingMismatch,
21037 );
21038 assert!(!shared
21039 .session_bindings
21040 .lock()
21041 .unwrap_or_else(|poisoned| poisoned.into_inner())
21042 .contains_key(&AgentInstanceId(42)));
21043 }
21044
21045 #[test]
21046 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21047 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21048 let address = terminal_address(1);
21049 let selection = shared
21050 .select_environment_profile(
21051 address.session.instance_id,
21052 &agent("claude"),
21053 SessionMode::Pty,
21054 Some(&environment_profile),
21055 )
21056 .unwrap()
21057 .unwrap();
21058 let overlay = NativeInstanceLaunchOverlay::new(
21059 agent("claude"),
21060 TransportKind::Pty,
21061 vec![EnvMutation {
21062 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21063 value: Some(OsString::from("fixture-secret")),
21064 }],
21065 vec![
21066 OsString::from("--plugin-dir"),
21067 std::env::current_dir().unwrap().into_os_string(),
21068 ],
21069 )
21070 .unwrap();
21071 let instance_overlay = shared
21072 .install_prepared_launch_overlay(
21073 address.session.instance_id,
21074 PreparedNativeLaunchOverlay::Instance(overlay),
21075 )
21076 .unwrap()
21077 .unwrap();
21078 let bundle = ResolvedBundleReceipt {
21079 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21080 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21081 digest: crate::protocol::SpawnBundleDigest::new(format!(
21082 "sha256:{}",
21083 "0".repeat(64),
21084 ))
21085 .unwrap(),
21086 };
21087 shared.bind_session_with_materialization(
21088 &address,
21089 ProviderRuntimePolicy::raw_pty(),
21090 Some(environment_profile),
21091 Some(bundle),
21092 None,
21093 None,
21094 );
21095 selection.retain();
21096 instance_overlay.retain();
21097
21098 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21099 assert!(matches!(
21100 runtime.remove_native_launch_profile(&native_id),
21101 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21102 ));
21103 assert!(shared.remove_binding(&address).is_some());
21104 assert!(!shared
21105 .native_launch_profile_control
21106 .as_ref()
21107 .unwrap()
21108 .clear_native_instance_launch_overlay(address.session.instance_id));
21109 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21110 }
21111
21112 #[test]
21113 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21114 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21115 let address = SessionAddress {
21116 workspace_id: WorkspaceId::new("primary").unwrap(),
21117 session: SessionKey {
21118 instance_id: AgentInstanceId(201),
21119 generation: SessionGeneration(1),
21120 },
21121 };
21122 let error = match shared.prepare_session_materialization(
21123 &address,
21124 &agent("claude"),
21125 SessionMode::Pty,
21126 Some(&receipt),
21127 None,
21128 None,
21129 None,
21130 ) {
21131 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21132 Err(error) => error,
21133 };
21134 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21135 assert!(shared.handle.snapshot().sessions.is_empty());
21136 assert!(shared
21137 .session_bindings
21138 .lock()
21139 .unwrap_or_else(|poisoned| poisoned.into_inner())
21140 .is_empty());
21141 assert!(shared
21142 .materializations
21143 .lock()
21144 .unwrap_or_else(|poisoned| poisoned.into_inner())
21145 .is_empty());
21146 drop(shared);
21147 drop(runtime);
21148 std::fs::remove_dir_all(root).unwrap();
21149 }
21150
21151 #[test]
21152 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21153 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21154 let address = SessionAddress {
21155 workspace_id: WorkspaceId::new("primary").unwrap(),
21156 session: SessionKey {
21157 instance_id: AgentInstanceId(202),
21158 generation: SessionGeneration(1),
21159 },
21160 };
21161 let (overlay, materialization_guard) = shared
21162 .prepare_session_materialization(
21163 &address,
21164 &agent("claude"),
21165 SessionMode::Pty,
21166 Some(&receipt),
21167 None,
21168 None,
21169 None,
21170 )
21171 .unwrap()
21172 .unwrap();
21173 let materialization_id = materialization_guard.id().unwrap().clone();
21174 let materialization_root = shared
21175 .materializations
21176 .lock()
21177 .unwrap_or_else(|poisoned| poisoned.into_inner())
21178 .get(&materialization_id)
21179 .unwrap()
21180 .root()
21181 .to_path_buf();
21182 let selection = shared
21183 .select_environment_profile(
21184 address.session.instance_id,
21185 &agent("claude"),
21186 SessionMode::Pty,
21187 Some(&receipt),
21188 )
21189 .unwrap()
21190 .unwrap();
21191 assert!(shared
21192 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21193 .unwrap()
21194 .is_none());
21195 shared.bind_session_with_materialization(
21196 &address,
21197 ProviderRuntimePolicy::raw_pty(),
21198 Some(receipt),
21199 None,
21200 None,
21201 Some(materialization_id),
21202 );
21203 selection.retain();
21204 materialization_guard.retain();
21205
21206 assert!(materialization_root.is_dir());
21207 assert!(shared
21208 .session_bindings
21209 .lock()
21210 .unwrap_or_else(|poisoned| poisoned.into_inner())
21211 .contains_key(&address.session.instance_id));
21212
21213 let removed = shared.remove_binding(&address).unwrap();
21214 shared
21215 .cleanup_session_owned_materialization(&address, &removed)
21216 .unwrap();
21217 assert!(!materialization_root.exists());
21218 assert!(shared
21219 .materializations
21220 .lock()
21221 .unwrap_or_else(|poisoned| poisoned.into_inner())
21222 .is_empty());
21223 drop(shared);
21224 drop(runtime);
21225 std::fs::remove_dir_all(root).unwrap();
21226 }
21227
21228 #[tokio::test]
21229 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21230 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21231 let address = SessionAddress {
21232 workspace_id: WorkspaceId::new("primary").unwrap(),
21233 session: SessionKey {
21234 instance_id: AgentInstanceId(203),
21235 generation: SessionGeneration(1),
21236 },
21237 };
21238 let (overlay, materialization_guard) = shared
21239 .prepare_session_materialization(
21240 &address,
21241 &agent("claude"),
21242 SessionMode::Pty,
21243 Some(&receipt),
21244 None,
21245 None,
21246 None,
21247 )
21248 .unwrap()
21249 .unwrap();
21250 let materialization_id = materialization_guard.id().unwrap().clone();
21251 let materialization_root = shared
21252 .materializations
21253 .lock()
21254 .unwrap_or_else(|poisoned| poisoned.into_inner())
21255 .get(&materialization_id)
21256 .unwrap()
21257 .root()
21258 .to_path_buf();
21259 let selection = shared
21260 .select_environment_profile(
21261 address.session.instance_id,
21262 &agent("claude"),
21263 SessionMode::Pty,
21264 Some(&receipt),
21265 )
21266 .unwrap()
21267 .unwrap();
21268 assert!(shared
21269 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21270 .unwrap()
21271 .is_none());
21272 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21273 let record_id = shared
21274 .bind_spawn_session_with_materialization(
21275 &address,
21276 agent("claude"),
21277 SessionMode::Pty,
21278 policy,
21279 SpawnRecordPolicy::ProviderIdentityOnly,
21280 Some(receipt.clone()),
21281 None,
21282 None,
21283 Some(materialization_id.clone()),
21284 )
21285 .unwrap()
21286 .unwrap();
21287 selection.retain();
21288 materialization_guard.retain();
21289 assert_eq!(
21290 shared
21291 .materializations
21292 .lock()
21293 .unwrap_or_else(|poisoned| poisoned.into_inner())
21294 .get(&materialization_id)
21295 .unwrap()
21296 .owner(),
21297 &MaterializationOwner::Record {
21298 record_id: record_id.clone(),
21299 },
21300 );
21301
21302 let removed = shared.remove_binding(&address).unwrap();
21303 shared
21304 .cleanup_session_owned_materialization(&address, &removed)
21305 .unwrap();
21306 assert!(materialization_root.is_dir());
21307 assert!(shared
21308 .resolve_record_materialization(
21309 &record_id,
21310 &agent("claude"),
21311 SessionMode::Pty,
21312 Some(&receipt),
21313 None,
21314 None,
21315 )
21316 .unwrap()
21317 .is_some());
21318 {
21319 let mut records = shared
21320 .session_records
21321 .lock()
21322 .unwrap_or_else(|poisoned| poisoned.into_inner());
21323 let record = records.records.get_mut(&record_id).unwrap();
21324 record.active_session = None;
21325 record.state = ManagedSessionState::Unavailable;
21326 }
21327 shared.forget_session_record(&record_id).await.unwrap();
21328 assert!(!materialization_root.exists());
21329 assert!(shared
21330 .materializations
21331 .lock()
21332 .unwrap_or_else(|poisoned| poisoned.into_inner())
21333 .is_empty());
21334 drop(shared);
21335 drop(runtime);
21336 std::fs::remove_dir_all(root).unwrap();
21337 }
21338
21339 #[test]
21340 fn record_materialization_revalidation_failure_persists_recovery_required() {
21341 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21342 let state_path = root.join("node-state-v6.json");
21343 shared.state_path = Some(state_path.clone());
21344 let (_address, _record_id, materialization_id, materialization_root) =
21345 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21346 std::fs::write(
21347 materialization_root.join(".gate4agent-materialization-owner"),
21348 b"tampered",
21349 )
21350 .unwrap();
21351
21352 let error = match shared.resolve_record_materialization(
21353 &_record_id,
21354 &agent("claude"),
21355 SessionMode::Pty,
21356 Some(&receipt),
21357 None,
21358 None,
21359 ) {
21360 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21361 Err(error) => error,
21362 };
21363 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21364 assert_eq!(
21365 shared
21366 .materializations
21367 .lock()
21368 .unwrap_or_else(|poisoned| poisoned.into_inner())
21369 .get(&materialization_id)
21370 .unwrap()
21371 .state(),
21372 MaterializationState::RecoveryRequired,
21373 );
21374 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21375 assert_eq!(loaded.materializations.len(), 1);
21376 assert_eq!(
21377 loaded.materializations[0].state(),
21378 MaterializationState::RecoveryRequired,
21379 );
21380 drop(shared);
21381 drop(runtime);
21382 std::fs::remove_dir_all(root).unwrap();
21383 }
21384
21385 #[test]
21386 fn transient_record_materialization_resolution_failure_remains_ready() {
21387 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21388 let (_address, record_id, materialization_id, _materialization_root) =
21389 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21390 deny.store(true, Ordering::Release);
21391
21392 let error = match shared.resolve_record_materialization(
21393 &record_id,
21394 &agent("claude"),
21395 SessionMode::Pty,
21396 Some(&receipt),
21397 None,
21398 None,
21399 ) {
21400 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21401 Err(error) => error,
21402 };
21403 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21404 assert_eq!(
21405 shared
21406 .materializations
21407 .lock()
21408 .unwrap_or_else(|poisoned| poisoned.into_inner())
21409 .get(&materialization_id)
21410 .unwrap()
21411 .state(),
21412 MaterializationState::Ready,
21413 );
21414 drop(shared);
21415 drop(runtime);
21416 std::fs::remove_dir_all(root).unwrap();
21417 }
21418
21419 #[test]
21420 fn startup_missing_record_materialization_is_not_resumable() {
21421 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21422 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21423 shared
21424 .insert_record(ManagedSessionRecord {
21425 record_id: record_id.clone(),
21426 display_name: "missing materialization".to_owned(),
21427 provider: agent("claude"),
21428 mode: SessionMode::Pty,
21429 state: ManagedSessionState::Dormant,
21430 workspace_id: WorkspaceId::new("primary").unwrap(),
21431 canonical_root: opaque_windows_path(
21432 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21433 ),
21434 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21435 key: gate4agent_types::ProviderSessionKey::SessionId,
21436 id: "provider-session-missing-materialization".to_owned(),
21437 transcript_path: None,
21438 }),
21439 active_session: None,
21440 environment_profile: Some(receipt),
21441 bundle: None,
21442 context_id: None,
21443 context: None,
21444 exported_context: None,
21445 task_binding: None,
21446 created_at_unix_ms: 1,
21447 updated_at_unix_ms: 1,
21448 last_error: None,
21449 })
21450 .unwrap();
21451
21452 shared.reconcile_materializations();
21453 let record = shared.record(&record_id).unwrap();
21454 assert_eq!(record.state, ManagedSessionState::Unavailable);
21455 assert!(record.provider_session.is_none());
21456 assert_eq!(
21457 record.last_error.as_deref(),
21458 Some("environment-profile-unavailable"),
21459 );
21460 drop(shared);
21461 drop(runtime);
21462 std::fs::remove_dir_all(root).unwrap();
21463 }
21464
21465 #[tokio::test]
21466 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21467 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21468 let lease = install_managed_lease(&shared);
21469 let address = SessionAddress {
21470 workspace_id: WorkspaceId::new("primary").unwrap(),
21471 session: SessionKey {
21472 instance_id: AgentInstanceId(204),
21473 generation: SessionGeneration(1),
21474 },
21475 };
21476 let (overlay, materialization_guard) = shared
21477 .prepare_session_materialization(
21478 &address,
21479 &agent("claude"),
21480 SessionMode::Pty,
21481 Some(&receipt),
21482 None,
21483 None,
21484 Some(lease.lease_id.clone()),
21485 )
21486 .unwrap()
21487 .unwrap();
21488 let materialization_id = materialization_guard.id().unwrap().clone();
21489 let materialization_root = shared
21490 .materializations
21491 .lock()
21492 .unwrap_or_else(|poisoned| poisoned.into_inner())
21493 .get(&materialization_id)
21494 .unwrap()
21495 .root()
21496 .to_path_buf();
21497 let selection = shared
21498 .select_environment_profile(
21499 address.session.instance_id,
21500 &agent("claude"),
21501 SessionMode::Pty,
21502 Some(&receipt),
21503 )
21504 .unwrap()
21505 .unwrap();
21506 assert!(shared
21507 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21508 .unwrap()
21509 .is_none());
21510 shared.bind_session_with_materialization(
21511 &address,
21512 ProviderRuntimePolicy::raw_pty(),
21513 Some(receipt),
21514 None,
21515 None,
21516 Some(materialization_id),
21517 );
21518 selection.retain();
21519 materialization_guard.retain();
21520
21521 let blocked = shared
21522 .cleanup_managed_worktree(&lease.lease_id, false)
21523 .await
21524 .unwrap_err();
21525 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21526 assert!(materialization_root.is_dir());
21527 let removed = shared.remove_binding(&address).unwrap();
21528 shared
21529 .cleanup_session_owned_materialization(&address, &removed)
21530 .unwrap();
21531 assert!(!materialization_root.exists());
21532 drop(shared);
21533 drop(runtime);
21534 std::fs::remove_dir_all(root).unwrap();
21535 }
21536
21537 #[tokio::test]
21538 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21539 let git_root = temporary_workspace_root("raw-managed-inventory");
21540 let source = git_root.join("source");
21541 let allocation = git_root.join("allocation");
21542 std::fs::create_dir_all(&source).unwrap();
21543 std::fs::create_dir_all(&allocation).unwrap();
21544 run_test_git(&source, &["init"]);
21545 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21546 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21547 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21548 run_test_git(&source, &["add", "seed.txt"]);
21549 run_test_git(&source, &["commit", "-m", "seed"]);
21550 let profile = ManagedWorktreeProfile::new(
21551 WorktreeProfileId::new("default").unwrap(),
21552 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21553 &allocation,
21554 "gate4agent",
21555 "HEAD",
21556 ManagedWorktreeRetention::RemoveWhenReleased,
21557 )
21558 .unwrap();
21559 let workspace = WorkspaceConfig::new(
21560 WorkspaceId::new("primary").unwrap(),
21561 &source,
21562 )
21563 .unwrap()
21564 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21565 .with_managed_worktree_profile(profile.clone())
21566 .unwrap();
21567 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21568 materialization_test_shared_with_workspace(false, workspace);
21569 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21570 let source_text = source.to_string_lossy().into_owned();
21571 let target_text = target.to_string_lossy().into_owned();
21572 let created = create_git_worktree(
21573 &source_text,
21574 &target_text,
21575 "gate4agent/mw-raw-inventory",
21576 Some("HEAD"),
21577 )
21578 .await
21579 .unwrap();
21580 let lease = ManagedWorktreeLeaseRecord {
21581 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21582 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21583 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21584 profile_id: profile.profile_id().clone(),
21585 profile_revision: profile.revision().clone(),
21586 target_root: created.path.clone(),
21587 branch: "gate4agent/mw-raw-inventory".to_owned(),
21588 base_commit: created.head.clone(),
21589 expected_head: Some(created.head.clone()),
21590 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21591 state: ManagedWorktreeLeaseState::Ready,
21592 session_holders: Vec::new(),
21593 record_holders: Vec::new(),
21594 cleanup_failure: None,
21595 created_at_unix_ms: 1,
21596 updated_at_unix_ms: 1,
21597 };
21598 shared
21599 .workspaces
21600 .write()
21601 .unwrap_or_else(|poisoned| poisoned.into_inner())
21602 .insert(lease.workspace_id.clone(), created.path.clone());
21603 *shared
21604 .managed_worktrees
21605 .lock()
21606 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21607 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21608 let address = SessionAddress {
21609 workspace_id: lease.workspace_id.clone(),
21610 session: SessionKey {
21611 instance_id: AgentInstanceId(205),
21612 generation: SessionGeneration(1),
21613 },
21614 };
21615 let (overlay, materialization_guard) = shared
21616 .prepare_session_materialization(
21617 &address,
21618 &agent("claude"),
21619 SessionMode::Pty,
21620 Some(&receipt),
21621 None,
21622 None,
21623 Some(lease.lease_id.clone()),
21624 )
21625 .unwrap()
21626 .unwrap();
21627 let materialization_id = materialization_guard.id().unwrap().clone();
21628 let materialization_root = shared
21629 .materializations
21630 .lock()
21631 .unwrap_or_else(|poisoned| poisoned.into_inner())
21632 .get(&materialization_id)
21633 .unwrap()
21634 .root()
21635 .to_path_buf();
21636 let selection = shared
21637 .select_environment_profile(
21638 address.session.instance_id,
21639 &agent("claude"),
21640 SessionMode::Pty,
21641 Some(&receipt),
21642 )
21643 .unwrap()
21644 .unwrap();
21645 assert!(shared
21646 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21647 .unwrap()
21648 .is_none());
21649 let record_id = shared
21650 .bind_spawn_session_with_materialization(
21651 &address,
21652 agent("claude"),
21653 SessionMode::Pty,
21654 ProviderRuntimePolicy::raw_pty(),
21655 SpawnRecordPolicy::Always,
21656 Some(receipt.clone()),
21657 None,
21658 None,
21659 Some(materialization_id.clone()),
21660 )
21661 .unwrap()
21662 .unwrap();
21663 selection.retain();
21664 materialization_guard.retain();
21665 let record = shared.record(&record_id).unwrap();
21666 assert_eq!(record.state, ManagedSessionState::Live);
21667 assert!(record.provider_session.is_none());
21668 assert!(record.environment_profile.is_none());
21669 assert!(record.bundle.is_none());
21670 assert!(record.context_id.is_none());
21671 assert!(record.context.is_none());
21672 assert_eq!(record.active_session.as_ref(), Some(&address));
21673 assert_eq!(
21674 shared
21675 .materializations
21676 .lock()
21677 .unwrap_or_else(|poisoned| poisoned.into_inner())
21678 .get(&materialization_id)
21679 .unwrap()
21680 .owner(),
21681 &MaterializationOwner::Session {
21682 incarnation_id: shared.incarnation_id,
21683 instance_id: address.session.instance_id,
21684 generation: address.session.generation,
21685 },
21686 );
21687 {
21688 let mut bindings = shared
21689 .session_bindings
21690 .lock()
21691 .unwrap_or_else(|poisoned| poisoned.into_inner());
21692 bindings
21693 .get_mut(&address.session.instance_id)
21694 .unwrap()
21695 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21696 }
21697 let lease_snapshot = shared
21698 .managed_worktrees
21699 .lock()
21700 .unwrap_or_else(|poisoned| poisoned.into_inner())
21701 .bind_session(
21702 &lease.lease_id,
21703 ManagedWorktreeSessionHolder {
21704 incarnation_id: shared.incarnation_id,
21705 instance_id: address.session.instance_id,
21706 generation: address.session.generation,
21707 },
21708 None,
21709 unix_time_ms(),
21710 )
21711 .unwrap();
21712 assert_eq!(lease_snapshot.active_session_count, 1);
21713 assert_eq!(lease_snapshot.managed_record_count, 0);
21714 shared.persist_state().unwrap();
21715 assert!(shared
21716 .managed_worktrees
21717 .lock()
21718 .unwrap_or_else(|poisoned| poisoned.into_inner())
21719 .get(&lease.lease_id)
21720 .unwrap()
21721 .record_holders
21722 .is_empty());
21723 shared
21724 .handle
21725 .dispatch(shared.prepare_command(ControlCommand::Register {
21726 instance_id: address.session.instance_id,
21727 agent_id: agent("claude"),
21728 transport: TransportKind::Pty,
21729 }))
21730 .unwrap();
21731 runtime.tick().await;
21732 shared.reconcile_managed_record(
21733 &record_id,
21734 &address,
21735 &ControlEvent {
21736 sequence: 1,
21737 command_id: None,
21738 instance_id: address.session.instance_id,
21739 generation: address.session.generation,
21740 event: ControlEventKind::Removed,
21741 },
21742 false,
21743 false,
21744 );
21745 let remove = shared.remove_session(&address);
21746 let drive_runtime = async {
21747 runtime.tick().await;
21748 };
21749 let (removed, ()) = tokio::join!(remove, drive_runtime);
21750 removed.unwrap();
21751
21752 assert!(!materialization_root.exists());
21753 assert!(!Path::new(&created.path).exists());
21754 assert_eq!(
21755 shared
21756 .managed_worktrees
21757 .lock()
21758 .unwrap_or_else(|poisoned| poisoned.into_inner())
21759 .get(&lease.lease_id)
21760 .unwrap()
21761 .state,
21762 ManagedWorktreeLeaseState::Removed,
21763 );
21764 let renamed = shared
21765 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21766 .unwrap();
21767 assert_eq!(renamed.record_id, record_id);
21768 assert_eq!(renamed.display_name, "raw managed inventory");
21769 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21770 assert!(renamed.active_session.is_none());
21771
21772 drop(shared);
21773 drop(runtime);
21774 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21775 std::fs::remove_dir_all(git_root).unwrap();
21776 }
21777
21778 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21779 let root = std::env::current_dir().unwrap()
21780 .join("managed-test-target")
21781 .to_string_lossy()
21782 .into_owned();
21783 let lease = ManagedWorktreeLeaseRecord {
21784 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21785 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21786 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21787 profile_id: WorktreeProfileId::new("default").unwrap(),
21788 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21789 target_root: root.clone(),
21790 branch: "gate4agent/mw-test".to_owned(),
21791 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21792 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21793 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21794 state: ManagedWorktreeLeaseState::Ready,
21795 session_holders: Vec::new(),
21796 record_holders: Vec::new(),
21797 cleanup_failure: None,
21798 created_at_unix_ms: 1,
21799 updated_at_unix_ms: 1,
21800 };
21801 shared.workspaces.write()
21802 .unwrap_or_else(|poisoned| poisoned.into_inner())
21803 .insert(lease.workspace_id.clone(), root);
21804 *shared.managed_worktrees.lock()
21805 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21806 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21807 lease
21808 }
21809
21810 #[cfg(feature = "fixture")]
21811 #[test]
21812 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21813 let mut shared = terminal_test_shared();
21814 shared.fixture_semantic_hook_policy = true;
21815 let lease = install_managed_lease(&shared);
21816 let address = SessionAddress {
21817 workspace_id: lease.workspace_id.clone(),
21818 session: SessionKey {
21819 instance_id: AgentInstanceId(206),
21820 generation: SessionGeneration(1),
21821 },
21822 };
21823 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21824 let effective_runtime_policy =
21825 shared.effective_spawn_runtime_policy(pre_admission_policy);
21826 assert_ne!(effective_runtime_policy, pre_admission_policy);
21827 assert!(effective_runtime_policy.provider_session_identity);
21828
21829 let record_id = shared
21830 .bind_spawn_session(
21831 &address,
21832 agent("claude"),
21833 SessionMode::Pty,
21834 effective_runtime_policy,
21835 SpawnRecordPolicy::Always,
21836 None,
21837 )
21838 .unwrap()
21839 .unwrap();
21840 assert_eq!(
21841 shared.record(&record_id).unwrap().state,
21842 ManagedSessionState::IdentityPending,
21843 );
21844 let (record_holder, raw_inventory_record_id) = {
21845 let mut bindings = shared
21846 .session_bindings
21847 .lock()
21848 .unwrap_or_else(|poisoned| poisoned.into_inner());
21849 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21850 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21851 managed_spawn_record_ownership(binding, effective_runtime_policy)
21852 };
21853 assert_eq!(record_holder.as_ref(), Some(&record_id));
21854 assert!(raw_inventory_record_id.is_none());
21855 let snapshot = shared
21856 .managed_worktrees
21857 .lock()
21858 .unwrap_or_else(|poisoned| poisoned.into_inner())
21859 .bind_session(
21860 &lease.lease_id,
21861 ManagedWorktreeSessionHolder {
21862 incarnation_id: shared.incarnation_id,
21863 instance_id: address.session.instance_id,
21864 generation: address.session.generation,
21865 },
21866 record_holder,
21867 unix_time_ms(),
21868 )
21869 .unwrap();
21870 assert_eq!(snapshot.managed_record_count, 1);
21871 assert_eq!(
21872 shared
21873 .managed_worktrees
21874 .lock()
21875 .unwrap_or_else(|poisoned| poisoned.into_inner())
21876 .get(&lease.lease_id)
21877 .unwrap()
21878 .record_holders,
21879 vec![record_id.clone()],
21880 );
21881
21882 let identity = ProviderSessionIdentity {
21883 key: ProviderSessionKey::SessionId,
21884 id: "monitoring-fixture-managed-session".to_owned(),
21885 transcript_path: None,
21886 };
21887 shared.publish_control(ControlEvent {
21888 sequence: 1,
21889 command_id: None,
21890 instance_id: address.session.instance_id,
21891 generation: address.session.generation,
21892 event: ControlEventKind::ProviderEvent {
21893 sequence: 1,
21894 source: gate4agent_types::ProviderSource {
21895 family: AdapterFamily::Hook,
21896 binding: AdapterBinding::new(
21897 gate4agent_types::AdapterId::new("claude-code").unwrap(),
21898 "fixture/v1",
21899 gate4agent_types::AdapterVerification::SyntheticFixture,
21900 )
21901 .unwrap(),
21902 },
21903 source_sequence: 1,
21904 event: ProviderEvent::SessionIdentityObserved {
21905 identity: identity.clone(),
21906 },
21907 },
21908 });
21909
21910 let live = shared.record(&record_id).unwrap();
21911 assert_eq!(live.state, ManagedSessionState::Live);
21912 assert_eq!(live.provider_session.as_ref(), Some(&identity));
21913 assert_eq!(live.active_session.as_ref(), Some(&address));
21914 assert_eq!(
21915 shared
21916 .managed_worktrees
21917 .lock()
21918 .unwrap_or_else(|poisoned| poisoned.into_inner())
21919 .get(&lease.lease_id)
21920 .unwrap()
21921 .record_holders,
21922 vec![record_id],
21923 );
21924 }
21925
21926 fn managed_v2_test_shared(
21927 allocation_root: &Path,
21928 state_path: Option<PathBuf>,
21929 profile_revision: &str,
21930 ) -> NodeShared {
21931 let profile = ManagedWorktreeProfile::new(
21932 WorktreeProfileId::new("default").unwrap(),
21933 WorktreeProfileRevision::new(profile_revision).unwrap(),
21934 allocation_root,
21935 "gate4agent",
21936 "HEAD",
21937 ManagedWorktreeRetention::RemoveWhenReleased,
21938 )
21939 .unwrap();
21940 let workspace = WorkspaceConfig::new(
21941 WorkspaceId::new("primary").unwrap(),
21942 std::env::current_dir().unwrap(),
21943 )
21944 .unwrap()
21945 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21946 .with_managed_worktree_profile(profile)
21947 .unwrap();
21948 let catalog = active_registry().unwrap();
21949 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21950 let mut shared = NodeShared::new(
21951 handle,
21952 "fixture-token".to_owned(),
21953 NodeId::new("node-terminal-test").unwrap(),
21954 vec![workspace],
21955 vec![agent("claude")],
21956 );
21957 shared.state_path = state_path;
21958 shared
21959 }
21960
21961 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21962 let mut spawn_spec = spawn_spec_fixture();
21963 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21964 ManagedWorktreeSpawnRequestV2 {
21965 spawn_spec,
21966 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21967 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21968 }
21969 }
21970
21971 #[test]
21972 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21973 let shared = terminal_test_shared();
21974 let mut lease = install_managed_lease(&shared);
21975 let session = SessionAddress {
21976 workspace_id: lease.workspace_id.clone(),
21977 session: SessionKey {
21978 instance_id: AgentInstanceId(73),
21979 generation: SessionGeneration(4),
21980 },
21981 };
21982 lease.state = ManagedWorktreeLeaseState::InUse;
21983 lease.session_holders.push(ManagedWorktreeSessionHolder {
21984 incarnation_id: shared.incarnation_id,
21985 instance_id: session.session.instance_id,
21986 generation: session.session.generation,
21987 });
21988 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21989 assert!(resolved.target.worktree_id.is_none());
21990
21991 let receipt = managed_spawn_receipt(
21992 &resolved,
21993 shared.incarnation_id,
21994 session.clone(),
21995 lease.snapshot(),
21996 None,
21997 None,
21998 None,
21999 );
22000 assert!(resolved.target.worktree_id.is_none());
22001 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22002 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22003 assert_eq!(receipt.spawn.session, session);
22004 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22005 let encoded = serde_json::to_vec(&receipt).unwrap();
22006 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22007 assert_eq!(decoded, receipt);
22008 }
22009
22010 #[tokio::test]
22011 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22012 let root = temporary_workspace_root("managed-v2-revision");
22013 let allocation = root.join("allocation");
22014 std::fs::create_dir_all(&allocation).unwrap();
22015 let shared = managed_v2_test_shared(
22016 &allocation,
22017 Some(root.join("node-state.json")),
22018 "v1",
22019 );
22020 let mut request = managed_v2_request("managed-v2-revision");
22021 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22022
22023 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22024
22025 assert_eq!(
22026 failure.code,
22027 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22028 );
22029 assert!(shared
22030 .managed_worktrees
22031 .lock()
22032 .unwrap_or_else(|poisoned| poisoned.into_inner())
22033 .records()
22034 .is_empty());
22035 assert!(shared
22036 .managed_spawn_replays
22037 .lock()
22038 .unwrap_or_else(|poisoned| poisoned.into_inner())
22039 .is_empty());
22040 std::fs::remove_dir_all(root).unwrap();
22041 }
22042
22043 #[test]
22044 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22045 let root = temporary_workspace_root("managed-v2-concurrent");
22046 let allocation = root.join("allocation");
22047 std::fs::create_dir_all(&allocation).unwrap();
22048 let shared = Arc::new(managed_v2_test_shared(
22049 &allocation,
22050 Some(root.join("node-state.json")),
22051 "v1",
22052 ));
22053 let request = managed_v2_request("managed-v2-concurrent");
22054 let barrier = Arc::new(std::sync::Barrier::new(3));
22055 let mut joins = Vec::new();
22056 for _ in 0..2 {
22057 let shared = Arc::clone(&shared);
22058 let request = request.clone();
22059 let barrier = Arc::clone(&barrier);
22060 joins.push(std::thread::spawn(move || {
22061 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22062 barrier.wait();
22063 shared.acquire_durable_managed_spawn_v2(&request, digest)
22064 }));
22065 }
22066 barrier.wait();
22067 let results = joins
22068 .into_iter()
22069 .map(|join| join.join().unwrap())
22070 .collect::<Vec<_>>();
22071 assert_eq!(
22072 results
22073 .iter()
22074 .filter(|result| {
22075 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22076 })
22077 .count(),
22078 1,
22079 );
22080 assert_eq!(
22081 results
22082 .iter()
22083 .filter(|result| {
22084 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22085 })
22086 .count(),
22087 1,
22088 );
22089 assert_eq!(
22090 shared
22091 .managed_spawn_replays
22092 .lock()
22093 .unwrap_or_else(|poisoned| poisoned.into_inner())
22094 .len(),
22095 1,
22096 );
22097 assert!(shared
22098 .managed_worktrees
22099 .lock()
22100 .unwrap_or_else(|poisoned| poisoned.into_inner())
22101 .records()
22102 .is_empty());
22103 drop(shared);
22104 std::fs::remove_dir_all(root).unwrap();
22105 }
22106
22107 #[tokio::test]
22108 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22109 let root = temporary_workspace_root("managed-v2-linked-pending");
22110 let allocation = root.join("allocation");
22111 std::fs::create_dir_all(&allocation).unwrap();
22112 let state_path = root.join("node-state.json");
22113 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22114 let lease = install_managed_lease(&shared);
22115 let request = managed_v2_request("managed-v2-linked-pending");
22116 assert!(matches!(
22117 shared
22118 .acquire_durable_managed_spawn_v2(
22119 &request,
22120 managed_spawn_request_digest_v2(&request).unwrap(),
22121 )
22122 .unwrap(),
22123 DurableManagedSpawnReplayDecision::Reserved,
22124 ));
22125 {
22126 let _transaction = shared
22127 .state_transaction
22128 .lock()
22129 .unwrap_or_else(|poisoned| poisoned.into_inner());
22130 shared
22131 .link_durable_managed_spawn_v2_lease_locked(
22132 &request.spawn_spec.idempotency_key,
22133 &lease.lease_id,
22134 )
22135 .unwrap();
22136 shared.persist_state_locked().unwrap();
22137 }
22138 drop(shared);
22139
22140 let mut loaded = session_registry::load(
22141 Some(&state_path),
22142 &NodeId::new("node-terminal-test").unwrap(),
22143 )
22144 .unwrap();
22145 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22146 *reopened
22147 .managed_worktrees
22148 .lock()
22149 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22150 ManagedWorktreeRegistry::from_records(
22151 std::mem::take(&mut loaded.managed_worktrees),
22152 std::mem::take(&mut loaded.managed_worktree_tombstones),
22153 )
22154 .unwrap();
22155 *reopened
22156 .managed_spawn_replays
22157 .lock()
22158 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22159 .managed_spawn_replays
22160 .into_iter()
22161 .map(|record| (record.idempotency_key.clone(), record))
22162 .collect();
22163 let lease_count = reopened
22164 .managed_worktrees
22165 .lock()
22166 .unwrap_or_else(|poisoned| poisoned.into_inner())
22167 .records()
22168 .len();
22169
22170 let failure = reopened
22171 .spawn_managed_worktree_v2(request)
22172 .await
22173 .unwrap_err();
22174
22175 assert_eq!(
22176 failure.code,
22177 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22178 );
22179 assert_eq!(
22180 reopened
22181 .managed_worktrees
22182 .lock()
22183 .unwrap_or_else(|poisoned| poisoned.into_inner())
22184 .records()
22185 .len(),
22186 lease_count,
22187 );
22188 std::fs::remove_dir_all(root).unwrap();
22189 }
22190
22191 #[tokio::test]
22192 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22193 let root = temporary_workspace_root("managed-v2-reopen");
22194 let allocation = root.join("allocation");
22195 std::fs::create_dir_all(&allocation).unwrap();
22196 let state_path = root.join("node-state.json");
22197 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22198 let lease = install_managed_lease(&shared);
22199 let request = managed_v2_request("managed-v2-reopen");
22200 let session = SessionAddress {
22201 workspace_id: lease.workspace_id.clone(),
22202 session: SessionKey {
22203 instance_id: AgentInstanceId(91),
22204 generation: SessionGeneration(1),
22205 },
22206 };
22207 let snapshot = {
22208 let mut registry = shared
22209 .managed_worktrees
22210 .lock()
22211 .unwrap_or_else(|poisoned| poisoned.into_inner());
22212 let current = registry.get_mut(&lease.lease_id).unwrap();
22213 current.state = ManagedWorktreeLeaseState::InUse;
22214 current.session_holders.push(ManagedWorktreeSessionHolder {
22215 incarnation_id: shared.incarnation_id,
22216 instance_id: session.session.instance_id,
22217 generation: session.session.generation,
22218 });
22219 current.snapshot()
22220 };
22221 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22222 let receipt = managed_spawn_receipt(
22223 &resolved,
22224 shared.incarnation_id,
22225 session,
22226 snapshot,
22227 None,
22228 None,
22229 None,
22230 );
22231 assert!(matches!(
22232 shared
22233 .acquire_durable_managed_spawn_v2(
22234 &request,
22235 managed_spawn_request_digest_v2(&request).unwrap(),
22236 )
22237 .unwrap(),
22238 DurableManagedSpawnReplayDecision::Reserved,
22239 ));
22240 {
22241 let _transaction = shared
22242 .state_transaction
22243 .lock()
22244 .unwrap_or_else(|poisoned| poisoned.into_inner());
22245 shared
22246 .link_durable_managed_spawn_v2_lease_locked(
22247 &request.spawn_spec.idempotency_key,
22248 &lease.lease_id,
22249 )
22250 .unwrap();
22251 shared.persist_state_locked().unwrap();
22252 }
22253 shared
22254 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22255 .unwrap();
22256 let legacy_same_key = ManagedWorktreeSpawnRequest {
22257 spawn_spec: request.spawn_spec.clone(),
22258 worktree_profile_id: request.worktree_profile_id.clone(),
22259 };
22260 shared.remember_managed_spawn_attempt(
22261 Some(&request.spawn_spec.idempotency_key),
22262 legacy_same_key.clone(),
22263 Ok(receipt.clone()),
22264 Instant::now(),
22265 );
22266 assert!(shared
22267 .replay_managed_spawn(&legacy_same_key, Instant::now())
22268 .unwrap()
22269 .is_none());
22270 drop(shared);
22271
22272 let mut loaded = session_registry::load(
22273 Some(&state_path),
22274 &NodeId::new("node-terminal-test").unwrap(),
22275 )
22276 .unwrap();
22277 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22278 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22279 *reopened
22280 .managed_worktrees
22281 .lock()
22282 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22283 ManagedWorktreeRegistry::from_records(
22284 std::mem::take(&mut loaded.managed_worktrees),
22285 std::mem::take(&mut loaded.managed_worktree_tombstones),
22286 )
22287 .unwrap();
22288 *reopened
22289 .managed_spawn_replays
22290 .lock()
22291 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22292 .managed_spawn_replays
22293 .into_iter()
22294 .map(|record| (record.idempotency_key.clone(), record))
22295 .collect();
22296 let lease_count_before = reopened
22297 .managed_worktrees
22298 .lock()
22299 .unwrap_or_else(|poisoned| poisoned.into_inner())
22300 .records()
22301 .len();
22302
22303 assert_eq!(
22304 reopened
22305 .spawn_managed_worktree_v2(request.clone())
22306 .await
22307 .unwrap(),
22308 receipt,
22309 );
22310 assert_eq!(
22311 reopened
22312 .managed_worktrees
22313 .lock()
22314 .unwrap_or_else(|poisoned| poisoned.into_inner())
22315 .records()
22316 .len(),
22317 lease_count_before,
22318 );
22319 let mut changed = request;
22320 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22321 let failure = reopened
22322 .spawn_managed_worktree_v2(changed)
22323 .await
22324 .unwrap_err();
22325 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22326 assert_eq!(
22327 reopened
22328 .managed_worktrees
22329 .lock()
22330 .unwrap_or_else(|poisoned| poisoned.into_inner())
22331 .records()
22332 .len(),
22333 lease_count_before,
22334 );
22335 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22336 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22337 let failure = reopened
22338 .spawn_managed_worktree_v2(new_key)
22339 .await
22340 .unwrap_err();
22341 assert_eq!(
22342 failure.code,
22343 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22344 );
22345 assert_eq!(
22346 reopened
22347 .managed_worktrees
22348 .lock()
22349 .unwrap_or_else(|poisoned| poisoned.into_inner())
22350 .records()
22351 .len(),
22352 lease_count_before,
22353 );
22354 assert_eq!(
22355 reopened
22356 .managed_spawn_replays
22357 .lock()
22358 .unwrap_or_else(|poisoned| poisoned.into_inner())
22359 .len(),
22360 1,
22361 );
22362 std::fs::remove_dir_all(root).unwrap();
22363 }
22364
22365 #[test]
22366 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22367 let shared = terminal_test_shared();
22368 let request = ManagedWorktreeSpawnRequest {
22369 spawn_spec: spawn_spec_fixture(),
22370 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22371 };
22372 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22373 shared.remember_managed_spawn(
22374 request.clone(),
22375 Err(expected.clone()),
22376 Instant::now(),
22377 );
22378
22379 assert_eq!(
22380 shared
22381 .replay_managed_spawn(&request, Instant::now())
22382 .unwrap()
22383 .unwrap(),
22384 Err(expected),
22385 );
22386 assert!(shared
22387 .managed_spawn_replays
22388 .lock()
22389 .unwrap_or_else(|poisoned| poisoned.into_inner())
22390 .is_empty());
22391 }
22392
22393 #[tokio::test]
22394 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22395 let shared = terminal_test_shared();
22396 let lease = install_managed_lease(&shared);
22397 let raw = shared.spawn_session(
22398 lease.workspace_id.clone(),
22399 agent("claude"),
22400 SessionMode::Pty,
22401 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22402 None,
22403 ).await.unwrap_err();
22404 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22405 let mut spec = spawn_spec_fixture();
22406 spec.target.workspace_id = lease.workspace_id.clone();
22407 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22408 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22409 let mut selected = spawn_spec_fixture();
22410 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22411 selected.target.worktree_id = Some(lease.workspace_id);
22412 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22413 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22414 assert!(shared.handle.snapshot().sessions.is_empty());
22415 }
22416
22417 #[test]
22418 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22419 let shared = terminal_test_shared();
22420 let lease = install_managed_lease(&shared);
22421 let snapshot = snapshot_for_wire(
22422 &shared, true, true, true, false, true, true, false, false, true,
22423 );
22424 assert!(snapshot.managed_worktrees.is_empty());
22425 assert!(snapshot.workspaces.iter().all(|workspace| {
22426 workspace.managed_worktree_profiles.is_none()
22427 }));
22428 let mut reply = ResponseEnvelope {
22429 request_id: 1,
22430 result: Ok(NodeResponse::Resync {
22431 event_sequence: 1,
22432 oldest_available_sequence: 1,
22433 snapshot: shared.snapshot(),
22434 events: vec![NodeEventEnvelope {
22435 sequence: 1,
22436 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22437 }],
22438 }),
22439 };
22440 project_response_without_managed_worktrees(&mut reply);
22441 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22442 panic!("projection changed the response kind");
22443 };
22444 assert!(snapshot.managed_worktrees.is_empty());
22445 assert!(events.is_empty());
22446 let mut git = git_snapshot_for_parser();
22447 git.branch = Some(lease.branch.clone());
22448 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22449 lease_id: lease.lease_id.clone(),
22450 source_workspace_id: lease.source_workspace_id.clone(),
22451 branch: lease.branch.clone(),
22452 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22453 active_session_count: 1,
22454 managed_record_count: 0,
22455 });
22456 let mut inspection_reply = ResponseEnvelope {
22457 request_id: 2,
22458 result: Ok(NodeResponse::WorkspaceInspected {
22459 inspection: WorkspaceInspection {
22460 workspace_id: lease.workspace_id.clone(),
22461 entries: Vec::new(),
22462 tree_truncated: false,
22463 git,
22464 truncation: None,
22465 },
22466 }),
22467 };
22468 project_response_without_managed_worktrees(&mut inspection_reply);
22469 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22470 panic!("projection changed the inspection response kind");
22471 };
22472 assert!(inspection.git.managed_worktree.is_none());
22473 let failure = managed_git_worktree_failure(GitWorktreeError {
22474 kind: GitWorktreeErrorKind::Failed,
22475 message: r"git failed at C:\private\managed-target".to_owned(),
22476 });
22477 assert!(!failure.message.contains("private"));
22478 assert!(!failure.message.contains("managed-target"));
22479 }
22480
22481 #[tokio::test]
22482 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22483 let root = temporary_workspace_root("managed-restart");
22484 let source = root.join("source");
22485 let allocation = root.join("allocation");
22486 std::fs::create_dir_all(&source).unwrap();
22487 std::fs::create_dir_all(&allocation).unwrap();
22488 run_test_git(&source, &["init"]);
22489 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22490 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22491 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22492 run_test_git(&source, &["add", "seed.txt"]);
22493 run_test_git(&source, &["commit", "-m", "seed"]);
22494 let profile = ManagedWorktreeProfile::new(
22495 WorktreeProfileId::new("default").unwrap(),
22496 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22497 &allocation,
22498 "gate4agent",
22499 "HEAD",
22500 ManagedWorktreeRetention::Retain,
22501 ).unwrap();
22502 let workspace = WorkspaceConfig::new(
22503 WorkspaceId::new("primary").unwrap(),
22504 &source,
22505 ).unwrap()
22506 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22507 .with_managed_worktree_profile(profile.clone()).unwrap();
22508 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22509 let source_text = workspace.canonical_root().to_owned();
22510 let target_text = target.to_string_lossy().into_owned();
22511 let created = create_git_worktree(
22512 &source_text,
22513 &target_text,
22514 "gate4agent/mw-restart",
22515 Some("HEAD"),
22516 ).await.unwrap();
22517 let lease = ManagedWorktreeLeaseRecord {
22518 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22519 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22520 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22521 profile_id: profile.profile_id().clone(),
22522 profile_revision: profile.revision().clone(),
22523 target_root: created.path.clone(),
22524 branch: "gate4agent/mw-restart".to_owned(),
22525 base_commit: created.head.clone(),
22526 expected_head: None,
22527 retention: ManagedWorktreeRetention::Retain,
22528 state: ManagedWorktreeLeaseState::Allocating,
22529 session_holders: Vec::new(),
22530 record_holders: Vec::new(),
22531 cleanup_failure: None,
22532 created_at_unix_ms: 1,
22533 updated_at_unix_ms: 1,
22534 };
22535 let catalog = active_registry().unwrap();
22536 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22537 let shared = NodeShared::new_with_incarnation(
22538 handle,
22539 "fixture-token".to_owned(),
22540 NodeId::new("node-managed-restart").unwrap(),
22541 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22542 vec![workspace],
22543 vec![agent("claude")],
22544 ProviderRuntimeStatuses::default(),
22545 None,
22546 Vec::new(),
22547 Vec::new(),
22548 SpawnProfileRegistry::default(),
22549 None,
22550 None,
22551 None,
22552 None,
22553 Vec::new(),
22554 vec![lease.clone()],
22555 Vec::new(),
22556 Vec::new(),
22557 None,
22558 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22559 );
22560 shared.reconcile_managed_worktrees().await;
22561 let retained = shared.managed_worktrees.lock()
22562 .unwrap_or_else(|poisoned| poisoned.into_inner())
22563 .get(&lease.lease_id).unwrap().clone();
22564 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22565 assert!(shared.workspaces.read()
22566 .unwrap_or_else(|poisoned| poisoned.into_inner())
22567 .contains_key(&lease.workspace_id));
22568 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22569 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22570 assert!(!Path::new(&created.path).exists());
22571 std::fs::remove_dir_all(root).unwrap();
22572 }
22573
22574 fn run_test_git(root: &Path, arguments: &[&str]) {
22575 let output = std::process::Command::new("git")
22576 .arg("-C").arg(root).args(arguments).output().unwrap();
22577 assert!(
22578 output.status.success(),
22579 "git {:?} failed: {}",
22580 arguments,
22581 String::from_utf8_lossy(&output.stderr),
22582 );
22583 }
22584
22585 #[tokio::test]
22586 async fn spawn_spec_validation_fails_before_session_mutation() {
22587 let shared = terminal_test_shared();
22588 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22589 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22590 }));
22591 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22592 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22593 }));
22594 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22595 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22596 }));
22597
22598 let mut target_mismatch = spawn_spec_fixture();
22599 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22600 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22601 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22602
22603 let mut unknown_profile = spawn_spec_fixture();
22604 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22605 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22606 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22607 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22608
22609 let mut revision_mismatch = spawn_spec_fixture();
22610 revision_mismatch.idempotency_key =
22611 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22612 revision_mismatch.expected_profile_revision =
22613 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22614 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22615 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22616
22617 let mut unsupported_materializer = spawn_spec_fixture();
22618 unsupported_materializer.idempotency_key =
22619 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22620 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22621 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22622 };
22623 let failure = shared
22624 .spawn_from_spec(unsupported_materializer)
22625 .await
22626 .unwrap_err();
22627 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22628
22629 let failure = shared
22630 .spawn_session_with_deadline(
22631 WorkspaceId::new("primary").unwrap(),
22632 agent("claude"),
22633 SessionMode::Pty,
22634 gate4agent_types::TerminalSize {
22635 rows: 24,
22636 columns: 80,
22637 },
22638 None,
22639 None,
22640 None,
22641 None,
22642 None,
22643 None,
22644 SpawnRecordPolicy::ProviderIdentityOnly,
22645 Some(Instant::now()),
22646 &[],
22647 None,
22648 ApprovalLevel::default(),
22649 None,
22650 None,
22651 )
22652 .await
22653 .unwrap_err();
22654 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22655 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22656 assert!(shared.handle.snapshot().sessions.is_empty());
22657 }
22658
22659 #[test]
22660 fn worktree_selection_requires_its_separate_negotiated_capability() {
22661 let mut spec = spawn_spec_fixture();
22662 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22663 let request = NodeRequest::SpawnSpec { spec };
22664 let spawn_spec_capability =
22665 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22666 assert!(request_uses_unnegotiated_capability(
22667 &request,
22668 &[spawn_spec_capability.clone()],
22669 ));
22670 let worktree_capability =
22671 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22672 assert!(request_uses_unnegotiated_capability(
22673 &request,
22674 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22675 ));
22676 let profile_revision_capability =
22677 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22678 assert!(request_uses_unnegotiated_capability(
22679 &request,
22680 &[
22681 spawn_spec_capability.clone(),
22682 worktree_capability.clone(),
22683 profile_revision_capability.clone(),
22684 ],
22685 ));
22686 let bundle_materialization_capability =
22693 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22694 assert!(request_uses_unnegotiated_capability(
22695 &request,
22696 &[
22697 spawn_spec_capability.clone(),
22698 worktree_capability.clone(),
22699 profile_revision_capability.clone(),
22700 bundle_materialization_capability.clone(),
22701 ],
22702 ));
22703 let history_context_pack_capability =
22704 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22705 assert!(!request_uses_unnegotiated_capability(
22706 &request,
22707 &[
22708 spawn_spec_capability,
22709 worktree_capability,
22710 profile_revision_capability,
22711 bundle_materialization_capability,
22712 history_context_pack_capability,
22713 ],
22714 ));
22715 }
22716
22717 #[tokio::test]
22718 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22719 let mut shared = terminal_test_shared();
22720 let spec = spawn_spec_fixture();
22721 let receipt = shared
22722 .resolve_spawn_spec(&spec)
22723 .unwrap()
22724 .receipt(shared.incarnation_id, terminal_address(1));
22725 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22726
22727 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22728 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22729 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22730
22731 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22732
22733 let mut revision_conflicting = spec.clone();
22734 revision_conflicting.expected_profile_revision =
22735 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22736 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22737 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22738
22739 let mut stale_cache_miss = spec.clone();
22740 stale_cache_miss.idempotency_key =
22741 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22742 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22743 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22744
22745 let mut conflicting = spec;
22746 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22747 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22748 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22749 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22750 assert!(shared.handle.snapshot().sessions.is_empty());
22751 }
22752
22753 #[tokio::test]
22754 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22755 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22756 let mut spec = spawn_spec_fixture();
22757 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22758 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22759 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22760
22761 let failure = shared
22762 .create_worktree(
22763 WorkspaceId::new("primary").unwrap(),
22764 WorkspaceId::new("new-worktree").unwrap(),
22765 "not-an-absolute-path".to_owned(),
22766 "invalid branch".to_owned(),
22767 None,
22768 )
22769 .await
22770 .unwrap_err();
22771 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22772
22773 let failure = shared
22774 .remove_worktree(
22775 WorkspaceId::new("primary").unwrap(),
22776 "not-an-absolute-path".to_owned(),
22777 )
22778 .await
22779 .unwrap_err();
22780 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22781 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22782 assert!(shared.handle.snapshot().sessions.is_empty());
22783 }
22784
22785 #[tokio::test]
22786 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22787 let root = temporary_workspace_root("manual-worktree-compensation");
22788 let source = root.join("source");
22789 let target = root.join("target");
22790 let invalid_state_parent = root.join("state-parent-is-a-file");
22791 let invalid_state_path = invalid_state_parent.join("state.json");
22792 std::fs::create_dir_all(&source).unwrap();
22793 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22794 run_test_git(&source, &["init"]);
22795 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22796 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22797 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22798 run_test_git(&source, &["add", "seed.txt"]);
22799 run_test_git(&source, &["commit", "-m", "seed"]);
22800
22801 let catalog = active_registry().unwrap();
22802 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22803 let workspace = WorkspaceConfig::new(
22804 WorkspaceId::new("primary").unwrap(),
22805 &source,
22806 ).unwrap();
22807 let mut shared = NodeShared::new(
22808 handle,
22809 "fixture-token".to_owned(),
22810 NodeId::new("node-worktree-compensation").unwrap(),
22811 vec![workspace],
22812 vec![agent("claude")],
22813 );
22814 shared.state_path = Some(invalid_state_path);
22815
22816 let error = shared.create_worktree(
22817 WorkspaceId::new("primary").unwrap(),
22818 WorkspaceId::new("compensated").unwrap(),
22819 target.to_string_lossy().into_owned(),
22820 "gate4agent/compensated".to_owned(),
22821 Some("HEAD".to_owned()),
22822 ).await.unwrap_err();
22823
22824 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22825 assert!(!target.exists());
22826 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22827 .iter()
22828 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22829 std::fs::remove_dir_all(root).unwrap();
22830 }
22831
22832 #[test]
22833 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22834 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22835 NativeGitWorktreeSnapshot {
22836 path: path.to_owned(),
22837 head: "0123456789abcdef".to_owned(),
22838 branch: Some("refs/heads/test".to_owned()),
22839 is_bare,
22840 is_main,
22841 locked: false,
22842 lock_reason: None,
22843 prunable: false,
22844 prunable_reason: None,
22845 workspace_id: None,
22846 }
22847 }
22848
22849 let source = r"C:\repo";
22850 let selected = r"C:\repo-worktree";
22851 let listed = vec![
22852 worktree(source, true, false),
22853 worktree(selected, false, false),
22854 ];
22855 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22856
22857 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22858 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22859
22860 let bare = vec![
22861 worktree(source, true, false),
22862 worktree(selected, false, true),
22863 ];
22864 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22865 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22866
22867 let foreign_failure =
22868 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22869 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22870 }
22871
22872 fn terminal_frame(sequence: u64) -> TerminalFrame {
22873 TerminalFrame {
22874 sequence,
22875 size: gate4agent_types::TerminalSize {
22876 rows: 24,
22877 columns: 80,
22878 },
22879 cursor_row: 2,
22880 cursor_column: 3,
22881 contents: format!("frame-{sequence}"),
22882 formatted: vec![1, 2, 3],
22883 scrollback_formatted: Vec::new(),
22884 alternate_screen: false,
22885 mouse_protocol_enabled: false,
22886 mouse_protocol_encoding: Default::default(),
22887 produced_at_unix_ms: 0,
22888 screen_state: gate4agent_types::PtyScreenState::default(),
22889 bracketed_paste: None,
22890 }
22891 }
22892
22893 #[test]
22894 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22895 let shared = terminal_test_shared();
22896 let mut receiver = shared.terminal_event_tx.subscribe();
22897 let original = terminal_address(1);
22898
22899 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22900 let first = receiver.try_recv().unwrap();
22901 assert_eq!(first.len(), 1);
22902 assert!(matches!(
22903 &first[0].event,
22904 NodeEvent::TerminalFrame { address, frame }
22905 if address == &original && frame.sequence == 7
22906 ));
22907
22908 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22909 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22910 assert!(matches!(
22911 receiver.try_recv(),
22912 Err(broadcast::error::TryRecvError::Empty)
22913 ));
22914
22915 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22916 let advanced = receiver.try_recv().unwrap();
22917 assert_eq!(advanced[0].sequence, 2);
22918
22919 let resumed = terminal_address(2);
22920 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22921 let reset = receiver.try_recv().unwrap();
22922 assert_eq!(reset[0].sequence, 3);
22923 assert!(matches!(
22924 &reset[0].event,
22925 NodeEvent::TerminalFrame { address, frame }
22926 if address == &resumed && frame.sequence == 1
22927 ));
22928 shared.clear_terminal_frame_watermark(&resumed);
22929 assert!(!shared
22930 .terminal_frame_watermarks
22931 .lock()
22932 .unwrap_or_else(|poisoned| poisoned.into_inner())
22933 .contains_key(&resumed.session.instance_id));
22934 }
22935
22936 #[test]
22937 fn terminal_frame_batches_replace_without_entering_node_event_history() {
22938 let shared = terminal_test_shared();
22939 let mut receiver = shared.terminal_event_tx.subscribe();
22940 let address = terminal_address(1);
22941 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22942
22943 for sequence in 1..=count {
22944 shared.publish_terminal_frame_candidates(vec![(
22945 address.clone(),
22946 terminal_frame(sequence),
22947 )]);
22948 }
22949
22950 let history = shared
22951 .history
22952 .lock()
22953 .unwrap_or_else(|poisoned| poisoned.into_inner());
22954 assert!(history.events.is_empty());
22955 assert_eq!(history.last_sequence, count);
22956 assert_eq!(history.replay_floor_sequence, 1);
22957 drop(history);
22958 let NodeResponse::Resync {
22959 event_sequence,
22960 oldest_available_sequence,
22961 events,
22962 ..
22963 } = shared.resync(0) else {
22964 unreachable!("resync helper returned another response")
22965 };
22966 assert_eq!(event_sequence, count);
22967 assert_eq!(oldest_available_sequence, 1);
22968 assert!(events.is_empty());
22969 assert!(matches!(
22970 resync_required_event(&shared).event,
22971 NodeEvent::ResyncRequired {
22972 oldest_available_sequence: 1,
22973 }
22974 ));
22975 assert!(matches!(
22976 receiver.try_recv(),
22977 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22978 ));
22979 let latest = receiver.try_recv().unwrap();
22980 assert_eq!(latest.len(), 1);
22981 assert_eq!(latest[0].sequence, count);
22982 }
22983
22984 #[test]
22985 fn real_durable_eviction_advances_authoritative_replay_floor() {
22986 let shared = terminal_test_shared();
22987 for _ in 0..=NODE_EVENT_HISTORY_MAX {
22988 shared.publish(NodeEvent::ControllerChanged { controller: None });
22989 }
22990
22991 let NodeResponse::Resync {
22992 event_sequence,
22993 oldest_available_sequence,
22994 events,
22995 ..
22996 } = shared.resync(0) else {
22997 unreachable!("resync helper returned another response")
22998 };
22999 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23000 assert_eq!(oldest_available_sequence, 2);
23001 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23002 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23003 assert!(matches!(
23004 resync_required_event(&shared).event,
23005 NodeEvent::ResyncRequired {
23006 oldest_available_sequence: 2,
23007 }
23008 ));
23009 }
23010
23011 #[test]
23012 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23013 let shared = terminal_test_shared();
23014 let without_capability = terminal_event_subscription(&shared, false);
23015 let with_capability = terminal_event_subscription(&shared, true);
23016 assert!(without_capability.is_none());
23017 assert!(with_capability.is_some());
23018 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23019 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23020 }));
23021 }
23022
23023 #[test]
23024 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23025 let mut pending = Vec::new();
23026 let mut event_sequence = 0_u64;
23027 for frame_sequence in 1..=4 {
23028 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23029 event_sequence += 1;
23030 let mut address = terminal_address(1);
23031 address.session.instance_id = AgentInstanceId(instance);
23032 queue_connection_event(
23033 &mut pending,
23034 NodeEventEnvelope {
23035 sequence: event_sequence,
23036 event: NodeEvent::TerminalFrame {
23037 address,
23038 frame: terminal_frame(frame_sequence),
23039 },
23040 },
23041 0,
23042 );
23043 }
23044 }
23045 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23046 pending.sort_unstable_by_key(|event| event.sequence);
23047
23048 let mut uninterrupted = pending.clone();
23049 let first_burst = connection_event_burst_len(uninterrupted.len());
23050 uninterrupted.drain(..first_burst);
23051 let second_burst = connection_event_burst_len(uninterrupted.len());
23052 uninterrupted.drain(..second_burst);
23053 assert!(uninterrupted.is_empty());
23054
23055 let (frame_tx, mut frame_rx) =
23056 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23057 frame_tx
23058 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23059 request_id: 77,
23060 request: NodeRequest::Snapshot,
23061 })))
23062 .unwrap();
23063 let burst_len = connection_event_burst_len(pending.len());
23064 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23065 pending.drain(..burst_len);
23066 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23067 assert!(matches!(
23068 frame_rx.try_recv(),
23069 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23070 request_id: 77,
23071 request: NodeRequest::Snapshot,
23072 })))
23073 ));
23074 }
23075
23076 #[test]
23077 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23078 let mut pending = Vec::new();
23079 for sequence in [7_u64, 8] {
23080 let mut address = terminal_address(1);
23081 address.session.instance_id = AgentInstanceId(sequence);
23082 queue_connection_event(
23083 &mut pending,
23084 NodeEventEnvelope {
23085 sequence,
23086 event: NodeEvent::TerminalFrame {
23087 address,
23088 frame: terminal_frame(sequence),
23089 },
23090 },
23091 0,
23092 );
23093 }
23094 let mut discard_through = 0;
23095 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23096 assert!(pending.is_empty());
23097
23098 let replayed = NodeEventEnvelope {
23099 sequence: 8,
23100 event: NodeEvent::TerminalFrame {
23101 address: terminal_address(1),
23102 frame: terminal_frame(8),
23103 },
23104 };
23105 let advanced = NodeEventEnvelope {
23106 sequence: 9,
23107 event: NodeEvent::TerminalFrame {
23108 address: terminal_address(2),
23109 frame: terminal_frame(1),
23110 },
23111 };
23112 queue_connection_event_batch(
23113 &mut pending,
23114 &[replayed, advanced.clone()],
23115 discard_through,
23116 );
23117 assert_eq!(pending, vec![advanced]);
23118 }
23119
23120 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23121 ManagedSessionRecord {
23122 record_id: SessionRecordId::new(record_id).unwrap(),
23123 display_name: format!("{provider} record"),
23124 provider: agent(provider),
23125 mode: SessionMode::Pty,
23126 state: ManagedSessionState::Unavailable,
23127 workspace_id: WorkspaceId::new("primary").unwrap(),
23128 canonical_root: opaque_windows_path(
23129 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23130 ),
23131 provider_session: None,
23132 active_session: None,
23133 environment_profile: None,
23134 bundle: None,
23135 context_id: None,
23136 context: None,
23137 exported_context: None,
23138 task_binding: None,
23139 created_at_unix_ms: 1,
23140 updated_at_unix_ms: 1,
23141 last_error: None,
23142 }
23143 }
23144
23145 fn retention_test_record(
23146 workspace_id: &str,
23147 record_id: &str,
23148 state: ManagedSessionState,
23149 updated_at_unix_ms: u64,
23150 ) -> ManagedSessionRecord {
23151 ManagedSessionRecord {
23152 record_id: SessionRecordId::new(record_id).unwrap(),
23153 display_name: format!("{record_id} display"),
23154 provider: agent("claude"),
23155 mode: SessionMode::Pty,
23156 state,
23157 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23158 canonical_root: opaque_windows_path(
23159 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23160 ),
23161 provider_session: None,
23162 active_session: None,
23163 environment_profile: None,
23164 bundle: None,
23165 context_id: None,
23166 context: None,
23167 exported_context: None,
23168 task_binding: None,
23169 created_at_unix_ms: updated_at_unix_ms,
23170 updated_at_unix_ms,
23171 last_error: None,
23172 }
23173 }
23174
23175 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23183 vec![
23184 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23185 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23186 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23187 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23188 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23189 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23190 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23191 retention_test_record(
23192 "workspace-a",
23193 "decoy-pending",
23194 ManagedSessionState::IdentityPending,
23195 now - 500_000,
23196 ),
23197 retention_test_record(
23198 "workspace-a",
23199 "decoy-dormant",
23200 ManagedSessionState::Dormant,
23201 now - 500_000,
23202 ),
23203 ]
23204 }
23205
23206 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23207 let root = temporary_workspace_root("session-record-retention");
23208 std::fs::create_dir_all(&root).unwrap();
23209 let catalog = active_registry().unwrap();
23210 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23211 let workspace_a =
23212 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23213 let workspace_b =
23214 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23215 let mut shared = NodeShared::new(
23216 handle,
23217 "fixture-token".to_owned(),
23218 NodeId::new("node-retention-test").unwrap(),
23219 vec![workspace_a, workspace_b],
23220 vec![agent("claude")],
23221 );
23222 shared.state_path = Some(root.join("node-state-v10.json"));
23223 let now = unix_time_ms();
23224 for record in retention_fixture_records(now) {
23225 shared.insert_record(record).unwrap();
23226 }
23227 shared
23228 .persist_state_locked()
23229 .expect("fixture-only, empty-history persist against a fresh temp state path");
23230 (shared, runtime, root, now)
23231 }
23232
23233 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23234 shared
23235 .history
23236 .lock()
23237 .unwrap_or_else(|poisoned| poisoned.into_inner())
23238 .events
23239 .iter()
23240 .filter_map(|envelope| match &envelope.event {
23241 NodeEvent::SessionRecordRemoved { record_id } => {
23242 Some(record_id.as_str().to_owned())
23243 }
23244 _ => None,
23245 })
23246 .collect()
23247 }
23248
23249 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23250 shared
23251 .session_records
23252 .lock()
23253 .unwrap_or_else(|poisoned| poisoned.into_inner())
23254 .records
23255 .keys()
23256 .map(|record_id| record_id.as_str().to_owned())
23257 .collect()
23258 }
23259
23260 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23261 values.iter().map(|value| (*value).to_owned()).collect()
23262 }
23263
23264 #[tokio::test]
23265 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23266 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23267 shared.session_record_retention = SessionRecordRetentionConfig {
23268 age_ms: 0,
23269 keep_per_workspace: 1,
23270 };
23271 shared.retire_unavailable_session_records().await;
23272
23273 assert_eq!(
23274 retention_remaining_record_ids(&shared),
23275 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23276 );
23277 assert_eq!(
23278 retention_removed_record_ids(&shared),
23279 owned(&["a1", "a2", "b1", "b2"]),
23280 );
23281 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23282
23283 drop(shared);
23284 drop(runtime);
23285 std::fs::remove_dir_all(root).unwrap();
23286 }
23287
23288 #[tokio::test]
23294 async fn session_record_retention_both_set_retires_only_the_intersection() {
23295 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23296 shared.session_record_retention = SessionRecordRetentionConfig {
23297 age_ms: 250_000,
23298 keep_per_workspace: 1,
23299 };
23300 shared.retire_unavailable_session_records().await;
23301
23302 assert_eq!(
23303 retention_remaining_record_ids(&shared),
23304 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23305 );
23306 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23307 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23308
23309 drop(shared);
23310 drop(runtime);
23311 std::fs::remove_dir_all(root).unwrap();
23312 }
23313
23314 #[tokio::test]
23315 async fn session_record_retention_both_disabled_is_a_no_op() {
23316 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23317 shared.session_record_retention = SessionRecordRetentionConfig::default();
23318 shared.retire_unavailable_session_records().await;
23319
23320 assert_eq!(
23321 retention_remaining_record_ids(&shared),
23322 owned(&[
23323 "a1", "a2", "a3", "b1", "b2", "b3",
23324 "decoy-live", "decoy-pending", "decoy-dormant",
23325 ]),
23326 );
23327 assert!(retention_removed_record_ids(&shared).is_empty());
23328 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23329
23330 drop(shared);
23331 drop(runtime);
23332 std::fs::remove_dir_all(root).unwrap();
23333 }
23334
23335 #[test]
23336 fn session_record_context_export_selects_one_exact_history_identity() {
23337 let candidates = vec![
23338 HistoryCandidateSummary {
23339 id: "candidate-other".to_owned(),
23340 session_id_hint: "provider-other".to_owned(),
23341 modified_at_unix_ms: Some(1),
23342 },
23343 HistoryCandidateSummary {
23344 id: "candidate-exact".to_owned(),
23345 session_id_hint: "provider-exact".to_owned(),
23346 modified_at_unix_ms: Some(2),
23347 },
23348 ];
23349 assert_eq!(
23350 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23351 "candidate-exact",
23352 );
23353 }
23354
23355 #[test]
23356 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23357 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23358 id: id.to_owned(),
23359 session_id_hint: hint.to_owned(),
23360 modified_at_unix_ms: None,
23361 };
23362 let missing = select_history_candidate(
23363 &[candidate("candidate-other", "provider-other")],
23364 Some("provider-exact"),
23365 )
23366 .unwrap_err();
23367 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23368 let ambiguous = select_history_candidate(
23369 &[
23370 candidate("candidate-a", "provider-exact"),
23371 candidate("candidate-b", "provider-exact"),
23372 ],
23373 Some("provider-exact"),
23374 )
23375 .unwrap_err();
23376 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23377 }
23378
23379 #[test]
23387 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23388 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23389 id: id.to_owned(),
23390 session_id_hint: hint.to_owned(),
23391 modified_at_unix_ms: None,
23392 };
23393 assert_eq!(
23394 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23395 .unwrap(),
23396 "candidate-sole",
23397 );
23398 let missing = select_history_candidate(&[], None).unwrap_err();
23399 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23400 let ambiguous = select_history_candidate(
23401 &[
23402 candidate("candidate-a", "sidecar-session-a"),
23403 candidate("candidate-b", "sidecar-session-b"),
23404 ],
23405 None,
23406 )
23407 .unwrap_err();
23408 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23409 }
23410
23411 #[test]
23412 fn session_record_context_export_revalidation_rejects_record_change() {
23413 let session = terminal_address(1);
23414 let identity = ProviderSessionIdentity {
23415 key: ProviderSessionKey::SessionId,
23416 id: "provider-exact".to_owned(),
23417 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23418 };
23419 let mut expected = record("claude", "record-context-export");
23420 expected.state = ManagedSessionState::Live;
23421 expected.provider_session = Some(identity.clone());
23422 expected.active_session = Some(session.clone());
23423 let binding = SessionBinding {
23424 workspace_id: session.workspace_id.clone(),
23425 generation: session.session.generation,
23426 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23427 pending_resume: None,
23428 record_id: Some(expected.record_id.clone()),
23429 managed_worktree_lease_id: None,
23430 environment_profile: None,
23431 bundle: None,
23432 context: None,
23433 materialization_id: None,
23434 };
23435 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23436 assert!(session_record_context_export_binding_is_exact(
23437 &expected,
23438 &expected,
23439 Some(&identity),
23440 &session,
23441 &binding,
23442 &expected.provider,
23443 ¤t_root,
23444 false,
23445 ));
23446 let mut changed = expected.clone();
23447 changed.updated_at_unix_ms += 1;
23448 assert!(!session_record_context_export_binding_is_exact(
23449 &expected,
23450 &changed,
23451 Some(&identity),
23452 &session,
23453 &binding,
23454 &expected.provider,
23455 ¤t_root,
23456 false,
23457 ));
23458 assert!(!session_record_context_export_binding_is_exact(
23463 &expected,
23464 &changed,
23465 Some(&identity),
23466 &session,
23467 &binding,
23468 &expected.provider,
23469 ¤t_root,
23470 true,
23471 ));
23472 let mut detached = expected.clone();
23473 detached.state = ManagedSessionState::Dormant;
23474 detached.active_session = None;
23475 detached.updated_at_unix_ms += 1;
23476 assert!(!session_record_context_export_binding_is_exact(
23477 &expected,
23478 &detached,
23479 Some(&identity),
23480 &session,
23481 &binding,
23482 &expected.provider,
23483 ¤t_root,
23484 false,
23485 ));
23486 assert!(session_record_context_export_binding_is_exact(
23487 &expected,
23488 &detached,
23489 Some(&identity),
23490 &session,
23491 &binding,
23492 &expected.provider,
23493 ¤t_root,
23494 true,
23495 ));
23496 }
23497
23498 #[test]
23505 fn session_record_context_export_binding_is_exact_without_identity() {
23506 let session = terminal_address(1);
23507 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23508 expected.state = ManagedSessionState::Live;
23509 expected.active_session = Some(session.clone());
23510 let binding = SessionBinding {
23511 workspace_id: session.workspace_id.clone(),
23512 generation: session.session.generation,
23513 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23514 pending_resume: None,
23515 record_id: Some(expected.record_id.clone()),
23516 managed_worktree_lease_id: None,
23517 environment_profile: None,
23518 bundle: None,
23519 context: None,
23520 materialization_id: None,
23521 };
23522 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23523 assert!(session_record_context_export_binding_is_exact(
23524 &expected,
23525 &expected,
23526 None,
23527 &session,
23528 &binding,
23529 &expected.provider,
23530 ¤t_root,
23531 false,
23532 ));
23533 let mut drifted = expected.clone();
23537 drifted.provider_session = Some(ProviderSessionIdentity {
23538 key: ProviderSessionKey::SessionId,
23539 id: "unexpected-identity".to_owned(),
23540 transcript_path: None,
23541 });
23542 assert!(!session_record_context_export_binding_is_exact(
23543 &expected,
23544 &drifted,
23545 None,
23546 &session,
23547 &binding,
23548 &expected.provider,
23549 ¤t_root,
23550 false,
23551 ));
23552 }
23553
23554 #[test]
23555 fn session_record_context_export_preflight_requires_live_usable_source() {
23556 let mut source = record("claude", "record-context-export-preflight");
23557 source.state = ManagedSessionState::Live;
23558 assert!(session_record_context_export_source_is_usable(
23559 &source,
23560 &SessionStatus::Running,
23561 false,
23562 ));
23563 assert!(session_record_context_export_source_is_usable(
23564 &source,
23565 &SessionStatus::Exited { exit_code: Some(0) },
23566 false,
23567 ));
23568
23569 source.state = ManagedSessionState::Dormant;
23570 assert!(!session_record_context_export_source_is_usable(
23571 &source,
23572 &SessionStatus::Running,
23573 false,
23574 ));
23575 assert!(session_record_context_export_source_is_usable(
23579 &source,
23580 &SessionStatus::Exited { exit_code: Some(0) },
23581 true,
23582 ));
23583 assert!(!session_record_context_export_source_is_usable(
23584 &source,
23585 &SessionStatus::Exited { exit_code: Some(0) },
23586 false,
23587 ));
23588 source.state = ManagedSessionState::Live;
23589 assert!(!session_record_context_export_source_is_usable(
23590 &source,
23591 &SessionStatus::Starting,
23592 false,
23593 ));
23594 }
23595
23596 #[test]
23597 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23598 let catalog = active_registry().unwrap();
23599 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23600 let workspace = WorkspaceConfig::new(
23601 WorkspaceId::new("primary").unwrap(),
23602 std::env::current_dir().unwrap(),
23603 )
23604 .unwrap();
23605 let shared = NodeShared::new(
23606 handle,
23607 "fixture-token".to_owned(),
23608 NodeId::new("node-context-export-conflict").unwrap(),
23609 vec![workspace],
23610 vec![agent("claude")],
23611 );
23612 let history = HistorySessionRecord {
23613 session_id: "provider-exact".to_owned(),
23614 title: None,
23615 cwd: None,
23616 model: None,
23617 message_count: 1,
23618 completed_turn_count: None,
23619 total_tokens: 0,
23620 messages: vec![gate4agent_types::HistoryMessageRecord {
23621 role: gate4agent_types::HistoryMessageRole::User,
23622 text: "bounded context".to_owned(),
23623 }],
23624 };
23625 let pack = NodeContextPack::export(
23626 ContextPackLineageReceipt {
23627 source_node_id: shared.node_id.clone(),
23628 source_session: terminal_address(1),
23629 source_provider: agent("claude"),
23630 },
23631 &history,
23632 )
23633 .unwrap();
23634 let context_id = pack.receipt().id.clone();
23635 let session = terminal_address(1);
23636 let identity = ProviderSessionIdentity {
23637 key: ProviderSessionKey::SessionId,
23638 id: history.session_id.clone(),
23639 transcript_path: None,
23640 };
23641 let mut expected = record("claude", "record-context-export-final-conflict");
23642 expected.state = ManagedSessionState::Live;
23643 expected.provider_session = Some(identity.clone());
23644 expected.active_session = Some(session.clone());
23645 let mut changed = expected.clone();
23646 changed.updated_at_unix_ms += 1;
23647 shared.insert_record(changed).unwrap();
23648
23649 let error = shared
23650 .commit_context_pack_for_session_record(
23651 &expected,
23652 Some(&identity),
23653 &session,
23654 "candidate-exact",
23655 &history,
23656 pack,
23657 false,
23658 )
23659 .unwrap_err();
23660
23661 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23662 assert!(shared
23663 .context_catalog
23664 .read()
23665 .unwrap_or_else(|poisoned| poisoned.into_inner())
23666 .get(&context_id)
23667 .is_none());
23668 }
23669
23670 #[tokio::test]
23671 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23672 let root = temporary_workspace_root("session-task-binding");
23673 std::fs::create_dir_all(&root).unwrap();
23674 let state_path = root.join("node-state.json");
23675 let catalog = active_registry().unwrap();
23676 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23677 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23678 let node_id = NodeId::new("node-1").unwrap();
23679 let mut shared = NodeShared::new(
23680 handle,
23681 "fixture-token".to_owned(),
23682 node_id.clone(),
23683 vec![workspace],
23684 vec![agent("claude")],
23685 );
23686 shared.state_path = Some(state_path.clone());
23687 let record = record("claude", "sr-task");
23688 let record_id = record.record_id.clone();
23689 shared.insert_record(record).unwrap();
23690 shared.persist_state().unwrap();
23691 shared
23692 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23693 .unwrap();
23694
23695 let minted = process_request_inner(
23696 &shared,
23697 77,
23698 ClientRole::Operator,
23699 NodeRequest::SetSessionTask {
23700 record_id: record_id.clone(),
23701 expected_revision: 0,
23702 target: SessionTaskTargetV1::New,
23703 },
23704 )
23705 .await
23706 .unwrap();
23707 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23708 panic!("session task mutation returned another response");
23709 };
23710 let minted_binding = minted.task_binding.clone().unwrap();
23711 let minted_task_id = minted_binding.task_id.clone().unwrap();
23712 assert_eq!(minted_binding.revision, 1);
23713 assert!(minted_task_id.as_str().starts_with("task-"));
23714 assert_eq!(minted_task_id.as_str().len(), 29);
23715
23716 let event_count = shared.current_sequence();
23717 let repeated = process_request_inner(
23718 &shared,
23719 77,
23720 ClientRole::Operator,
23721 NodeRequest::SetSessionTask {
23722 record_id: record_id.clone(),
23723 expected_revision: 1,
23724 target: SessionTaskTargetV1::Existing {
23725 task_id: minted_task_id.clone(),
23726 },
23727 },
23728 )
23729 .await
23730 .unwrap();
23731 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23732 assert_eq!(shared.current_sequence(), event_count);
23733
23734 let stale = process_request_inner(
23735 &shared,
23736 77,
23737 ClientRole::Operator,
23738 NodeRequest::SetSessionTask {
23739 record_id: record_id.clone(),
23740 expected_revision: 0,
23741 target: SessionTaskTargetV1::Existing {
23742 task_id: minted_task_id,
23743 },
23744 },
23745 )
23746 .await
23747 .unwrap_err();
23748 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23749
23750 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23751 let assigned = process_request_inner(
23752 &shared,
23753 77,
23754 ClientRole::Operator,
23755 NodeRequest::SetSessionTask {
23756 record_id: record_id.clone(),
23757 expected_revision: 1,
23758 target: SessionTaskTargetV1::Existing {
23759 task_id: assigned_id.clone(),
23760 },
23761 },
23762 )
23763 .await
23764 .unwrap();
23765 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23766 panic!("session task assignment returned another response");
23767 };
23768 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23769 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23770
23771 let cleared = process_request_inner(
23772 &shared,
23773 77,
23774 ClientRole::Operator,
23775 NodeRequest::SetSessionTask {
23776 record_id: record_id.clone(),
23777 expected_revision: 2,
23778 target: SessionTaskTargetV1::Clear,
23779 },
23780 )
23781 .await
23782 .unwrap();
23783 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23784 panic!("session task clear returned another response");
23785 };
23786 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23787 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23788 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23789 assert_eq!(
23790 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23791 cleared.updated_at_unix_ms,
23792 );
23793
23794 let persisted = std::fs::read(&state_path).unwrap();
23795 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23796 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23797 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23798 std::fs::remove_dir_all(root).unwrap();
23799 }
23800
23801 #[test]
23802 fn session_task_capability_strips_snapshots_events_and_replies() {
23803 let mut bound = record("claude", "sr-task-projection");
23804 bound.updated_at_unix_ms = 2;
23805 bound.task_binding = Some(SessionTaskBindingV1 {
23806 revision: 1,
23807 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23808 changed_at_unix_ms: 2,
23809 });
23810 let request = NodeRequest::SetSessionTask {
23811 record_id: bound.record_id.clone(),
23812 expected_revision: 1,
23813 target: SessionTaskTargetV1::Clear,
23814 };
23815 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23816 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23817 }));
23818 assert!(!request_uses_unnegotiated_capability(
23819 &request,
23820 &baseline_capabilities().unwrap(),
23821 ));
23822 assert!(request_uses_unnegotiated_capability(&request, &[]));
23823
23824 let mut snapshot = terminal_test_shared().snapshot();
23825 snapshot.session_records.push(bound.clone());
23826 let mut reply = ResponseEnvelope {
23827 request_id: 1,
23828 result: Ok(NodeResponse::Resync {
23829 event_sequence: 1,
23830 oldest_available_sequence: 1,
23831 snapshot,
23832 events: vec![NodeEventEnvelope {
23833 sequence: 1,
23834 event: NodeEvent::SessionRecordUpserted {
23835 record: bound.clone(),
23836 },
23837 }],
23838 }),
23839 };
23840 project_response_without_session_task_binding(&mut reply);
23841 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23842 panic!("task projection changed the response kind");
23843 };
23844 assert!(snapshot.session_records[0].task_binding.is_none());
23845 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23846 panic!("task projection changed the event kind");
23847 };
23848 assert!(record.task_binding.is_none());
23849
23850 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23851 sequence: 2,
23852 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23853 });
23854 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23855 panic!("task event projection changed the event kind");
23856 };
23857 assert!(record.task_binding.is_none());
23858
23859 let mut direct = ResponseEnvelope {
23860 request_id: 2,
23861 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23862 };
23863 project_response_without_session_task_binding(&mut direct);
23864 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23865 panic!("task reply projection changed the response kind");
23866 };
23867 assert!(record.task_binding.is_none());
23868 }
23869
23870 #[test]
23871 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23872 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23873 let workspace = WorkspaceConfig::new(
23874 WorkspaceId::new("primary").unwrap(),
23875 std::env::current_dir().unwrap(),
23876 )
23877 .unwrap();
23878 let config = NodeServerConfig::new(
23879 DEFAULT_NODE_ENDPOINT,
23880 "fixture-token",
23881 NodeId::new("node-1").unwrap(),
23882 [workspace],
23883 )
23884 .unwrap();
23885 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23886 }
23887
23888 #[cfg(all(feature = "fixture", windows))]
23889 #[test]
23890 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23891 let root = temporary_workspace_root("provider-bundle-argv-hold");
23892 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23893 std::fs::create_dir_all(&root).unwrap();
23894 std::fs::create_dir_all(&outside).unwrap();
23895 let proof_path = root.join("provider.proof");
23896 let release_signal = root.join("release.signal");
23897 let config = NodeServerConfig::new(
23898 format!(
23899 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23900 std::process::id(),
23901 ),
23902 "fixture-token",
23903 NodeId::new("provider-bundle-hold-node").unwrap(),
23904 [WorkspaceConfig::new(
23905 WorkspaceId::new("primary").unwrap(),
23906 std::env::current_dir().unwrap(),
23907 )
23908 .unwrap()],
23909 )
23910 .unwrap()
23911 .with_spawn_profiles(SpawnProfileRegistry::new([
23912 crate::protocol::SpawnProfileDefaults {
23913 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23914 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23915 .unwrap(),
23916 provider: agent("kimi"),
23917 mode: SessionMode::Pty,
23918 terminal_size: gate4agent_types::TerminalSize {
23919 rows: 24,
23920 columns: 80,
23921 },
23922 prompt: None,
23923 bundle_id: None,
23924 context_id: None,
23925 environment_profile_id: None,
23926 },
23927 ])
23928 .unwrap());
23929 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23930 config,
23931 agent("kimi"),
23932 proof_path.clone(),
23933 release_signal.clone(),
23934 )
23935 .unwrap();
23936 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23937
23938 let spec = NodeServer::provider_bundle_argv_fixture_spec(
23939 agent("kimi"),
23940 proof_path.clone(),
23941 Some(release_signal.clone()),
23942 )
23943 .unwrap();
23944 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23945 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23946 let release_wait = wrapper
23947 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23948 .unwrap();
23949 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23950 assert!(proof_write < release_wait);
23951 assert!(release_wait < fixture_ready);
23952 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23953 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23954 assert_eq!(
23955 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23956 &[
23957 proof_path.to_string_lossy().into_owned(),
23958 release_signal.to_string_lossy().into_owned(),
23959 ],
23960 );
23961
23962 let error = NodeServer::provider_bundle_argv_fixture_spec(
23963 agent("kimi"),
23964 proof_path,
23965 Some(outside.join("release.signal")),
23966 )
23967 .unwrap_err();
23968 assert!(matches!(
23969 error,
23970 NodeServerError::Registry(message)
23971 if message == "provider bundle argv release path must share the proof parent"
23972 ));
23973 drop(server);
23974 std::fs::remove_dir_all(root).unwrap();
23975 std::fs::remove_dir_all(outside).unwrap();
23976 }
23977
23978 #[cfg(feature = "fixture")]
23979 #[test]
23980 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23981 let root = temporary_workspace_root("context-fixture-registry");
23982 std::fs::create_dir_all(&root).unwrap();
23983 let proof_path = root.join("context.proof");
23984 let config = NodeServerConfig::new(
23985 format!(
23986 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23987 std::process::id(),
23988 ),
23989 "fixture-token",
23990 NodeId::new("context-fixture-node").unwrap(),
23991 [WorkspaceConfig::new(
23992 WorkspaceId::new("primary").unwrap(),
23993 std::env::current_dir().unwrap(),
23994 )
23995 .unwrap()],
23996 )
23997 .unwrap();
23998 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23999 let mut enabled = server
24000 .shared
24001 .enabled_providers
24002 .iter()
24003 .map(AgentId::as_str)
24004 .collect::<Vec<_>>();
24005 enabled.sort_unstable();
24006 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24007 for provider in &server.shared.enabled_providers {
24008 assert!(server
24009 .shared
24010 .provider_adapter_contracts
24011 .iter()
24012 .any(|contract| {
24013 &contract.provider == provider
24014 && contract.family == AdapterFamily::History
24015 }));
24016 }
24017 drop(server);
24018 std::fs::remove_dir_all(root).unwrap();
24019 }
24020
24021 #[cfg(feature = "fixture")]
24022 #[test]
24023 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24024 let root = temporary_workspace_root("monitoring-context-fixture");
24025 std::fs::create_dir_all(&root).unwrap();
24026 let proof_path = root.join("context.proof");
24027 let standard = NodeServer::context_pack_fixture_catalog(
24028 proof_path.clone(),
24029 None,
24030 false,
24031 ).unwrap();
24032 let monitoring = NodeServer::context_pack_fixture_catalog(
24033 proof_path.clone(),
24034 None,
24035 true,
24036 ).unwrap();
24037 let claude = AgentId::new("claude").unwrap();
24038 let standard_claude = standard.get(&claude).unwrap();
24039 let monitoring_claude = monitoring.get(&claude).unwrap();
24040 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24044 assert_eq!(
24045 monitoring_claude.capabilities.adapters.history,
24046 standard_claude.capabilities.adapters.history,
24047 );
24048 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24049 let codex = AgentId::new("codex").unwrap();
24050 let standard_codex = standard.get(&codex).unwrap();
24051 let monitoring_codex = monitoring.get(&codex).unwrap();
24052 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24053 assert_eq!(
24054 monitoring_codex.capabilities.adapters.history,
24055 standard_codex.capabilities.adapters.history,
24056 );
24057 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24058 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24059 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24060 .unwrap();
24061 for required in [
24062 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24063 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24064 "@('role', 'text')",
24065 "g4a-private-provider-session-canary",
24066 "private-provider-login-identity",
24067 "$rawProof",
24068 "$env:GATE4AGENT_HOOK_URL",
24069 "g4a-private-provider-session-canary",
24070 ] {
24071 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24072 }
24073 for provider in ["grok", "kimi"] {
24074 let provider = AgentId::new(provider).unwrap();
24075 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24076 }
24077
24078 let config = NodeServerConfig::new(
24079 format!(
24080 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24081 std::process::id(),
24082 ),
24083 "fixture-token",
24084 NodeId::new("monitoring-context-fixture-node").unwrap(),
24085 [WorkspaceConfig::new(
24086 WorkspaceId::new("primary").unwrap(),
24087 std::env::current_dir().unwrap(),
24088 )
24089 .unwrap()],
24090 )
24091 .unwrap();
24092 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24093 assert!(server.shared.fixture_semantic_hook_policy);
24094 drop(server);
24095 std::fs::remove_dir_all(root).unwrap();
24096 }
24097
24098 #[cfg(feature = "fixture")]
24099 #[test]
24100 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24101 let root = temporary_workspace_root("kimi-context-only-fixture");
24102 std::fs::create_dir_all(&root).unwrap();
24103 let proof_path = root.join("context.proof");
24104 let standard = NodeServer::context_pack_fixture_catalog(
24105 proof_path.clone(),
24106 None,
24107 false,
24108 ).unwrap();
24109 let kimi = AgentId::new("kimi").unwrap();
24110 let context_only = NodeServer::context_pack_fixture_catalog(
24111 proof_path,
24112 Some(&kimi),
24113 false,
24114 )
24115 .unwrap();
24116
24117 let codex = AgentId::new("codex").unwrap();
24118 assert_eq!(
24119 standard.get(&codex).unwrap().launch.fixed_args,
24120 context_only.get(&codex).unwrap().launch.fixed_args,
24121 );
24122 let script = context_only
24123 .get(&kimi)
24124 .unwrap()
24125 .launch
24126 .fixed_args
24127 .iter()
24128 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24129 .expect("Kimi context-only validation script is missing");
24130 for required in [
24131 "$bundleArgs.Count -ne 0",
24132 "GATE4AGENT_CONTEXT_ROOT",
24133 "context-pack.json",
24134 "$entries.Count -ne 1",
24135 "g4a-context-pack-v1",
24136 "$document.PSObject.Properties['cwd']",
24137 "'claude', 'codex', 'grok', 'kimi'",
24138 "$hasUser",
24139 "$hasAssistant",
24140 "SHA256",
24141 "context-only",
24142 ] {
24143 assert!(script.contains(required), "missing validation: {required}");
24144 }
24145 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24146 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24147 }
24148 assert!(!standard
24149 .get(&kimi)
24150 .unwrap()
24151 .launch
24152 .fixed_args
24153 .iter()
24154 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24155
24156 std::fs::remove_dir_all(root).unwrap();
24157 }
24158
24159 #[cfg(all(feature = "fixture", windows))]
24160 #[tokio::test]
24161 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24162 let root = temporary_workspace_root("kimi-context-only-spawn");
24163 let workspace_root = root.join("workspace");
24164 std::fs::create_dir_all(&workspace_root).unwrap();
24165 let materialization_root = root.join("private-materializations");
24166 let proof_path = root.join("context.proof");
24167 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24168 let workspace_id = WorkspaceId::new("primary").unwrap();
24169 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24170 let endpoint = format!(
24171 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24172 std::process::id(),
24173 unix_time_ms(),
24174 );
24175 let spawn_profiles = SpawnProfileRegistry::new([
24176 crate::protocol::SpawnProfileDefaults {
24177 profile_id: profile_id.clone(),
24178 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24179 provider: agent("kimi"),
24180 mode: SessionMode::Pty,
24181 terminal_size: gate4agent_types::TerminalSize {
24182 rows: 30,
24183 columns: 120,
24184 },
24185 prompt: None,
24186 bundle_id: None,
24187 context_id: None,
24188 environment_profile_id: None,
24189 },
24190 ])
24191 .unwrap();
24192 let mut config = NodeServerConfig::new(
24193 &endpoint,
24194 "fixture-token",
24195 node_id.clone(),
24196 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24197 )
24198 .unwrap()
24199 .with_spawn_profiles(spawn_profiles)
24200 .with_session_environment_materialization(
24201 materialization_root,
24202 Arc::new(FixtureSecretResolver {
24203 deny: Arc::new(AtomicBool::new(false)),
24204 }),
24205 )
24206 .unwrap();
24207 config.fixture_raw_pty_runtime = true;
24208 let server = NodeServer::new_context_only_proof_fixture(
24209 config,
24210 agent("kimi"),
24211 proof_path.clone(),
24212 )
24213 .unwrap();
24214 let shared = Arc::clone(&server.shared);
24215 let shutdown = server.shutdown_handle();
24216
24217 let history = HistorySessionRecord {
24218 session_id: "context-only-source-history".to_owned(),
24219 title: None,
24220 cwd: None,
24221 model: None,
24222 message_count: 2,
24223 completed_turn_count: None,
24224 total_tokens: 0,
24225 messages: vec![
24226 gate4agent_types::HistoryMessageRecord {
24227 role: gate4agent_types::HistoryMessageRole::User,
24228 text: "continue the bounded parent run through native C2".to_owned(),
24229 },
24230 gate4agent_types::HistoryMessageRecord {
24231 role: gate4agent_types::HistoryMessageRole::Assistant,
24232 text: "the exact parent context is ready for the child run".to_owned(),
24233 },
24234 ],
24235 };
24236 let pack = NodeContextPack::export(
24237 ContextPackLineageReceipt {
24238 source_node_id: node_id.clone(),
24239 source_session: SessionAddress {
24240 workspace_id: workspace_id.clone(),
24241 session: SessionKey {
24242 instance_id: AgentInstanceId(71),
24243 generation: SessionGeneration(1),
24244 },
24245 },
24246 source_provider: agent("codex"),
24247 },
24248 &history,
24249 )
24250 .unwrap();
24251 let expected_context_hash = digest(&SHA256, pack.bytes())
24252 .as_ref()
24253 .iter()
24254 .map(|byte| format!("{byte:02x}"))
24255 .collect::<String>();
24256 let context = shared
24257 .context_catalog
24258 .write()
24259 .unwrap_or_else(|poisoned| poisoned.into_inner())
24260 .insert(pack)
24261 .unwrap();
24262 let spec = SpawnSpec {
24263 target: crate::protocol::SpawnTarget {
24264 node_id,
24265 workspace_id: workspace_id.clone(),
24266 worktree_id: None,
24267 },
24268 profile_id,
24269 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24270 "target-kimi-r1",
24271 ).unwrap(),
24272 overrides: crate::protocol::SpawnOverrides {
24273 context_id: crate::protocol::SpawnOverride::Set {
24274 value: context.id.clone(),
24275 },
24276 ..crate::protocol::SpawnOverrides::default()
24277 },
24278 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24279 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24280 required_capabilities: SpawnRequiredCapabilities::default(),
24281 };
24282 let resolved = match shared.resolve_spawn_spec(&spec) {
24283 Ok(resolved) => resolved,
24284 Err(error) => panic!(
24285 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24286 error.code,
24287 error.message,
24288 ),
24289 };
24290 let resolved_context = match shared.resolve_context(&resolved) {
24291 Ok(Some(resolved_context)) => resolved_context,
24292 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24293 Err(error) => panic!(
24294 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24295 error.code,
24296 error.message,
24297 ),
24298 };
24299 assert_eq!(resolved_context, context);
24300 let runtime_policy = match shared
24301 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24302 .await
24303 {
24304 Ok(runtime_policy) => runtime_policy,
24305 Err(error) => panic!(
24306 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24307 error.code,
24308 error.message,
24309 ),
24310 };
24311 assert!(runtime_policy.raw_pty_lifecycle);
24312
24313 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24314 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24315 agent("kimi"),
24316 TransportKind::Pty,
24317 vec![EnvMutation {
24318 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24319 value: Some(OsString::from("must-remain-rejected")),
24320 }],
24321 )
24322 .unwrap();
24323 assert_eq!(
24324 launch_control
24325 .install_native_launch_environment_overlay(
24326 AgentInstanceId(72),
24327 generic_overlay,
24328 )
24329 .unwrap_err(),
24330 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24331 );
24332
24333 let diagnostic_address = SessionAddress {
24334 workspace_id: workspace_id.clone(),
24335 session: SessionKey {
24336 instance_id: AgentInstanceId(73),
24337 generation: SessionGeneration(1),
24338 },
24339 };
24340 let (diagnostic_overlay, diagnostic_materialization) = match shared
24341 .prepare_session_materialization(
24342 &diagnostic_address,
24343 &agent("kimi"),
24344 SessionMode::Pty,
24345 None,
24346 None,
24347 Some(&resolved_context),
24348 None,
24349 )
24350 {
24351 Ok(Some(prepared)) => prepared,
24352 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24353 Err(error) => panic!(
24354 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24355 error.code,
24356 error.message,
24357 ),
24358 };
24359 let diagnostic_overlay = diagnostic_overlay
24360 .expect("Kimi context-only materialization omitted its environment overlay");
24361 assert!(matches!(
24362 &diagnostic_overlay,
24363 PreparedNativeLaunchOverlay::Environment(_),
24364 ));
24365 match shared.install_prepared_launch_overlay(
24366 diagnostic_address.session.instance_id,
24367 diagnostic_overlay,
24368 ) {
24369 Ok(None) => {}
24370 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24371 Err(error) => panic!(
24372 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24373 error.code,
24374 error.message,
24375 ),
24376 }
24377 assert!(launch_control.clear_native_instance_launch_overlay(
24378 diagnostic_address.session.instance_id,
24379 ));
24380 drop(diagnostic_materialization);
24381 assert!(shared
24382 .materializations
24383 .lock()
24384 .unwrap_or_else(|poisoned| poisoned.into_inner())
24385 .is_empty());
24386
24387 let node_task = tokio::spawn(server.run());
24388 let ready_client = timeout(Duration::from_secs(5), async {
24389 loop {
24390 match gate4agent_node_wire::LocalNodeClient::connect(
24391 &endpoint,
24392 &shared.node_id,
24393 ClientRole::Observer,
24394 "fixture-token",
24395 )
24396 .await
24397 {
24398 Ok(client) => break client,
24399 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24400 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24401 }
24402 }
24403 })
24404 .await
24405 .expect("Kimi context-only Node did not become ready");
24406 assert_eq!(
24407 ready_client
24408 .hello()
24409 .snapshot
24410 .provider_runtime_statuses
24411 .iter()
24412 .find(|status| status.provider().as_str() == "kimi")
24413 .expect("Kimi runtime status is missing")
24414 .mode(),
24415 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24416 );
24417 drop(ready_client);
24418
24419 let receipt = match shared.spawn_from_spec(spec).await {
24420 Ok(receipt) => receipt,
24421 Err(error) => panic!(
24422 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24423 error.code,
24424 error.message,
24425 ),
24426 };
24427 assert_eq!(receipt.provider, agent("kimi"));
24428 assert_eq!(receipt.context.as_ref(), Some(&context));
24429 assert!(receipt.context_binding_is_valid());
24430 timeout(Duration::from_secs(5), async {
24431 while !proof_path.is_file() {
24432 sleep(Duration::from_millis(10)).await;
24433 }
24434 })
24435 .await
24436 .expect("Kimi context-only fixture did not write its proof");
24437
24438 let proof = std::fs::read_to_string(&proof_path).unwrap();
24439 let lines = proof.lines().collect::<Vec<_>>();
24440 assert_eq!(lines.len(), 7);
24441 assert_eq!(lines[0], "context-only");
24442 assert!(Path::new(lines[1]).is_absolute());
24443 assert_ne!(
24444 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24445 std::fs::canonicalize(&workspace_root).unwrap(),
24446 );
24447 assert_eq!(
24448 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24449 std::fs::canonicalize(&workspace_root).unwrap(),
24450 );
24451 assert_eq!(lines[3], expected_context_hash);
24452 assert_eq!(lines[4], "g4a-context-pack-v1");
24453 assert_eq!(lines[5], "codex");
24454 assert_eq!(lines[6], "2");
24455 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24456 session.instance_id == receipt.session.session.instance_id
24457 && session.agent_id.as_str() == "kimi"
24458 }));
24459
24460 shutdown.request_shutdown().await.unwrap();
24461 timeout(Duration::from_secs(5), node_task)
24462 .await
24463 .unwrap()
24464 .unwrap()
24465 .unwrap();
24466 drop(shutdown);
24467 drop(shared);
24468 std::fs::remove_dir_all(root).unwrap();
24469 }
24470
24471 #[cfg(feature = "fixture")]
24472 #[test]
24473 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24474 let root = temporary_workspace_root("clean-exit-constructor");
24475 std::fs::create_dir_all(&root).unwrap();
24476 let config = construction_test_config("clean-exit-constructor")
24477 .with_state_path(root.join("node-state.json"))
24478 .unwrap();
24479 let server = NodeServer::new_clean_exit_fixture(
24480 config,
24481 root.clone(),
24482 root.join("started.marker"),
24483 root.join("release.signal"),
24484 )
24485 .unwrap();
24486 assert_eq!(
24487 server
24488 .shared
24489 .enabled_providers
24490 .iter()
24491 .map(AgentId::as_str)
24492 .collect::<Vec<_>>(),
24493 ["claude"],
24494 );
24495 drop(server);
24496 std::fs::remove_dir_all(root).unwrap();
24497 }
24498
24499 #[test]
24500 fn production_registry_provider_contract_manifest_is_exact() {
24501 let registry = active_registry().unwrap();
24502 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24503 assert_eq!(
24504 providers
24505 .iter()
24506 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24507 .collect::<Vec<_>>(),
24508 vec![
24509 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24510 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24511 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24512 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24513 ]
24514 );
24515 assert_eq!(
24516 adapters
24517 .iter()
24518 .map(|contract| (
24519 contract.provider.as_str(),
24520 contract.family,
24521 contract.adapter_id.as_str(),
24522 contract.revision.as_str(),
24523 ))
24524 .collect::<Vec<_>>(),
24525 vec![
24526 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24527 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24528 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24529 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24530 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24531 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24532 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24533 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24534 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24535 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24536 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24537 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24538 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24539 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24540 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24541 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24542 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24543 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24544 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24545 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24546 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24547 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24548 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24549 ]
24550 );
24551 }
24552
24553 fn construction_test_config(name: &str) -> NodeServerConfig {
24554 let workspace = WorkspaceConfig::new(
24555 WorkspaceId::new("primary").unwrap(),
24556 std::env::current_dir().unwrap(),
24557 )
24558 .unwrap();
24559 NodeServerConfig::new(
24560 format!(r"\\.\pipe\gate4agent-{name}"),
24561 "fixture-token",
24562 NodeId::new(name).unwrap(),
24563 [workspace],
24564 )
24565 .unwrap()
24566 }
24567
24568 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24569 let adapter = AdapterBinding::new(
24570 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24571 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24572 gate4agent_types::AdapterVerification::SyntheticFixture,
24573 )
24574 .unwrap();
24575 let agent_ids = ["claude", "codex", "kimi"]
24576 .into_iter()
24577 .map(|agent_id| AgentId::new(agent_id).unwrap())
24578 .collect::<Vec<_>>();
24579 let adapters = gate4agent_catalog::AdapterRegistry::new(
24580 [
24581 AdapterFamily::PtySemantic,
24582 AdapterFamily::Pipe,
24583 AdapterFamily::OneShot,
24584 AdapterFamily::Acp,
24585 AdapterFamily::Hook,
24586 AdapterFamily::ManagedHook,
24587 AdapterFamily::History,
24588 AdapterFamily::Resume,
24589 AdapterFamily::SessionOptions,
24590 AdapterFamily::CapabilityProbe,
24591 ]
24592 .into_iter()
24593 .map(|family| gate4agent_catalog::AdapterDescriptor {
24594 family,
24595 binding: adapter.clone(),
24596 agents: agent_ids.clone(),
24597 }),
24598 )
24599 .unwrap();
24600 let specs = agent_ids
24601 .iter()
24602 .map(|agent_id| {
24603 let agent_id = agent_id.as_str();
24604 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24605 spec.revision =
24606 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24607 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24608 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24609 adapter: adapter.clone(),
24610 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24611 launch_override: None,
24612 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24613 });
24614 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24615 adapter: adapter.clone(),
24616 launch_override: None,
24617 });
24618 spec.capabilities.adapters.hook = Some(adapter.clone());
24619 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24620 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24621 spec.capabilities.adapters.history = Some(adapter.clone());
24622 spec.capabilities.adapters.resume = Some(adapter.clone());
24623 spec.capabilities.adapters.session_options = Some(adapter.clone());
24624 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24625 spec
24626 })
24627 .collect::<Vec<_>>();
24628 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24629 }
24630
24631 #[test]
24639 fn node_server_construction_accepts_the_production_manifest() {
24640 let registry = active_registry().unwrap();
24641 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24642 assert_eq!(providers.len(), registry.iter().count());
24643 assert!(!providers.is_empty());
24644 let server = NodeServer::new_with_registry(
24645 construction_test_config("manifest-production-bound"),
24646 registry,
24647 )
24648 .unwrap();
24649 drop(server);
24650 }
24651
24652 #[test]
24653 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24654 let registry = maximum_size_provider_manifest_registry();
24655 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24656 assert_eq!(providers.len(), 3);
24657 assert_eq!(adapters.len(), 30);
24658 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24659 let error = match NodeServer::new_with_registry(
24660 construction_test_config("manifest-handshake-overflow"),
24661 registry,
24662 ) {
24663 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24664 Err(error) => error,
24665 };
24666 assert!(matches!(
24667 error,
24668 NodeServerError::ProviderContractManifest(message)
24669 if message.contains("authentication binding length")
24670 && message.contains("8192-byte limit")
24671 ));
24672 }
24673
24674 #[test]
24675 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24676 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24677 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24678 let registry = AgentRegistry::new([spec]).unwrap();
24679 assert!(matches!(
24680 provider_contract_manifest(®istry),
24681 Err(NodeServerError::ProviderContractManifest(message))
24682 if message.contains("exceeds the 128-byte limit")
24683 ));
24684 }
24685
24686 #[test]
24687 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24688 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24689 spec.revision = "invalid revision".to_owned();
24690 let registry = AgentRegistry::new([spec]).unwrap();
24691 assert!(matches!(
24692 provider_contract_manifest(®istry),
24693 Err(NodeServerError::ProviderContractManifest(message))
24694 if message.contains("bounded lowercase ASCII")
24695 ));
24696 }
24697
24698 #[test]
24699 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24700 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24701 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24702 let conflicting_binding = builtin_registry()
24703 .get_by_id("codex")
24704 .unwrap()
24705 .capabilities
24706 .adapters
24707 .one_shot
24708 .clone()
24709 .unwrap();
24710 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24711 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24712 pipe.adapter = transport_binding;
24713 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24714 let registry = AgentRegistry::new([spec]).unwrap();
24715 assert!(matches!(
24716 provider_contract_manifest(®istry),
24717 Err(NodeServerError::ProviderContractManifest(message))
24718 if message.contains("conflicting OneShot adapter bindings")
24719 ));
24720 }
24721
24722 #[test]
24723 fn public_node_failures_expose_only_fixed_categories() {
24724 let provider = failure(
24725 NodeFailureCode::BackendOperationFailed,
24726 r"provider bearer-secret failed at C:\private\session.jsonl",
24727 );
24728 assert_eq!(provider.message, "backend-operation-failed");
24729 assert!(!provider.message.contains("bearer-secret"));
24730 assert!(!provider.message.contains(r"C:\private"));
24731 let persistence = persistence_failure(io::Error::new(
24732 io::ErrorKind::Other,
24733 r"state write failed at C:\private\state-v1.json",
24734 ));
24735 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24736 assert!(!persistence.message.contains(r"C:\private"));
24737
24738 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24739 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24740 assert_eq!(
24741 generic.to_string(),
24742 "node durable state failed: durable-state-load-failed",
24743 );
24744 }
24745 }
24746
24747 #[test]
24748 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24749 let sequence = b"\x1b[1;5D".to_vec();
24750 assert_eq!(
24751 terminal_bytes_action(sequence.clone()).unwrap(),
24752 InputAction::TerminalBytes(sequence),
24753 );
24754 assert_eq!(
24755 terminal_bytes_action(Vec::new()).unwrap_err().code,
24756 NodeFailureCode::InvalidRequest,
24757 );
24758 assert_eq!(
24759 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24760 .unwrap_err()
24761 .code,
24762 NodeFailureCode::InvalidRequest,
24763 );
24764 }
24765
24766 #[test]
24767 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24768 let root = temporary_workspace_root("exclusive-state-owner");
24769 std::fs::create_dir_all(&root).unwrap();
24770 let workspace = WorkspaceConfig::new(
24771 WorkspaceId::new("primary").unwrap(),
24772 &root,
24773 )
24774 .unwrap();
24775 let state_path = root.join("state-v1.json");
24776 let endpoint = format!(
24777 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24778 std::process::id(),
24779 std::time::SystemTime::now()
24780 .duration_since(std::time::UNIX_EPOCH)
24781 .unwrap()
24782 .as_nanos(),
24783 );
24784 let config = NodeServerConfig::new(
24785 endpoint,
24786 "fixture-token",
24787 NodeId::new("node-1").unwrap(),
24788 [workspace],
24789 )
24790 .unwrap()
24791 .with_state_path(&state_path)
24792 .unwrap();
24793
24794 let first = NodeServer::new(config.clone()).unwrap();
24795 let error = match NodeServer::new(config.clone()) {
24796 Ok(_) => panic!("a second node server acquired the same durable state path"),
24797 Err(error) => error,
24798 };
24799 assert!(matches!(
24800 &error,
24801 NodeServerError::DurableState(error)
24802 if error.kind() == io::ErrorKind::WouldBlock
24803 ));
24804 assert_eq!(
24805 error.to_string(),
24806 "node durable state failed: durable-state-lock-failed",
24807 );
24808 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24809 drop(first);
24810 let second = NodeServer::new(config).unwrap();
24811 drop(second);
24812 std::fs::remove_dir_all(root).unwrap();
24813 }
24814
24815 #[test]
24816 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24817 let catalog = active_registry().unwrap();
24818 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24819 let workspace_id = WorkspaceId::new("primary").unwrap();
24820 let workspace = WorkspaceConfig::new(
24821 workspace_id.clone(),
24822 std::env::current_dir().unwrap(),
24823 )
24824 .unwrap();
24825 let shared = NodeShared::new(
24826 handle,
24827 "fixture-token".to_owned(),
24828 NodeId::new("node-1").unwrap(),
24829 vec![workspace.clone()],
24830 vec![agent("claude"), agent("codex"), agent("kimi")],
24831 );
24832 let instance_id = AgentInstanceId(17);
24833 let generation = SessionGeneration(3);
24834 assert!(shared.address_for(instance_id, generation).is_none());
24835 shared.bind_session(instance_id, workspace_id.clone(), generation);
24836 let address = SessionAddress {
24837 workspace_id,
24838 session: SessionKey { instance_id, generation },
24839 };
24840 assert_eq!(
24841 shared.workspace_root(&address.workspace_id).unwrap(),
24842 workspace.canonical_root,
24843 );
24844 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24845 shared.remove_binding(&address);
24846 assert!(shared.address_for(instance_id, generation).is_none());
24847 }
24848
24849 #[test]
24850 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24851 let address = SessionAddress {
24852 workspace_id: WorkspaceId::new("primary").unwrap(),
24853 session: SessionKey {
24854 instance_id: AgentInstanceId(7),
24855 generation: SessionGeneration(2),
24856 },
24857 };
24858 let record_id = SessionRecordId::new("record-a").unwrap();
24859 let size = gate4agent_types::TerminalSize {
24860 rows: 24,
24861 columns: 80,
24862 };
24863 let address_requests = [
24864 NodeRequest::Resume {
24865 session: address.clone(),
24866 terminal_size: size,
24867 initial_prompt: None,
24868 },
24869 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24870 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24871 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24872 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24873 NodeRequest::TerminalControl {
24874 session: address.clone(),
24875 control: TerminalControl::Enter,
24876 },
24877 NodeRequest::Resize { session: address.clone(), size },
24878 NodeRequest::Interrupt { session: address.clone() },
24879 NodeRequest::Stop { session: address.clone(), force: false },
24880 NodeRequest::Remove { session: address },
24881 ];
24882 for request in &address_requests {
24883 assert!(request_requires_open_provider_ids_with(
24884 request,
24885 |_| Some(agent("third-party-agent")),
24886 |_| Some(agent("claude")),
24887 ));
24888 assert!(!request_requires_open_provider_ids_with(
24889 request,
24890 |_| Some(agent("claude")),
24891 |_| Some(agent("claude")),
24892 ));
24893 assert!(request_requires_open_provider_ids_with(
24894 request,
24895 |_| None,
24896 |_| Some(agent("claude")),
24897 ));
24898 }
24899
24900 let record_requests = [
24901 NodeRequest::RenameSessionRecord {
24902 record_id: record_id.clone(),
24903 display_name: "renamed".to_owned(),
24904 },
24905 NodeRequest::ResumeSessionRecord {
24906 record_id: record_id.clone(),
24907 terminal_size: size,
24908 initial_prompt: None,
24909 },
24910 NodeRequest::ForgetSessionRecord { record_id },
24911 ];
24912 for request in &record_requests {
24913 assert!(request_requires_open_provider_ids_with(
24914 request,
24915 |_| Some(agent("claude")),
24916 |_| Some(agent("grok")),
24917 ));
24918 assert!(!request_requires_open_provider_ids_with(
24919 request,
24920 |_| Some(agent("claude")),
24921 |_| Some(agent("kimi")),
24922 ));
24923 assert!(request_requires_open_provider_ids_with(
24924 request,
24925 |_| Some(agent("claude")),
24926 |_| None,
24927 ));
24928 }
24929
24930 assert!(request_requires_open_provider_ids_with(
24931 &NodeRequest::Spawn {
24932 workspace_id: WorkspaceId::new("primary").unwrap(),
24933 provider: agent("third-party-agent"),
24934 mode: SessionMode::Pty,
24935 terminal_size: size,
24936 initial_prompt: None,
24937 },
24938 |_| None,
24939 |_| None,
24940 ));
24941 assert!(!request_requires_open_provider_ids_with(
24942 &NodeRequest::Snapshot,
24943 |_| None,
24944 |_| None,
24945 ));
24946 assert!(request_requires_open_provider_ids_with(
24947 &NodeRequest::ForgetContextPack {
24948 context_id: SpawnContextId::new("ctx-forget").unwrap(),
24949 },
24950 |_| None,
24951 |_| None,
24952 ));
24953 }
24954
24955 #[test]
24956 fn legacy_projection_filters_open_record_replies_and_event_history() {
24957 let catalog = active_registry().unwrap();
24958 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24959 let workspace = WorkspaceConfig::new(
24960 WorkspaceId::new("primary").unwrap(),
24961 std::env::current_dir().unwrap(),
24962 )
24963 .unwrap();
24964 let shared = NodeShared::new(
24965 handle,
24966 "fixture-token".to_owned(),
24967 NodeId::new("node-1").unwrap(),
24968 vec![workspace],
24969 vec![agent("claude"), agent("grok")],
24970 );
24971 let legacy = record("claude", "legacy-record");
24972 let open = record("grok", "open-record");
24973 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24974 record: legacy.clone(),
24975 });
24976 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24977 record: open.clone(),
24978 });
24979 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24980 record_id: legacy.record_id.clone(),
24981 });
24982 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24983 record_id: open.record_id.clone(),
24984 });
24985 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24986 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24987 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24988 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24989
24990 let mut record_reply = ResponseEnvelope {
24991 request_id: 1,
24992 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24993 };
24994 project_response_legacy_provider_ids(&shared, &mut record_reply);
24995 assert!(matches!(
24996 record_reply.result,
24997 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24998 ));
24999
25000 let mut resync = ResponseEnvelope {
25001 request_id: 2,
25002 result: Ok(NodeResponse::Resync {
25003 event_sequence: open_removed.sequence,
25004 oldest_available_sequence: 1,
25005 snapshot: shared.snapshot(),
25006 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25007 }),
25008 };
25009 project_response_legacy_provider_ids(&shared, &mut resync);
25010 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25011 panic!("legacy projection changed the resync response kind");
25012 };
25013 assert!(snapshot.session_records.iter().all(|item| {
25014 provider_id_is_legacy(&item.provider)
25015 }));
25016 assert_eq!(events.len(), 2);
25017 assert!(events.iter().all(|event| matches!(
25018 &event.event,
25019 NodeEvent::SessionRecordUpserted { record }
25020 if provider_id_is_legacy(&record.provider)
25021 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25022 }
25023
25024 #[test]
25025 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25026 let catalog = active_registry().unwrap();
25027 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25028 let workspace_id = WorkspaceId::new("primary").unwrap();
25029 let workspace = WorkspaceConfig::new(
25030 workspace_id.clone(),
25031 std::env::current_dir().unwrap(),
25032 )
25033 .unwrap();
25034 let shared = NodeShared::new(
25035 handle,
25036 "fixture-token".to_owned(),
25037 NodeId::new("node-1").unwrap(),
25038 vec![workspace],
25039 vec![agent("claude")],
25040 );
25041 for index in 0..CONTROL_SESSIONS_MAX {
25042 shared.bind_session(
25043 AgentInstanceId((index + 1) as u64),
25044 workspace_id.clone(),
25045 SessionGeneration(1),
25046 );
25047 }
25048 let error = shared.ensure_binding_capacity().unwrap_err();
25049 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25050 }
25051
25052 #[tokio::test]
25053 async fn provider_spawn_admission_rejects_before_session_mutation() {
25054 let catalog = active_registry().unwrap();
25055 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25056 let workspace = WorkspaceConfig::new(
25057 WorkspaceId::new("primary").unwrap(),
25058 std::env::current_dir().unwrap(),
25059 )
25060 .unwrap();
25061 let mut shared = NodeShared::new(
25062 handle,
25063 "fixture-token".to_owned(),
25064 NodeId::new("node-provider-admission").unwrap(),
25065 vec![workspace],
25066 vec![agent("claude")],
25067 );
25068 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25069 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25070 ])
25071 .unwrap();
25072 let terminal_size = gate4agent_types::TerminalSize {
25073 rows: 24,
25074 columns: 80,
25075 };
25076
25077 let unavailable = shared
25078 .spawn_session(
25079 WorkspaceId::new("primary").unwrap(),
25080 agent("claude"),
25081 SessionMode::Pty,
25082 terminal_size,
25083 None,
25084 )
25085 .await
25086 .unwrap_err();
25087 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25088 assert_eq!(unavailable.message, "backend-operation-failed");
25089
25090 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25091 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25092 agent("claude"),
25093 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25094 ),
25095 ])
25096 .unwrap();
25097 let semantic = shared
25098 .spawn_session(
25099 WorkspaceId::new("primary").unwrap(),
25100 agent("claude"),
25101 SessionMode::Inline,
25102 terminal_size,
25103 None,
25104 )
25105 .await
25106 .unwrap_err();
25107 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25108 assert_eq!(semantic.message, "unsupported-capability");
25109
25110 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25111 assert!(shared
25112 .session_bindings
25113 .lock()
25114 .unwrap_or_else(|poisoned| poisoned.into_inner())
25115 .is_empty());
25116 assert!(shared
25117 .session_records
25118 .lock()
25119 .unwrap_or_else(|poisoned| poisoned.into_inner())
25120 .records
25121 .is_empty());
25122 assert!(shared.handle.snapshot().sessions.is_empty());
25123 }
25124
25125 #[tokio::test]
25126 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25127 let catalog = active_registry().unwrap();
25128 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25129 let workspace_id = WorkspaceId::new("primary").unwrap();
25130 let workspace = WorkspaceConfig::new(
25131 workspace_id.clone(),
25132 std::env::current_dir().unwrap(),
25133 )
25134 .unwrap();
25135 let shared = NodeShared::new(
25136 handle,
25137 "fixture-token".to_owned(),
25138 NodeId::new("node-raw-spawn-record").unwrap(),
25139 vec![workspace],
25140 vec![agent("claude")],
25141 );
25142 let address = SessionAddress {
25143 workspace_id,
25144 session: SessionKey {
25145 instance_id: AgentInstanceId(71),
25146 generation: SessionGeneration::default(),
25147 },
25148 };
25149
25150 let record_id = shared
25151 .bind_spawn_session(
25152 &address,
25153 agent("claude"),
25154 SessionMode::Pty,
25155 ProviderRuntimePolicy::raw_pty(),
25156 SpawnRecordPolicy::Always,
25157 None,
25158 )
25159 .unwrap()
25160 .unwrap();
25161 shared
25162 .handle
25163 .dispatch(shared.prepare_command(ControlCommand::Register {
25164 instance_id: address.session.instance_id,
25165 agent_id: agent("claude"),
25166 transport: TransportKind::Pty,
25167 }))
25168 .unwrap();
25169 runtime.tick().await;
25170
25171 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25172 session.instance_id == address.session.instance_id
25173 && session.generation == address.session.generation
25174 }));
25175 let record = shared.record(&record_id).unwrap();
25176 assert_eq!(record.display_name, "claude #1");
25177 assert_eq!(record.state, ManagedSessionState::Live);
25178 assert_eq!(record.active_session.as_ref(), Some(&address));
25179 assert!(record.provider_session.is_none());
25180 let binding_record_id = shared
25181 .session_bindings
25182 .lock()
25183 .unwrap_or_else(|poisoned| poisoned.into_inner())
25184 .get(&address.session.instance_id)
25185 .and_then(|binding| binding.record_id.clone());
25186 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25187
25188 let renamed = shared
25189 .rename_session_record(&record_id, "raw review".to_owned())
25190 .unwrap();
25191 assert_eq!(renamed.record_id, record_id);
25192 assert_eq!(renamed.display_name, "raw review");
25193 shared.reconcile_managed_record(
25194 &record_id,
25195 &address,
25196 &ControlEvent {
25197 sequence: 1,
25198 command_id: None,
25199 instance_id: address.session.instance_id,
25200 generation: address.session.generation,
25201 event: ControlEventKind::Running { process_id: None },
25202 },
25203 false,
25204 false,
25205 );
25206 let running = shared.record(&record_id).unwrap();
25207 assert_eq!(running.state, ManagedSessionState::Live);
25208 assert_eq!(running.active_session.as_ref(), Some(&address));
25209 assert_eq!(running.display_name, "raw review");
25210
25211 shared.reconcile_managed_record(
25212 &record_id,
25213 &address,
25214 &ControlEvent {
25215 sequence: 2,
25216 command_id: None,
25217 instance_id: address.session.instance_id,
25218 generation: address.session.generation,
25219 event: ControlEventKind::Exited {
25220 exit_code: Some(0),
25221 forced: false,
25222 },
25223 },
25224 false,
25225 false,
25226 );
25227 let detached = shared.record(&record_id).unwrap();
25228 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25229 assert!(detached.active_session.is_none());
25230 assert_eq!(detached.display_name, "raw review");
25231 let renamed_detached = shared
25232 .rename_session_record(&record_id, "raw review detached".to_owned())
25233 .unwrap();
25234 assert_eq!(renamed_detached.record_id, record_id);
25235 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25236 assert!(renamed_detached.active_session.is_none());
25237 assert_eq!(renamed_detached.display_name, "raw review detached");
25238 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25239 }
25240
25241 #[test]
25242 fn legacy_raw_spawn_binding_remains_untracked() {
25243 let catalog = active_registry().unwrap();
25244 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25245 let workspace_id = WorkspaceId::new("primary").unwrap();
25246 let workspace = WorkspaceConfig::new(
25247 workspace_id.clone(),
25248 std::env::current_dir().unwrap(),
25249 )
25250 .unwrap();
25251 let shared = NodeShared::new(
25252 handle,
25253 "fixture-token".to_owned(),
25254 NodeId::new("node-legacy-raw-spawn").unwrap(),
25255 vec![workspace],
25256 vec![agent("claude")],
25257 );
25258 let address = SessionAddress {
25259 workspace_id,
25260 session: SessionKey {
25261 instance_id: AgentInstanceId(72),
25262 generation: SessionGeneration::default(),
25263 },
25264 };
25265
25266 let record_id = shared
25267 .bind_spawn_session(
25268 &address,
25269 agent("claude"),
25270 SessionMode::Pty,
25271 ProviderRuntimePolicy::raw_pty(),
25272 SpawnRecordPolicy::ProviderIdentityOnly,
25273 None,
25274 )
25275 .unwrap();
25276
25277 assert!(record_id.is_none());
25278 let binding_record_id = shared
25279 .session_bindings
25280 .lock()
25281 .unwrap_or_else(|poisoned| poisoned.into_inner())
25282 .get(&address.session.instance_id)
25283 .and_then(|binding| binding.record_id.clone());
25284 assert!(binding_record_id.is_none());
25285 assert!(shared.snapshot().session_records.is_empty());
25286 }
25287
25288 #[tokio::test]
25289 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25290 let catalog = active_registry().unwrap();
25291 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25292 let workspace_id = WorkspaceId::new("primary").unwrap();
25293 let workspace = WorkspaceConfig::new(
25294 workspace_id.clone(),
25295 std::env::current_dir().unwrap(),
25296 )
25297 .unwrap();
25298 let mut shared = NodeShared::new(
25299 handle,
25300 "fixture-token".to_owned(),
25301 NodeId::new("node-raw-semantic-gate").unwrap(),
25302 vec![workspace],
25303 vec![agent("claude")],
25304 );
25305 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25306 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25307 ])
25308 .unwrap();
25309 shared
25310 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25311 .unwrap();
25312 let address = SessionAddress {
25313 workspace_id,
25314 session: SessionKey {
25315 instance_id: AgentInstanceId(72),
25316 generation: SessionGeneration::default(),
25317 },
25318 };
25319 shared.bind_session_with_policy(
25320 &address,
25321 ProviderRuntimePolicy::raw_pty(),
25322 None,
25323 );
25324 shared
25325 .handle
25326 .dispatch(shared.prepare_command(ControlCommand::Register {
25327 instance_id: address.session.instance_id,
25328 agent_id: agent("claude"),
25329 transport: TransportKind::Pty,
25330 }))
25331 .unwrap();
25332 runtime.tick().await;
25333 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25334 let revision_before = shared.handle.snapshot().revision;
25335 let history_sequence_before = shared.current_sequence();
25336 let terminal_size = gate4agent_types::TerminalSize {
25337 rows: 24,
25338 columns: 80,
25339 };
25340
25341 for request in [
25342 NodeRequest::Prompt {
25343 session: address.clone(),
25344 text: "prompt".to_owned(),
25345 },
25346 NodeRequest::Paste {
25347 session: address.clone(),
25348 text: "paste".to_owned(),
25349 },
25350 NodeRequest::Resume {
25356 session: address.clone(),
25357 terminal_size,
25358 initial_prompt: Some("resume".to_owned()),
25359 },
25360 ] {
25361 let error = process_request_inner(
25362 &shared,
25363 77,
25364 ClientRole::Operator,
25365 request,
25366 )
25367 .await
25368 .unwrap_err();
25369 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25370 }
25371
25372 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25373 assert_eq!(shared.handle.snapshot().revision, revision_before);
25374 assert_eq!(shared.current_sequence(), history_sequence_before);
25375 let bindings = shared
25376 .session_bindings
25377 .lock()
25378 .unwrap_or_else(|poisoned| poisoned.into_inner());
25379 let binding = bindings.get(&address.session.instance_id).unwrap();
25380 assert_eq!(binding.generation, address.session.generation);
25381 assert!(binding.pending_resume.is_none());
25382 assert!(binding.record_id.is_none());
25383 }
25384
25385 #[tokio::test]
25386 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25387 let catalog = active_registry().unwrap();
25388 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25389 let primary = WorkspaceConfig::new(
25390 WorkspaceId::new("primary").unwrap(),
25391 std::env::current_dir().unwrap(),
25392 )
25393 .unwrap();
25394 let shared = NodeShared::new(
25395 handle,
25396 "fixture-token".to_owned(),
25397 NodeId::new("node-1").unwrap(),
25398 vec![primary],
25399 vec![agent("claude")],
25400 );
25401 shared
25402 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25403 .unwrap();
25404 let before = shared.snapshot();
25405 let error = process_request_inner(
25406 &shared,
25407 77,
25408 ClientRole::Operator,
25409 NodeRequest::RegisterWorkspace {
25410 workspace_id: WorkspaceId::new("foreign").unwrap(),
25411 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25412 },
25413 )
25414 .await
25415 .unwrap_err();
25416 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25417 assert_eq!(error.message, "invalid-request");
25418 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25419 }
25420
25421 #[tokio::test]
25422 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25423 let catalog = active_registry().unwrap();
25424 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25425 let primary_id = WorkspaceId::new("primary").unwrap();
25426 let primary = WorkspaceConfig::new(
25427 primary_id.clone(),
25428 std::env::current_dir().unwrap(),
25429 )
25430 .unwrap();
25431 let shared = NodeShared::new(
25432 handle,
25433 "fixture-token".to_owned(),
25434 NodeId::new("node-1").unwrap(),
25435 vec![primary.clone()],
25436 vec![agent("claude")],
25437 );
25438 let secondary_id = WorkspaceId::new("secondary").unwrap();
25439 let secondary = shared
25440 .register_workspace(
25441 secondary_id.clone(),
25442 std::env::temp_dir().to_string_lossy().into_owned(),
25443 )
25444 .await
25445 .unwrap();
25446 assert_eq!(secondary.workspace_id, secondary_id);
25447 assert!(secondary.sessions.is_empty());
25448 assert_eq!(shared.snapshot().workspaces.len(), 2);
25449
25450 let duplicate_id = shared
25451 .register_workspace(
25452 secondary_id.clone(),
25453 primary.canonical_root().to_owned(),
25454 )
25455 .await
25456 .unwrap_err();
25457 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25458 let duplicate_root = shared
25459 .register_workspace(
25460 WorkspaceId::new("duplicate-root").unwrap(),
25461 windows_path_text(&secondary.canonical_root).to_owned(),
25462 )
25463 .await
25464 .unwrap_err();
25465 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25466
25467 let address = SessionAddress {
25468 workspace_id: secondary_id.clone(),
25469 session: SessionKey {
25470 instance_id: AgentInstanceId(41),
25471 generation: SessionGeneration(1),
25472 },
25473 };
25474 shared.bind_session(
25475 address.session.instance_id,
25476 secondary_id.clone(),
25477 address.session.generation,
25478 );
25479 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25480 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25481 shared.remove_binding(&address);
25482 shared.unregister_workspace(&secondary_id).unwrap();
25483 assert_eq!(shared.snapshot().workspaces.len(), 1);
25484
25485 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25486 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25487 let history = shared
25488 .history
25489 .lock()
25490 .unwrap_or_else(|poisoned| poisoned.into_inner());
25491 assert!(matches!(
25492 &history.events[0],
25493 NodeEventEnvelope {
25494 sequence: 1,
25495 event: NodeEvent::WorkspaceAdded { workspace },
25496 } if workspace.workspace_id == secondary_id
25497 ));
25498 assert!(matches!(
25499 &history.events[1],
25500 NodeEventEnvelope {
25501 sequence: 2,
25502 event: NodeEvent::WorkspaceRemoved { workspace_id },
25503 } if workspace_id == &secondary_id
25504 ));
25505 }
25506
25507 #[tokio::test]
25508 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25509 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25510 std::fs::create_dir_all(&secondary_root).unwrap();
25511 let catalog = active_registry().unwrap();
25512 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25513 let primary_id = WorkspaceId::new("primary").unwrap();
25514 let secondary_id = WorkspaceId::new("secondary").unwrap();
25515 let shared = NodeShared::new(
25516 handle,
25517 "fixture-token".to_owned(),
25518 NodeId::new("node-session-reference-index").unwrap(),
25519 vec![
25520 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25521 .unwrap(),
25522 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25523 ],
25524 vec![agent("claude"), agent("codex")],
25525 );
25526 let identity = ProviderSessionIdentity {
25527 key: gate4agent_types::ProviderSessionKey::SessionId,
25528 id: "provider-session-42".to_owned(),
25529 transcript_path: None,
25530 };
25531 let index_request = |workspace_id: WorkspaceId,
25532 provider: AgentId,
25533 identity: ProviderSessionIdentity,
25534 display_name: &str| {
25535 NodeRequest::IndexProviderSession {
25536 workspace_id,
25537 provider,
25538 identity,
25539 display_name: display_name.to_owned(),
25540 }
25541 };
25542 let request = index_request(
25543 primary_id.clone(),
25544 agent("claude"),
25545 identity.clone(),
25546 "release shepherd",
25547 );
25548 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25549 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25550 }));
25551 let observer = process_request_inner(
25552 &shared,
25553 77,
25554 ClientRole::Observer,
25555 request.clone(),
25556 )
25557 .await
25558 .unwrap_err();
25559 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25560 let uncontrolled = process_request_inner(
25561 &shared,
25562 77,
25563 ClientRole::Operator,
25564 request.clone(),
25565 )
25566 .await
25567 .unwrap_err();
25568 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25569 shared
25570 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25571 .unwrap();
25572
25573 let indexed = process_request_inner(
25574 &shared,
25575 77,
25576 ClientRole::Operator,
25577 request.clone(),
25578 )
25579 .await
25580 .unwrap();
25581 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25582 panic!("provider session reference index returned another response");
25583 };
25584 assert_eq!(record.state, ManagedSessionState::Dormant);
25585 assert_eq!(record.mode, SessionMode::Pty);
25586 assert_eq!(record.workspace_id, primary_id);
25587 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25588 assert!(record.active_session.is_none());
25589 assert!(record.environment_profile.is_none());
25590 assert!(record.bundle.is_none());
25591 assert!(record.context.is_none());
25592
25593 let repeated = process_request_inner(
25594 &shared,
25595 77,
25596 ClientRole::Operator,
25597 index_request(
25598 primary_id.clone(),
25599 agent("claude"),
25600 identity.clone(),
25601 "ignored replacement",
25602 ),
25603 )
25604 .await
25605 .unwrap();
25606 assert!(matches!(
25607 repeated,
25608 NodeResponse::ProviderSessionIndexed { record: ref existing }
25609 if existing.record_id == record.record_id
25610 && existing.display_name == "release shepherd"
25611 ));
25612 let conflicting_workspace = process_request_inner(
25613 &shared,
25614 77,
25615 ClientRole::Operator,
25616 index_request(
25617 secondary_id,
25618 agent("claude"),
25619 identity.clone(),
25620 "release shepherd",
25621 ),
25622 )
25623 .await
25624 .unwrap_err();
25625 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25626 let conflicting_provider = process_request_inner(
25627 &shared,
25628 77,
25629 ClientRole::Operator,
25630 index_request(
25631 primary_id.clone(),
25632 agent("codex"),
25633 identity.clone(),
25634 "release shepherd",
25635 ),
25636 )
25637 .await
25638 .unwrap_err();
25639 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25640 let transcript_rejected = process_request_inner(
25641 &shared,
25642 77,
25643 ClientRole::Operator,
25644 index_request(
25645 primary_id,
25646 agent("claude"),
25647 ProviderSessionIdentity {
25648 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25649 ..identity
25650 },
25651 "release shepherd",
25652 ),
25653 )
25654 .await
25655 .unwrap_err();
25656 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25657 let upserts = shared
25658 .history
25659 .lock()
25660 .unwrap_or_else(|poisoned| poisoned.into_inner())
25661 .events
25662 .iter()
25663 .filter(|event| matches!(
25664 &event.event,
25665 NodeEvent::SessionRecordUpserted { record: published }
25666 if published.record_id == record.record_id
25667 ))
25668 .count();
25669 assert_eq!(upserts, 1);
25670 std::fs::remove_dir_all(secondary_root).unwrap();
25671 }
25672
25673 #[tokio::test]
25674 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25675 let fixture_root = temporary_workspace_root("standalone-independent");
25676 let target = fixture_root.join("independent");
25677 std::fs::create_dir_all(&fixture_root).unwrap();
25678 let catalog = active_registry().unwrap();
25679 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25680 let selected = WorkspaceConfig::new(
25681 WorkspaceId::new("primary").unwrap(),
25682 std::env::current_dir().unwrap(),
25683 )
25684 .unwrap();
25685 let selected_root = selected.canonical_root().to_owned();
25686 let shared = NodeShared::new(
25687 handle,
25688 "fixture-token".to_owned(),
25689 NodeId::new("node-standalone").unwrap(),
25690 vec![selected],
25691 vec![agent("claude")],
25692 );
25693 let workspace_id = WorkspaceId::new("independent").unwrap();
25694 let request = NodeRequest::CreateStandaloneWorkspace {
25695 workspace_id: workspace_id.clone(),
25696 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25697 initial_branch: Some("main".to_owned()),
25698 };
25699 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25700 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25701 }));
25702 assert!(!request_uses_unnegotiated_capability(
25703 &request,
25704 &baseline_capabilities().unwrap(),
25705 ));
25706 assert!(request_uses_unnegotiated_capability(&request, &[]));
25707
25708 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25709 .await
25710 .unwrap_err();
25711 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25712 assert!(!target.exists());
25713 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25714 .await
25715 .unwrap_err();
25716 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25717 assert!(!target.exists());
25718 shared
25719 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25720 .unwrap();
25721
25722 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25723 .await
25724 .unwrap();
25725 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25726 panic!("standalone workspace creation returned another response");
25727 };
25728 assert_eq!(workspace.workspace_id, workspace_id);
25729 assert!(target.join(".git").is_dir());
25730 let branch = run_git_bounded(
25731 target.to_str().unwrap(),
25732 &["branch", "--show-current"],
25733 GIT_OUTPUT_MAX_BYTES,
25734 )
25735 .await
25736 .unwrap();
25737 assert!(branch.success);
25738 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25739 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25740 assert_eq!(worktrees.len(), 1);
25741 let listed_root = WorkspaceConfig::new(
25742 WorkspaceId::new("listed-independent").unwrap(),
25743 &worktrees[0].path,
25744 )
25745 .unwrap();
25746 assert!(platform::roots_equal(
25747 listed_root.canonical_root(),
25748 windows_path_text(&workspace.canonical_root),
25749 ));
25750 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25751 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25752 snapshot.workspace_id == workspace_id
25753 && snapshot.canonical_root == workspace.canonical_root
25754 }));
25755 assert!(shared
25756 .history
25757 .lock()
25758 .unwrap_or_else(|poisoned| poisoned.into_inner())
25759 .events
25760 .iter()
25761 .any(|event| matches!(
25762 &event.event,
25763 NodeEvent::WorkspaceAdded { workspace }
25764 if workspace.workspace_id == workspace_id
25765 )));
25766 std::fs::remove_dir_all(fixture_root).unwrap();
25767 }
25768
25769 #[tokio::test]
25770 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25771 let fixture_root = temporary_workspace_root("standalone-compensation");
25772 let created_target = fixture_root.join("created-target");
25773 let existing_target = fixture_root.join("existing-target");
25774 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25775 let invalid_state_path = invalid_state_parent.join("state.json");
25776 std::fs::create_dir_all(&existing_target).unwrap();
25777 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25778 let catalog = active_registry().unwrap();
25779 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25780 let primary = WorkspaceConfig::new(
25781 WorkspaceId::new("primary").unwrap(),
25782 std::env::current_dir().unwrap(),
25783 )
25784 .unwrap();
25785 let mut shared = NodeShared::new(
25786 handle,
25787 "fixture-token".to_owned(),
25788 NodeId::new("node-standalone-compensation").unwrap(),
25789 vec![primary],
25790 vec![agent("claude")],
25791 );
25792 shared.state_path = Some(invalid_state_path);
25793
25794 let created_recovery = shared
25795 .create_standalone_workspace(
25796 WorkspaceId::new("compensated").unwrap(),
25797 created_target.to_string_lossy().into_owned(),
25798 Some("main".to_owned()),
25799 )
25800 .await
25801 .unwrap_err();
25802 assert_eq!(
25803 created_recovery.code,
25804 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25805 );
25806 assert!(created_target.join(".git").is_dir());
25807 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25808 workspace.workspace_id.as_str() != "compensated"
25809 }));
25810
25811 let existing_recovery = shared
25812 .create_standalone_workspace(
25813 WorkspaceId::new("restored").unwrap(),
25814 existing_target.to_string_lossy().into_owned(),
25815 None,
25816 )
25817 .await
25818 .unwrap_err();
25819 assert_eq!(
25820 existing_recovery.code,
25821 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25822 );
25823 assert!(existing_target.is_dir());
25824 assert!(existing_target.join(".git").is_dir());
25825 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25826 workspace.workspace_id.as_str() != "restored"
25827 }));
25828
25829 let registered_empty = fixture_root.join("registered-empty");
25830 std::fs::create_dir(®istered_empty).unwrap();
25831 let catalog = active_registry().unwrap();
25832 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25833 let registered = WorkspaceConfig::new(
25834 WorkspaceId::new("registered-empty").unwrap(),
25835 ®istered_empty,
25836 )
25837 .unwrap();
25838 let duplicate_shared = NodeShared::new(
25839 handle,
25840 "fixture-token".to_owned(),
25841 NodeId::new("node-standalone-duplicate-root").unwrap(),
25842 vec![registered],
25843 vec![agent("claude")],
25844 );
25845 let duplicate = duplicate_shared
25846 .create_standalone_workspace(
25847 WorkspaceId::new("duplicate-root").unwrap(),
25848 registered_empty.to_string_lossy().into_owned(),
25849 None,
25850 )
25851 .await
25852 .unwrap_err();
25853 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25854 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25855 std::fs::remove_dir_all(fixture_root).unwrap();
25856 }
25857
25858 #[test]
25859 fn managed_record_id_collision_preserves_the_existing_record() {
25860 let catalog = active_registry().unwrap();
25861 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25862 let workspace = WorkspaceConfig::new(
25863 WorkspaceId::new("primary").unwrap(),
25864 std::env::current_dir().unwrap(),
25865 )
25866 .unwrap();
25867 let shared = NodeShared::new(
25868 handle,
25869 "fixture-token".to_owned(),
25870 NodeId::new("node-1").unwrap(),
25871 vec![workspace.clone()],
25872 vec![agent("claude")],
25873 );
25874 let record_id = SessionRecordId::new("sr-collision").unwrap();
25875 let original = ManagedSessionRecord {
25876 record_id: record_id.clone(),
25877 display_name: "original".to_owned(),
25878 provider: agent("claude"),
25879 mode: SessionMode::Pty,
25880 state: ManagedSessionState::Dormant,
25881 workspace_id: workspace.workspace_id,
25882 canonical_root: opaque_windows_path(workspace.canonical_root),
25883 provider_session: None,
25884 active_session: None,
25885 environment_profile: None,
25886 bundle: None,
25887 context_id: None,
25888 context: None,
25889 exported_context: None,
25890 task_binding: None,
25891 created_at_unix_ms: 1,
25892 updated_at_unix_ms: 1,
25893 last_error: None,
25894 };
25895 shared.insert_record(original.clone()).unwrap();
25896 let mut collision = original.clone();
25897 collision.display_name = "replacement".to_owned();
25898 let error = shared.insert_record(collision).unwrap_err();
25899 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25900 assert_eq!(shared.record(&record_id).unwrap(), original);
25901 }
25902
25903 #[test]
25904 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25905 let catalog = active_registry().unwrap();
25906 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25907 let workspace = WorkspaceConfig::new(
25908 WorkspaceId::new("primary").unwrap(),
25909 std::env::current_dir().unwrap(),
25910 )
25911 .unwrap();
25912 let shared = NodeShared::new(
25913 handle,
25914 "fixture-token".to_owned(),
25915 NodeId::new("node-1").unwrap(),
25916 vec![workspace.clone()],
25917 vec![agent("claude")],
25918 );
25919 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25920 shared
25921 .insert_record(ManagedSessionRecord {
25922 record_id: record_id.clone(),
25923 display_name: "runtime error".to_owned(),
25924 provider: agent("claude"),
25925 mode: SessionMode::Pty,
25926 state: ManagedSessionState::IdentityPending,
25927 workspace_id: workspace.workspace_id,
25928 canonical_root: opaque_windows_path(workspace.canonical_root),
25929 provider_session: None,
25930 active_session: None,
25931 environment_profile: None,
25932 bundle: None,
25933 context_id: None,
25934 context: None,
25935 exported_context: None,
25936 task_binding: None,
25937 created_at_unix_ms: 1,
25938 updated_at_unix_ms: 1,
25939 last_error: None,
25940 })
25941 .unwrap();
25942
25943 shared
25944 .mark_record_error(
25945 &record_id,
25946 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25947 )
25948 .unwrap();
25949 let record = shared.record(&record_id).unwrap();
25950 assert_eq!(record.state, ManagedSessionState::Unavailable);
25951 assert_eq!(
25952 record.last_error.as_deref(),
25953 Some("provider-runtime-failed"),
25954 );
25955 let snapshot = shared.snapshot();
25956 assert_eq!(snapshot.session_records, vec![record]);
25957 }
25958
25959 #[test]
25968 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25969 let catalog = active_registry().unwrap();
25970 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25971 let workspace = WorkspaceConfig::new(
25972 WorkspaceId::new("primary").unwrap(),
25973 std::env::current_dir().unwrap(),
25974 )
25975 .unwrap();
25976 let shared = NodeShared::new(
25977 handle,
25978 "fixture-token".to_owned(),
25979 NodeId::new("node-1").unwrap(),
25980 vec![workspace],
25981 vec![agent("claude")],
25982 );
25983
25984 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25985
25986 shared.record_rejected_command(
25987 CommandId(1),
25988 "agent 'claude' does not support transport Acp".to_owned(),
25989 true,
25990 );
25991 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25993
25994 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25995 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25996 assert!(reason.unsupported_transport);
25997
25998 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26000
26001 shared.record_rejected_command(
26006 CommandId(3),
26007 "backend busy".to_owned(),
26008 false,
26009 );
26010 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26011 assert!(!unclassified.unsupported_transport);
26012 }
26013
26014 #[test]
26015 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26016 let catalog = active_registry().unwrap();
26017 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26018 let workspace = WorkspaceConfig::new(
26019 WorkspaceId::new("primary").unwrap(),
26020 std::env::current_dir().unwrap(),
26021 )
26022 .unwrap();
26023 let shared = NodeShared::new(
26024 handle,
26025 "fixture-token".to_owned(),
26026 NodeId::new("node-identity-policy").unwrap(),
26027 vec![workspace.clone()],
26028 vec![agent("claude")],
26029 );
26030 let address = SessionAddress {
26031 workspace_id: workspace.workspace_id.clone(),
26032 session: SessionKey {
26033 instance_id: AgentInstanceId(90),
26034 generation: SessionGeneration(1),
26035 },
26036 };
26037 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26038 shared
26039 .insert_record(ManagedSessionRecord {
26040 record_id: record_id.clone(),
26041 display_name: "policy denied".to_owned(),
26042 provider: agent("claude"),
26043 mode: SessionMode::Pty,
26044 state: ManagedSessionState::IdentityPending,
26045 workspace_id: workspace.workspace_id,
26046 canonical_root: opaque_windows_path(workspace.canonical_root),
26047 provider_session: None,
26048 active_session: Some(address.clone()),
26049 environment_profile: None,
26050 bundle: None,
26051 context_id: None,
26052 context: None,
26053 exported_context: None,
26054 task_binding: None,
26055 created_at_unix_ms: 1,
26056 updated_at_unix_ms: 1,
26057 last_error: None,
26058 })
26059 .unwrap();
26060 let event = ControlEvent {
26061 sequence: 1,
26062 command_id: None,
26063 instance_id: address.session.instance_id,
26064 generation: address.session.generation,
26065 event: ControlEventKind::ProviderEvent {
26066 sequence: 1,
26067 source: gate4agent_types::ProviderSource {
26068 family: AdapterFamily::PtySemantic,
26069 binding: AdapterBinding::new(
26070 gate4agent_types::AdapterId::new("claude").unwrap(),
26071 "fixture/v1",
26072 gate4agent_types::AdapterVerification::SyntheticFixture,
26073 )
26074 .unwrap(),
26075 },
26076 source_sequence: 1,
26077 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26078 identity: gate4agent_types::ProviderSessionIdentity {
26079 key: gate4agent_types::ProviderSessionKey::SessionId,
26080 id: "provider-session".to_owned(),
26081 transcript_path: None,
26082 },
26083 },
26084 },
26085 };
26086
26087 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26088
26089 let record = shared.record(&record_id).unwrap();
26090 assert!(record.provider_session.is_none());
26091 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26092 assert_eq!(record.active_session.as_ref(), Some(&address));
26093 }
26094
26095 #[test]
26096 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26097 let secondary_root = temporary_workspace_root("identity-scope");
26098 std::fs::create_dir_all(&secondary_root).unwrap();
26099 let catalog = active_registry().unwrap();
26100 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26101 let primary = WorkspaceConfig::new(
26102 WorkspaceId::new("primary").unwrap(),
26103 std::env::current_dir().unwrap(),
26104 )
26105 .unwrap();
26106 let secondary = WorkspaceConfig::new(
26107 WorkspaceId::new("secondary").unwrap(),
26108 &secondary_root,
26109 )
26110 .unwrap();
26111 let shared = NodeShared::new(
26112 handle,
26113 "fixture-token".to_owned(),
26114 NodeId::new("node-1").unwrap(),
26115 vec![primary.clone(), secondary.clone()],
26116 vec![agent("claude")],
26117 );
26118 let identity = gate4agent_types::ProviderSessionIdentity {
26119 key: gate4agent_types::ProviderSessionKey::SessionId,
26120 id: "provider-session".to_owned(),
26121 transcript_path: None,
26122 };
26123 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26124 shared
26125 .insert_record(ManagedSessionRecord {
26126 record_id: dormant_id.clone(),
26127 display_name: "dormant".to_owned(),
26128 provider: agent("claude"),
26129 mode: SessionMode::Pty,
26130 state: ManagedSessionState::Dormant,
26131 workspace_id: primary.workspace_id,
26132 canonical_root: opaque_windows_path(primary.canonical_root),
26133 provider_session: Some(identity.clone()),
26134 active_session: None,
26135 environment_profile: None,
26136 bundle: None,
26137 context_id: None,
26138 context: None,
26139 exported_context: None,
26140 task_binding: None,
26141 created_at_unix_ms: 1,
26142 updated_at_unix_ms: 1,
26143 last_error: None,
26144 })
26145 .unwrap();
26146 let address = SessionAddress {
26147 workspace_id: secondary.workspace_id.clone(),
26148 session: SessionKey {
26149 instance_id: AgentInstanceId(91),
26150 generation: SessionGeneration(1),
26151 },
26152 };
26153 let current_id = SessionRecordId::new("sr-current").unwrap();
26154 shared
26155 .insert_record(ManagedSessionRecord {
26156 record_id: current_id.clone(),
26157 display_name: "current".to_owned(),
26158 provider: agent("claude"),
26159 mode: SessionMode::Pty,
26160 state: ManagedSessionState::IdentityPending,
26161 workspace_id: secondary.workspace_id,
26162 canonical_root: opaque_windows_path(secondary.canonical_root),
26163 provider_session: None,
26164 active_session: Some(address.clone()),
26165 environment_profile: None,
26166 bundle: None,
26167 context_id: None,
26168 context: None,
26169 exported_context: None,
26170 task_binding: None,
26171 created_at_unix_ms: 2,
26172 updated_at_unix_ms: 2,
26173 last_error: None,
26174 })
26175 .unwrap();
26176 shared.bind_managed_session(
26177 &address,
26178 current_id.clone(),
26179 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26180 None,
26181 );
26182 let event = ControlEvent {
26183 sequence: 1,
26184 command_id: None,
26185 instance_id: address.session.instance_id,
26186 generation: address.session.generation,
26187 event: ControlEventKind::ProviderEvent {
26188 sequence: 1,
26189 source: gate4agent_types::ProviderSource {
26190 family: gate4agent_types::AdapterFamily::PtySemantic,
26191 binding: gate4agent_types::AdapterBinding::new(
26192 gate4agent_types::AdapterId::new("claude").unwrap(),
26193 "fixture/v1",
26194 gate4agent_types::AdapterVerification::SyntheticFixture,
26195 )
26196 .unwrap(),
26197 },
26198 source_sequence: 1,
26199 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26200 identity,
26201 },
26202 },
26203 };
26204 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26205
26206 let binding = shared
26207 .session_bindings
26208 .lock()
26209 .unwrap_or_else(|poisoned| poisoned.into_inner())
26210 .get(&address.session.instance_id)
26211 .cloned()
26212 .unwrap();
26213 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26214 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26215 let current = shared.record(¤t_id).unwrap();
26216 assert_eq!(current.state, ManagedSessionState::Unavailable);
26217 assert_eq!(current.active_session.as_ref(), Some(&address));
26218 assert_eq!(
26219 current.last_error.as_deref(),
26220 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26221 );
26222 std::fs::remove_dir_all(secondary_root).unwrap();
26223 }
26224
26225 #[tokio::test]
26226 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26227 let root = temporary_workspace_root("semantic-provider-session");
26228 std::fs::create_dir_all(&root).unwrap();
26229 let state_path = root.join("state-v1.json");
26230 let catalog = active_registry().unwrap();
26231 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26232 let workspace = WorkspaceConfig::new(
26233 WorkspaceId::new("primary").unwrap(),
26234 &root,
26235 )
26236 .unwrap();
26237 let node_id = NodeId::new("node-semantic-identity").unwrap();
26238 let shared = NodeShared::new_with_incarnation(
26239 handle,
26240 "fixture-token".to_owned(),
26241 node_id.clone(),
26242 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26243 vec![workspace.clone()],
26244 vec![agent("claude")],
26245 ProviderRuntimeStatuses::default(),
26246 None,
26247 Vec::new(),
26248 Vec::new(),
26249 SpawnProfileRegistry::default(),
26250 None,
26251 None,
26252 None,
26253 Some(state_path.clone()),
26254 Vec::new(),
26255 Vec::new(),
26256 Vec::new(),
26257 Vec::new(),
26258 None,
26259 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26260 );
26261 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26262 key: gate4agent_types::ProviderSessionKey::SessionId,
26263 id: "provider-session".to_owned(),
26264 transcript_path: None,
26265 };
26266 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26267 shared
26268 .insert_record(ManagedSessionRecord {
26269 record_id: durable_id.clone(),
26270 display_name: "durable".to_owned(),
26271 provider: agent("claude"),
26272 mode: SessionMode::Pty,
26273 state: ManagedSessionState::Dormant,
26274 workspace_id: workspace.workspace_id.clone(),
26275 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26276 provider_session: Some(persisted_identity.clone()),
26277 active_session: None,
26278 environment_profile: None,
26279 bundle: None,
26280 context_id: None,
26281 context: None,
26282 exported_context: None,
26283 task_binding: None,
26284 created_at_unix_ms: 1,
26285 updated_at_unix_ms: 1,
26286 last_error: None,
26287 })
26288 .unwrap();
26289 let address = SessionAddress {
26290 workspace_id: workspace.workspace_id.clone(),
26291 session: SessionKey {
26292 instance_id: AgentInstanceId(92),
26293 generation: SessionGeneration(2),
26294 },
26295 };
26296 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26297 shared
26298 .insert_record(ManagedSessionRecord {
26299 record_id: pending_id.clone(),
26300 display_name: "pending".to_owned(),
26301 provider: agent("claude"),
26302 mode: SessionMode::Pty,
26303 state: ManagedSessionState::IdentityPending,
26304 workspace_id: workspace.workspace_id.clone(),
26305 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26306 provider_session: None,
26307 active_session: Some(address.clone()),
26308 environment_profile: None,
26309 bundle: None,
26310 context_id: None,
26311 context: None,
26312 exported_context: None,
26313 task_binding: None,
26314 created_at_unix_ms: 2,
26315 updated_at_unix_ms: 2,
26316 last_error: None,
26317 })
26318 .unwrap();
26319 shared.bind_managed_session(
26320 &address,
26321 pending_id.clone(),
26322 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26323 None,
26324 );
26325 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26326 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26327 ..persisted_identity
26328 };
26329 let event = ControlEvent {
26330 sequence: 1,
26331 command_id: None,
26332 instance_id: address.session.instance_id,
26333 generation: address.session.generation,
26334 event: ControlEventKind::ProviderEvent {
26335 sequence: 1,
26336 source: gate4agent_types::ProviderSource {
26337 family: gate4agent_types::AdapterFamily::PtySemantic,
26338 binding: gate4agent_types::AdapterBinding::new(
26339 gate4agent_types::AdapterId::new("claude").unwrap(),
26340 "fixture/v1",
26341 gate4agent_types::AdapterVerification::SyntheticFixture,
26342 )
26343 .unwrap(),
26344 },
26345 source_sequence: 1,
26346 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26347 identity: observed_identity.clone(),
26348 },
26349 },
26350 };
26351 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26352
26353 let snapshot = shared.snapshot();
26354 assert_eq!(snapshot.session_records.len(), 1);
26355 let live = &snapshot.session_records[0];
26356 assert_eq!(live.record_id, durable_id);
26357 assert_eq!(live.state, ManagedSessionState::Live);
26358 assert_eq!(live.active_session.as_ref(), Some(&address));
26359 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26360 let binding = shared
26361 .session_bindings
26362 .lock()
26363 .unwrap_or_else(|poisoned| poisoned.into_inner())
26364 .get(&address.session.instance_id)
26365 .cloned()
26366 .unwrap();
26367 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26368
26369 let mut duplicate = live.clone();
26370 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26371 duplicate.display_name = "duplicate".to_owned();
26372 duplicate.state = ManagedSessionState::Dormant;
26373 duplicate.active_session = None;
26374 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26375 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26376 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26377 assert_eq!(shared.snapshot().session_records.len(), 1);
26378
26379 let terminal_size = gate4agent_types::TerminalSize {
26380 rows: 24,
26381 columns: 80,
26382 };
26383 let (first_busy, second_busy) = tokio::join!(
26384 shared.resume_session_record(&durable_id, terminal_size, None),
26385 shared.resume_session_record(&durable_id, terminal_size, None),
26386 );
26387 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26388 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26389 assert_eq!(busy.message, "session-record-busy");
26390 }
26391
26392 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26393 assert_eq!(loaded.records.len(), 1);
26394 assert_eq!(loaded.records[0].record_id, durable_id);
26395 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26396 assert_eq!(
26397 loaded.records[0]
26398 .provider_session
26399 .as_ref()
26400 .unwrap()
26401 .transcript_path,
26402 None,
26403 );
26404 let catalog = active_registry().unwrap();
26405 let (reloaded_handle, _reloaded_runtime) =
26406 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26407 let reloaded = NodeShared::new_with_incarnation(
26408 reloaded_handle,
26409 "fixture-token".to_owned(),
26410 node_id,
26411 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26412 vec![workspace],
26413 vec![agent("claude")],
26414 ProviderRuntimeStatuses::default(),
26415 None,
26416 Vec::new(),
26417 Vec::new(),
26418 SpawnProfileRegistry::default(),
26419 None,
26420 None,
26421 None,
26422 None,
26423 loaded.records,
26424 loaded.managed_worktrees,
26425 loaded.managed_worktree_tombstones,
26426 loaded.materializations,
26427 loaded.warning,
26428 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26429 );
26430 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26431 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26432 std::fs::remove_dir_all(root).unwrap();
26433 }
26434
26435 #[tokio::test]
26436 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26437 let secondary_root = temporary_workspace_root("workspace-restore");
26438 let other_root = temporary_workspace_root("workspace-conflict");
26439 std::fs::create_dir_all(&secondary_root).unwrap();
26440 std::fs::create_dir_all(&other_root).unwrap();
26441 let catalog = active_registry().unwrap();
26442 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26443 let primary = WorkspaceConfig::new(
26444 WorkspaceId::new("primary").unwrap(),
26445 std::env::current_dir().unwrap(),
26446 )
26447 .unwrap();
26448 let secondary = WorkspaceConfig::new(
26449 WorkspaceId::new("secondary").unwrap(),
26450 &secondary_root,
26451 )
26452 .unwrap();
26453 let shared = NodeShared::new(
26454 handle,
26455 "fixture-token".to_owned(),
26456 NodeId::new("node-1").unwrap(),
26457 vec![primary, secondary.clone()],
26458 vec![agent("claude")],
26459 );
26460 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26461 shared
26462 .insert_record(ManagedSessionRecord {
26463 record_id: record_id.clone(),
26464 display_name: "workspace record".to_owned(),
26465 provider: agent("claude"),
26466 mode: SessionMode::Pty,
26467 state: ManagedSessionState::Dormant,
26468 workspace_id: secondary.workspace_id.clone(),
26469 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26470 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26471 key: gate4agent_types::ProviderSessionKey::SessionId,
26472 id: "provider-session".to_owned(),
26473 transcript_path: None,
26474 }),
26475 active_session: None,
26476 environment_profile: None,
26477 bundle: None,
26478 context_id: None,
26479 context: None,
26480 exported_context: None,
26481 task_binding: None,
26482 created_at_unix_ms: 1,
26483 updated_at_unix_ms: 1,
26484 last_error: None,
26485 })
26486 .unwrap();
26487 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26488 let unavailable = shared.record(&record_id).unwrap();
26489 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26490 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26491
26492 let conflict = shared
26493 .register_workspace(
26494 secondary.workspace_id.clone(),
26495 other_root.to_string_lossy().into_owned(),
26496 )
26497 .await
26498 .unwrap_err();
26499 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26500 shared
26501 .register_workspace(
26502 secondary.workspace_id,
26503 secondary_root.to_string_lossy().into_owned(),
26504 )
26505 .await
26506 .unwrap();
26507 let restored = shared.record(&record_id).unwrap();
26508 assert_eq!(restored.state, ManagedSessionState::Dormant);
26509 assert!(restored.last_error.is_none());
26510 std::fs::remove_dir_all(secondary_root).unwrap();
26511 std::fs::remove_dir_all(other_root).unwrap();
26512 }
26513
26514 #[test]
26515 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26516 let error = validate_workspace_request_root(
26517 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26518 )
26519 .unwrap_err();
26520 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26521 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26522 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26523 }
26524
26525 #[test]
26526 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26527 let catalog = active_registry().unwrap();
26528 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26529 let workspace = WorkspaceConfig::new(
26530 WorkspaceId::new("primary").unwrap(),
26531 std::env::current_dir().unwrap(),
26532 )
26533 .unwrap();
26534 let shared = NodeShared::new(
26535 handle,
26536 "fixture-token".to_owned(),
26537 NodeId::new("node-1").unwrap(),
26538 vec![workspace],
26539 vec![agent("claude")],
26540 );
26541 let publishers = 4_u64;
26542 let events_per_publisher = 256_u64;
26543 let start = std::sync::Barrier::new(publishers as usize + 1);
26544 let finished = AtomicU64::new(0);
26545
26546 std::thread::scope(|scope| {
26547 for _ in 0..publishers {
26548 scope.spawn(|| {
26549 start.wait();
26550 for _ in 0..events_per_publisher {
26551 shared.publish(NodeEvent::ControllerChanged { controller: None });
26552 }
26553 finished.fetch_add(1, Ordering::Release);
26554 });
26555 }
26556 start.wait();
26557 while finished.load(Ordering::Acquire) != publishers {
26558 assert_resync_history_is_linearized(shared.resync(0));
26559 std::thread::yield_now();
26560 }
26561 });
26562 let response = shared.resync(0);
26563 assert_resync_history_is_linearized(response.clone());
26564 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26565 unreachable!("resync helper returned another response");
26566 };
26567 assert_eq!(event_sequence, publishers * events_per_publisher);
26568 assert_eq!(events.len() as u64, event_sequence);
26569 }
26570
26571 fn assert_resync_history_is_linearized(response: NodeResponse) {
26572 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26573 unreachable!("resync helper returned another response");
26574 };
26575 assert!(events.windows(2).all(|pair| {
26576 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26577 }));
26578 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26579 }
26580
26581 #[test]
26582 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26583 assert_eq!(
26584 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26585 r"C:\repo\worktree",
26586 );
26587 assert_eq!(
26588 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26589 r"\\server\share\repo",
26590 );
26591 assert!(validate_workspace_root(
26592 &WorkspaceId::new("network").unwrap(),
26593 r"\\server\share\repo",
26594 )
26595 .is_err());
26596 let workspace = WorkspaceConfig::new(
26597 WorkspaceId::new("primary").unwrap(),
26598 std::env::current_dir().unwrap(),
26599 )
26600 .unwrap();
26601 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26602 }
26603
26604 #[test]
26605 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26606 let catalog = active_registry().unwrap();
26607 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26608 let workspace_id = WorkspaceId::new("primary").unwrap();
26609 let workspace = WorkspaceConfig::new(
26610 workspace_id.clone(),
26611 std::env::current_dir().unwrap(),
26612 )
26613 .unwrap();
26614 let shared = NodeShared::new(
26615 handle,
26616 "fixture-token".to_owned(),
26617 NodeId::new("node-1").unwrap(),
26618 vec![workspace],
26619 vec![agent("claude")],
26620 );
26621 let instance_id = AgentInstanceId(17);
26622 let original = SessionAddress {
26623 workspace_id,
26624 session: SessionKey {
26625 instance_id,
26626 generation: SessionGeneration(3),
26627 },
26628 };
26629 shared.bind_session(
26630 instance_id,
26631 original.workspace_id.clone(),
26632 original.session.generation,
26633 );
26634 shared
26635 .arm_resume(
26636 &original,
26637 CommandId(41),
26638 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26639 )
26640 .unwrap();
26641
26642 shared.publish_control(ControlEvent {
26643 sequence: 1,
26644 command_id: None,
26645 instance_id,
26646 generation: SessionGeneration(4),
26647 event: ControlEventKind::Running { process_id: None },
26648 });
26649 assert_eq!(
26650 shared.address_for(instance_id, SessionGeneration(3)),
26651 Some(original.clone()),
26652 );
26653 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26654
26655 shared.publish_control(ControlEvent {
26656 sequence: 2,
26657 command_id: Some(CommandId(42)),
26658 instance_id,
26659 generation: SessionGeneration(3),
26660 event: ControlEventKind::CommandRejected {
26661 message: "unrelated command".to_owned(),
26662 },
26663 });
26664
26665 shared.publish_control(ControlEvent {
26666 sequence: 3,
26667 command_id: None,
26668 instance_id,
26669 generation: SessionGeneration(4),
26670 event: ControlEventKind::ResumeAuthorized {
26671 session: gate4agent_types::ResumeSessionSummary {
26672 key: gate4agent_types::ProviderSessionKey::SessionId,
26673 id: "fixture-session".to_owned(),
26674 },
26675 },
26676 });
26677 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26678 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26679 assert_eq!(rebound.workspace_id, original.workspace_id);
26680 let history = shared
26681 .history
26682 .lock()
26683 .unwrap_or_else(|poisoned| poisoned.into_inner());
26684 assert!(history.events.iter().any(|envelope| matches!(
26685 &envelope.event,
26686 NodeEvent::Control { address, event }
26687 if address == &rebound
26688 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26689 )));
26690 }
26691
26692 #[test]
26693 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26694 for agent in ["claude", "codex", "kimi"] {
26695 assert_eq!(
26696 prompt_framing(&AgentId::new(agent).unwrap()),
26697 PromptFraming::BracketedPaste
26698 );
26699 }
26700 }
26701
26702 fn generous_walk_deadline() -> Instant {
26703 Instant::now() + Duration::from_secs(30)
26704 }
26705
26706 #[test]
26707 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26708 let root = temporary_workspace_root("tree");
26709 std::fs::create_dir_all(root.join("src")).unwrap();
26710 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26711 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26712 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26713 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26714 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26715 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26721 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26722 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26726 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26727
26728 let (entries, truncated, _budget) =
26729 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26730 assert!(!truncated);
26731 assert!(entries.iter().any(|entry| {
26732 entry.relative_path.as_utf8() == Some("src")
26733 && entry.kind == WorkspaceEntryKind::Directory
26734 }));
26735 assert!(entries.iter().any(|entry| {
26736 entry.relative_path.as_utf8() == Some("src/lib.rs")
26737 && entry.kind == WorkspaceEntryKind::File
26738 }));
26739 assert!(entries.iter().any(|entry| {
26740 entry.relative_path.as_utf8() == Some("target-notes")
26741 && entry.kind == WorkspaceEntryKind::Directory
26742 }));
26743 assert!(entries.iter().any(|entry| {
26744 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26745 && entry.kind == WorkspaceEntryKind::File
26746 }));
26747 assert!(entries.iter().all(|entry| {
26748 let path = entry.relative_path.as_utf8().unwrap();
26749 !path.starts_with(".git")
26750 && !(path == "target" || path.starts_with("target/"))
26751 && !(path == "target-a2" || path.starts_with("target-a2/"))
26752 && !path.starts_with("node_modules")
26753 }));
26754
26755 std::fs::remove_dir_all(&root).unwrap();
26756 }
26757
26758 #[test]
26759 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26760 let root = temporary_workspace_root("tree-entry-cap");
26761 std::fs::create_dir_all(&root).unwrap();
26762 for index in 0..20 {
26763 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26764 }
26765
26766 let (entries, truncated, budget) =
26767 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26768 assert!(truncated);
26769 assert!(budget.entry_cap_exceeded);
26770 assert!(!budget.time_budget_exceeded);
26771 assert!(entries.len() <= 5);
26772
26773 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26774 .expect("an entry-cap-truncated walk must report a truncation marker");
26775 assert!(inspection_truncation.walk_entry_cap_exceeded);
26776 assert!(!inspection_truncation.walk_time_budget_exceeded);
26777 assert!(!inspection_truncation.git_time_budget_exceeded);
26778 assert!(inspection_truncation.entries_visited >= 5);
26779
26780 std::fs::remove_dir_all(&root).unwrap();
26781 }
26782
26783 #[test]
26784 fn workspace_walk_within_budget_reports_no_truncation() {
26785 let root = temporary_workspace_root("tree-no-truncation");
26786 std::fs::create_dir_all(&root).unwrap();
26787 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26788
26789 let (_entries, truncated, budget) =
26790 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26791 assert!(!truncated);
26792 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26793
26794 std::fs::remove_dir_all(&root).unwrap();
26795 }
26796
26797 #[test]
26798 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26799 let root = temporary_workspace_root("tree-prefix-siblings");
26800 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26801 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26802 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26803 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26804
26805 let (entries, truncated, _budget) =
26806 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26807 assert!(!truncated);
26808 let paths = entries
26809 .iter()
26810 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26811 .collect::<Vec<_>>();
26812 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26813 assert_eq!(
26814 paths,
26815 vec![
26816 "gate4agent-c2".to_owned(),
26817 "gate4agent-c2-client".to_owned(),
26818 "gate4agent-c2-client/lib.rs".to_owned(),
26819 "gate4agent-c2/src".to_owned(),
26820 "gate4agent-c2/src/lib.rs".to_owned(),
26821 ],
26822 );
26823
26824 std::fs::remove_dir_all(&root).unwrap();
26825 }
26826
26827 #[test]
26828 fn git_parsers_cap_status_and_commit_results() {
26829 let mut status_output = Vec::new();
26830 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26831 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26832 }
26833 let mut snapshot = git_snapshot_for_parser();
26834 parse_git_status(&status_output, &mut snapshot);
26835 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26836 assert!(snapshot.truncated);
26837 assert_eq!(snapshot.status[0].index_status, " ");
26838 assert_eq!(snapshot.status[0].worktree_status, "M");
26839
26840 snapshot.truncated = false;
26841 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26842 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26843 .collect::<String>();
26844 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26845 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26846 assert!(snapshot.truncated);
26847 }
26848
26849 #[test]
26850 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26851 let mut snapshot = git_snapshot_for_parser();
26852 parse_git_status(
26853 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26854 &mut snapshot,
26855 );
26856
26857 assert!(!snapshot.truncated);
26858 assert_eq!(snapshot.status.len(), 3);
26859 assert_eq!(snapshot.status[0].index_status, "R");
26860 assert_eq!(snapshot.status[0].worktree_status, " ");
26861 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26862 assert_eq!(
26863 snapshot.status[0]
26864 .previous_path
26865 .as_ref()
26866 .and_then(RepositoryPath::as_utf8),
26867 Some("src/previous name.rs"),
26868 );
26869 assert_eq!(snapshot.status[1].index_status, " ");
26870 assert_eq!(snapshot.status[1].worktree_status, "C");
26871 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26872 assert_eq!(
26873 snapshot.status[1]
26874 .previous_path
26875 .as_ref()
26876 .and_then(RepositoryPath::as_utf8),
26877 Some("src/original.rs"),
26878 );
26879 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26880 assert!(snapshot.status[2].previous_path.is_none());
26881 }
26882
26883 #[test]
26884 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26885 let mut snapshot = git_snapshot_for_parser();
26886 parse_git_status(
26887 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
26888 &mut snapshot,
26889 );
26890
26891 assert!(snapshot.truncated);
26892 assert_eq!(snapshot.status.len(), 1);
26893 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26894 assert!(snapshot.status[0].previous_path.is_none());
26895 }
26896
26897 #[tokio::test]
26898 async fn observer_can_browse_host_directories_without_controller() {
26899 let root = temporary_workspace_root("host-directory-observer");
26900 std::fs::create_dir_all(root.join("directory-b")).unwrap();
26901 std::fs::create_dir_all(root.join("directory-a")).unwrap();
26902 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26903 let workspace_id = WorkspaceId::new("primary").unwrap();
26904 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26905 let catalog = active_registry().unwrap();
26906 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26907 let shared = NodeShared::new(
26908 handle,
26909 "fixture-token".to_owned(),
26910 NodeId::new("node-1").unwrap(),
26911 vec![workspace],
26912 vec![agent("claude")],
26913 );
26914 let request = NodeRequest::BrowseHostDirectories {
26915 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26916 after: None,
26917 };
26918 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26919 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26920 }));
26921 assert!(!request_uses_unnegotiated_capability(
26922 &request,
26923 &baseline_capabilities().unwrap(),
26924 ));
26925 assert!(request_uses_unnegotiated_capability(&request, &[]));
26926
26927 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26928 .await
26929 .unwrap();
26930 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26931 panic!("host directory browse returned another response");
26932 };
26933 assert_eq!(
26934 listing
26935 .entries
26936 .iter()
26937 .map(|entry| entry.display_name.as_str())
26938 .collect::<Vec<_>>(),
26939 vec!["directory-a", "directory-b"],
26940 );
26941 assert!(listing.entries.iter().all(|entry| !entry.is_link));
26942 assert!(!listing.incomplete);
26943 std::fs::remove_dir_all(root).unwrap();
26944 }
26945
26946 #[tokio::test]
26947 async fn observer_can_inspect_only_a_registered_workspace() {
26948 let root = temporary_workspace_root("observer");
26949 std::fs::create_dir_all(root.join("src")).unwrap();
26950 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26951 let workspace_id = WorkspaceId::new("primary").unwrap();
26952 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26953 let catalog = active_registry().unwrap();
26954 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26955 let shared = NodeShared::new(
26956 handle,
26957 "fixture-token".to_owned(),
26958 NodeId::new("node-1").unwrap(),
26959 vec![workspace],
26960 vec![agent("claude")],
26961 );
26962
26963 let response = process_request_inner(
26964 &shared,
26965 77,
26966 ClientRole::Observer,
26967 NodeRequest::InspectWorkspace {
26968 workspace_id: workspace_id.clone(),
26969 },
26970 )
26971 .await
26972 .unwrap();
26973 let NodeResponse::WorkspaceInspected { inspection } = response else {
26974 panic!("workspace inspection returned another response");
26975 };
26976 assert_eq!(inspection.workspace_id, workspace_id);
26977 assert!(inspection.entries.iter().any(|entry| {
26978 entry.relative_path.as_utf8() == Some("src/main.rs")
26979 }));
26980
26981 let unknown = process_request_inner(
26982 &shared,
26983 77,
26984 ClientRole::Observer,
26985 NodeRequest::InspectWorkspace {
26986 workspace_id: WorkspaceId::new("unknown").unwrap(),
26987 },
26988 )
26989 .await
26990 .unwrap_err();
26991 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26992 std::fs::remove_dir_all(&root).unwrap();
26993 }
26994
26995 #[test]
26996 fn context_pack_source_accepts_only_running_or_successful_exit() {
26997 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26998 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26999 exit_code: Some(0),
27000 }));
27001 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27002 exit_code: Some(1),
27003 }));
27004 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27005 exit_code: None,
27006 }));
27007 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27008 message: "provider failed".to_owned(),
27009 }));
27010 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27011 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27012 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27013 }
27014
27015 #[test]
27016 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27017 let git = git_snapshot_for_parser();
27018 let first = ContextPackRepositoryObservation {
27019 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27020 git: git.clone(),
27021 files: Vec::new(),
27022 };
27023 let second = ContextPackRepositoryObservation {
27024 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27025 git,
27026 files: Vec::new(),
27027 };
27028
27029 let error = stable_context_pack_repository(first, second).unwrap_err();
27030
27031 assert_eq!(
27032 error.code,
27033 NodeFailureCode::ContextPackMaterializationFailed,
27034 );
27035 }
27036
27037 #[test]
27038 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27039 let git = git_snapshot_for_parser();
27040 let first = ContextPackRepositoryObservation {
27041 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27042 git: git.clone(),
27043 files: vec![ContextPackRepositoryFileSource::utf8(
27044 "README.md",
27045 "first\n".to_owned(),
27046 6,
27047 )],
27048 };
27049 let second = ContextPackRepositoryObservation {
27050 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27051 git,
27052 files: vec![ContextPackRepositoryFileSource::utf8(
27053 "README.md",
27054 "other\n".to_owned(),
27055 6,
27056 )],
27057 };
27058
27059 let error = stable_context_pack_repository(first, second).unwrap_err();
27060
27061 assert_eq!(
27062 error.code,
27063 NodeFailureCode::ContextPackMaterializationFailed,
27064 );
27065 }
27066
27067 #[tokio::test]
27068 async fn native_session_catalog_zero_active_session_fixture() {
27069 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27070 let history_root = temporary_workspace_root("native-catalog-history");
27071 let outside_root = temporary_workspace_root("native-catalog-outside");
27072 std::fs::create_dir_all(&workspace_root).unwrap();
27073 std::fs::create_dir_all(&history_root).unwrap();
27074 std::fs::create_dir_all(&outside_root).unwrap();
27075 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27076 [
27077 serde_json::json!({
27078 "type": "user",
27079 "sessionId": session_id,
27080 "cwd": cwd.to_str().unwrap(),
27081 "message": { "content": "raw user text must not cross the catalog wire" }
27082 }),
27083 serde_json::json!({
27084 "type": "assistant",
27085 "sessionId": session_id,
27086 "cwd": cwd.to_str().unwrap(),
27087 "message": { "model": "claude-sonnet", "content": "raw answer" }
27088 }),
27089 serde_json::json!({
27090 "type": "ai-title",
27091 "sessionId": session_id,
27092 "aiTitle": title
27093 }),
27094 ]
27095 .into_iter()
27096 .map(|record| record.to_string())
27097 .collect::<Vec<_>>()
27098 .join("\n")
27099 };
27100 std::fs::write(
27101 history_root.join("stable-session.jsonl"),
27102 transcript("stable-session", &workspace_root, "Workspace review"),
27103 )
27104 .unwrap();
27105 std::fs::write(
27106 history_root.join("outside-session.jsonl"),
27107 transcript("outside-session", &outside_root, "Outside review"),
27108 )
27109 .unwrap();
27110 let history = NativeHistoryConfig::new(vec![
27111 NativeHistoryRoot::new(
27112 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27113 HistorySourceLayout::SingleNdjson,
27114 history_root.clone(),
27115 )
27116 .unwrap(),
27117 ])
27118 .unwrap();
27119 let catalog = active_registry().unwrap();
27120 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27121 assert!(handle.snapshot().sessions.is_empty());
27122 let workspace_id = WorkspaceId::new("primary").unwrap();
27123 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27124 let mut shared = NodeShared::new(
27125 handle,
27126 "fixture-token".to_owned(),
27127 NodeId::new("node-native-catalog").unwrap(),
27128 vec![workspace],
27129 vec![agent("claude")],
27130 );
27131 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27132 NativeSessionCatalogAuthority::new(history),
27133 )));
27134 let route = NativeSessionCatalogRoute::workspace(
27135 workspace_id.clone(),
27136 agent("claude"),
27137 );
27138 let request = NodeRequest::CatalogNativeSessions {
27139 route: route.clone(),
27140 limit: 2,
27141 };
27142 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27143 .await
27144 .unwrap_err();
27145 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27146 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27147 .await
27148 .unwrap();
27149 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27150 panic!("native catalog returned a different response");
27151 };
27152 let summary = summary.expect("node must emit native session catalog summary metadata");
27153 assert_eq!(entries.len(), 1);
27154 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27155 assert_eq!(entries[0].external_group, None);
27156 assert_eq!(entries[0].record_id, None);
27157 assert_eq!(entries[0].title, None);
27158 assert_eq!(entries[0].model, None);
27159 assert_eq!(entries[0].message_count, 0);
27160 let encoded_entries = serde_json::to_string(&entries).unwrap();
27161 assert!(!encoded_entries.contains("stable-session"));
27162 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27163 let page_request = NodeRequest::PageNativeSessions {
27164 route: route.clone(),
27165 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27166 catalog_revision: summary.catalog_revision,
27167 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27168 after_selection_id: None,
27169 limit: 2,
27170 };
27171 let observer = process_request_inner(
27172 &shared,
27173 7,
27174 ClientRole::Observer,
27175 page_request.clone(),
27176 )
27177 .await
27178 .unwrap_err();
27179 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27180 let page_response = process_request_inner(
27181 &shared,
27182 7,
27183 ClientRole::Operator,
27184 page_request.clone(),
27185 )
27186 .await
27187 .unwrap();
27188 assert!(matches!(
27189 page_response,
27190 NodeResponse::NativeSessionsPaged { page, .. }
27191 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27192 ));
27193 let stale = process_request_inner(
27194 &shared,
27195 7,
27196 ClientRole::Operator,
27197 NodeRequest::PageNativeSessions {
27198 route: route.clone(),
27199 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27200 catalog_revision: summary.catalog_revision.wrapping_add(1),
27201 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27202 after_selection_id: None,
27203 limit: 2,
27204 },
27205 )
27206 .await
27207 .unwrap_err();
27208 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27209 let selection = NativeSessionSelection {
27210 route: route.clone(),
27211 catalog_revision: summary.catalog_revision,
27212 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27213 selection_id: entries[0].selection_id.clone(),
27214 };
27215 let preview_response = process_request_inner(
27216 &shared,
27217 7,
27218 ClientRole::Operator,
27219 NodeRequest::PreviewNativeSession {
27220 selection: selection.clone(),
27221 message_limit: 1,
27222 },
27223 )
27224 .await
27225 .unwrap();
27226 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27227 panic!("native preview returned a different response");
27228 };
27229 assert_eq!(preview.messages.len(), 1);
27230 assert!(preview.message_count_exact);
27231 assert_eq!(preview.messages[0].text, "raw answer");
27232 assert!(preview.truncated);
27233
27234 let index_request = NodeRequest::IndexNativeSession {
27235 selection: selection.clone(),
27236 display_name: "Saved Claude".to_owned(),
27237 };
27238 let observer = process_request_inner(
27239 &shared,
27240 7,
27241 ClientRole::Observer,
27242 index_request.clone(),
27243 )
27244 .await
27245 .unwrap_err();
27246 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27247 let without_controller = process_request_inner(
27248 &shared,
27249 7,
27250 ClientRole::Operator,
27251 index_request.clone(),
27252 )
27253 .await
27254 .unwrap_err();
27255 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27256 let acquired = process_request_inner(
27257 &shared,
27258 7,
27259 ClientRole::Operator,
27260 NodeRequest::AcquireController { lease_ms: 5_000 },
27261 )
27262 .await
27263 .unwrap();
27264 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27265 let indexed = process_request_inner(
27266 &shared,
27267 7,
27268 ClientRole::Operator,
27269 index_request,
27270 )
27271 .await
27272 .unwrap();
27273 assert!(indexed.native_session_index_contract_is_valid());
27274 let NodeResponse::NativeSessionIndexed {
27275 selection: echoed_selection,
27276 record,
27277 } = indexed else {
27278 panic!("native session index returned a different response");
27279 };
27280 assert_eq!(echoed_selection, selection);
27281 let recataloged = process_request_inner(
27282 &shared,
27283 7,
27284 ClientRole::Operator,
27285 NodeRequest::CatalogNativeSessions {
27286 route,
27287 limit: 2,
27288 },
27289 )
27290 .await
27291 .unwrap();
27292 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27293 panic!("native catalog returned a different response after indexing");
27294 };
27295 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27296
27297 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27298 let external_catalog = process_request_inner(
27299 &shared,
27300 7,
27301 ClientRole::Operator,
27302 NodeRequest::CatalogNativeSessions {
27303 route: unregistered_route.clone(),
27304 limit: 2,
27305 },
27306 )
27307 .await
27308 .unwrap();
27309 let external_json = serde_json::to_string(&external_catalog).unwrap();
27310 assert!(!external_json.contains("outside-session"));
27311 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27312 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27313 assert!(!external_json.contains("\"session_id\""));
27314 assert!(!external_json.contains("\"cwd\""));
27315 assert!(!external_json.contains("\"path\""));
27316 let NodeResponse::NativeSessionsCataloged {
27317 route: echoed_route,
27318 entries: external_entries,
27319 summary: Some(external_summary),
27320 } = external_catalog
27321 else {
27322 panic!("unregistered native catalog returned a different response");
27323 };
27324 assert_eq!(echoed_route, unregistered_route);
27325 assert_eq!(external_entries.len(), 1);
27326 assert_eq!(external_entries[0].record_id, None);
27327 let external_group = external_entries[0]
27328 .external_group
27329 .as_ref()
27330 .expect("unregistered session must have an external group");
27331 assert_eq!(
27332 external_group.kind,
27333 gate4agent_types::NativeSessionExternalGroupKind::Project,
27334 );
27335 assert!(external_group.group_id.starts_with("external-"));
27336 assert_eq!(
27337 external_group.display_name,
27338 outside_root.file_name().unwrap().to_string_lossy(),
27339 );
27340 let external_selection = NativeSessionSelection {
27341 route: unregistered_route,
27342 catalog_revision: external_summary.catalog_revision,
27343 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27344 selection_id: external_entries[0].selection_id.clone(),
27345 };
27346 let external_preview = process_request_inner(
27347 &shared,
27348 7,
27349 ClientRole::Operator,
27350 NodeRequest::PreviewNativeSession {
27351 selection: external_selection.clone(),
27352 message_limit: 1,
27353 },
27354 )
27355 .await
27356 .unwrap();
27357 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27358 assert!(!external_preview_json.contains("outside-session"));
27359 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27360 assert!(!external_preview_json.contains("\"session_id\""));
27361 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27362 panic!("unregistered native preview returned a different response");
27363 };
27364 assert_eq!(preview.messages.len(), 1);
27365 let external_index = process_request_inner(
27366 &shared,
27367 7,
27368 ClientRole::Operator,
27369 NodeRequest::IndexNativeSession {
27370 selection: external_selection,
27371 display_name: "External Claude".to_owned(),
27372 },
27373 )
27374 .await
27375 .unwrap_err();
27376 assert_eq!(
27377 external_index.code,
27378 NodeFailureCode::WorkspaceRegistrationRequired,
27379 );
27380 let record_response = process_request_inner(
27381 &shared,
27382 7,
27383 ClientRole::Operator,
27384 NodeRequest::PreviewSessionRecord {
27385 record_id: record.record_id.clone(),
27386 message_limit: 2,
27387 },
27388 )
27389 .await
27390 .unwrap();
27391 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27392 panic!("record preview returned a different response");
27393 };
27394 assert_eq!(record_id, record.record_id);
27395 assert_eq!(preview.messages.len(), 2);
27396 let encoded = serde_json::to_string(&preview).unwrap();
27397 assert!(!encoded.contains("stable-session"));
27398 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27399 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27400 panic!("record preview history summary returned a different response");
27401 };
27402 let summaries = events
27403 .iter()
27404 .filter_map(|envelope| match &envelope.event {
27405 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27406 if record_id == &record.record_id =>
27407 {
27408 Some(*summary)
27409 }
27410 _ => None,
27411 })
27412 .collect::<Vec<_>>();
27413 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27414 let summary_json = serde_json::to_string(&summaries).unwrap();
27415 for private in [
27416 "stable-session",
27417 "raw user text must not cross the catalog wire",
27418 "raw answer",
27419 "Workspace review",
27420 "claude-sonnet",
27421 ] {
27422 assert!(!summary_json.contains(private), "{private}");
27423 }
27424 let identity = record.provider_session.clone().unwrap();
27425 assert_eq!(
27426 shared.revalidate_session_record_preview(&record, &identity),
27427 Ok(())
27428 );
27429 shared.remove_record_memory(&record.record_id);
27430 assert_eq!(
27431 shared
27432 .revalidate_session_record_preview(&record, &identity)
27433 .unwrap_err()
27434 .code,
27435 NodeFailureCode::UnknownSessionRecord
27436 );
27437 let released = process_request_inner(
27438 &shared,
27439 7,
27440 ClientRole::Operator,
27441 NodeRequest::ReleaseController,
27442 )
27443 .await
27444 .unwrap();
27445 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27446 assert!(shared.controller_state().is_none());
27447 std::fs::remove_dir_all(workspace_root).unwrap();
27448 std::fs::remove_dir_all(history_root).unwrap();
27449 std::fs::remove_dir_all(outside_root).unwrap();
27450 }
27451
27452 #[tokio::test]
27453 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27454 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27455 std::fs::create_dir_all(&history_root).unwrap();
27456 let history = NativeHistoryConfig::new(vec![
27457 NativeHistoryRoot::new(
27458 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27459 HistorySourceLayout::SingleNdjson,
27460 history_root.clone(),
27461 )
27462 .unwrap(),
27463 ])
27464 .unwrap();
27465 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27466 let held = catalog.lock().unwrap();
27467 let result = timeout(
27468 Duration::from_secs(1),
27469 run_native_session_catalog_operation(
27470 Arc::clone(&catalog),
27471 "native session catalog",
27472 |_| Ok::<(), std::convert::Infallible>(()),
27473 ),
27474 )
27475 .await
27476 .expect("concurrent native catalog request must not wait for the operation deadline")
27477 .unwrap_err();
27478 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27479 drop(held);
27480 std::fs::remove_dir_all(history_root).unwrap();
27481 }
27482
27483 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27484 let unique = std::time::SystemTime::now()
27485 .duration_since(std::time::UNIX_EPOCH)
27486 .unwrap()
27487 .as_nanos();
27488 std::env::temp_dir().join(format!(
27489 "gate4agent-node-{label}-{}-{unique}",
27490 std::process::id(),
27491 ))
27492 }
27493
27494 fn git_snapshot_for_parser() -> GitSnapshot {
27495 GitSnapshot {
27496 is_repository: true,
27497 branch: Some("main".to_owned()),
27498 status: Vec::new(),
27499 recent_commits: Vec::new(),
27500 worktrees: Vec::new(),
27501 managed_worktree: None,
27502 truncated: false,
27503 diagnostic: None,
27504 }
27505 }
27506
27507 #[tokio::test]
27508 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27509 let root = temporary_workspace_root("managed-git-scope");
27510 std::fs::create_dir_all(&root).unwrap();
27511 run_test_git(&root, &["init"]);
27512 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27513 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27514 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27515 run_test_git(&root, &["add", "seed.txt"]);
27516 run_test_git(&root, &["commit", "-m", "seed"]);
27517 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27518
27519 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27520 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27521 let catalog = active_registry().unwrap();
27522 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27523 let shared = NodeShared::new(
27524 handle,
27525 "fixture-token".to_owned(),
27526 NodeId::new("node-1").unwrap(),
27527 vec![workspace],
27528 vec![agent("claude")],
27529 );
27530 let mut lease = ManagedWorktreeLeaseRecord {
27531 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27532 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27533 workspace_id: workspace_id.clone(),
27534 profile_id: WorktreeProfileId::new("default").unwrap(),
27535 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27536 target_root: root.to_string_lossy().into_owned(),
27537 branch: "gate4agent/a".to_owned(),
27538 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27539 expected_head: None,
27540 retention: ManagedWorktreeRetention::Retain,
27541 state: ManagedWorktreeLeaseState::InUse,
27542 session_holders: vec![ManagedWorktreeSessionHolder {
27543 incarnation_id: shared.incarnation_id,
27544 instance_id: AgentInstanceId(7),
27545 generation: SessionGeneration(1),
27546 }],
27547 record_holders: Vec::new(),
27548 cleanup_failure: None,
27549 created_at_unix_ms: 1,
27550 updated_at_unix_ms: 1,
27551 };
27552 *shared
27553 .managed_worktrees
27554 .lock()
27555 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27556 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27557
27558 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27559 let scope = inspection.git.managed_worktree.unwrap();
27560 assert_eq!(scope.lease_id, lease.lease_id);
27561 assert_eq!(scope.branch, "gate4agent/a");
27562 assert_eq!(scope.active_session_count, 1);
27563
27564 lease.branch = "gate4agent/b".to_owned();
27565 *shared
27566 .managed_worktrees
27567 .lock()
27568 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27569 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27570 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27571 assert!(mismatch.git.managed_worktree.is_none());
27572 std::fs::remove_dir_all(root).unwrap();
27573 }
27574
27575 #[tokio::test]
27576 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27577 let root = temporary_workspace_root("workspace-file-write-controller");
27578 std::fs::create_dir_all(root.join("src")).unwrap();
27579 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27580 let workspace_id = WorkspaceId::new("primary").unwrap();
27581 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27582 let catalog = active_registry().unwrap();
27583 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27584 let shared = NodeShared::new(
27585 handle,
27586 "fixture-token".to_owned(),
27587 NodeId::new("node-1").unwrap(),
27588 vec![workspace],
27589 vec![agent("claude")],
27590 );
27591 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27592 let expected_revision = workspace_file_revision(b"old\n");
27593 let request = NodeRequest::WriteWorkspaceFile {
27594 workspace_id: workspace_id.clone(),
27595 path: path.clone(),
27596 expected_revision: expected_revision.clone(),
27597 text: "new\n".to_owned(),
27598 };
27599 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27600 .await
27601 .unwrap_err();
27602 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27603 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27604 .await
27605 .unwrap_err();
27606 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27607 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27608 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27609 let NodeResponse::WorkspaceFileWritten { file } = response else {
27610 panic!("workspace file write returned another response");
27611 };
27612 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27613 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27614 let stale = process_request_inner(
27615 &shared,
27616 77,
27617 ClientRole::Operator,
27618 NodeRequest::WriteWorkspaceFile {
27619 workspace_id,
27620 path,
27621 expected_revision,
27622 text: "lost update\n".to_owned(),
27623 },
27624 )
27625 .await
27626 .unwrap_err();
27627 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27628 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27629 std::fs::remove_dir_all(root).unwrap();
27630 }
27631
27632 #[tokio::test]
27633 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27634 let root = temporary_workspace_root("workspace-entry-create-controller");
27635 std::fs::create_dir_all(root.join("src")).unwrap();
27636 let workspace_id = WorkspaceId::new("primary").unwrap();
27637 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27638 let catalog = active_registry().unwrap();
27639 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27640 let shared = NodeShared::new(
27641 handle,
27642 "fixture-token".to_owned(),
27643 NodeId::new("node-1").unwrap(),
27644 vec![workspace],
27645 vec![agent("claude")],
27646 );
27647 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27648 let request = NodeRequest::CreateWorkspaceFile {
27649 workspace_id: workspace_id.clone(),
27650 path: path.clone(),
27651 };
27652 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27653 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27654 }));
27655 assert!(!request_uses_unnegotiated_capability(
27656 &request,
27657 &baseline_capabilities().unwrap(),
27658 ));
27659 assert!(request_uses_unnegotiated_capability(&request, &[]));
27660 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27661 .await
27662 .unwrap_err();
27663 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27664 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27665 .await
27666 .unwrap_err();
27667 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27668 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27669
27670 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27671 .await
27672 .unwrap();
27673 let NodeResponse::WorkspaceFileCreated { file } = response else {
27674 panic!("workspace file create returned another response");
27675 };
27676 assert_eq!(file.workspace_id, workspace_id);
27677 assert_eq!(file.path, path);
27678 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27679 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27680
27681 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27682 .await
27683 .unwrap_err();
27684 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27685
27686 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27687 let response = process_request_inner(
27688 &shared,
27689 77,
27690 ClientRole::Operator,
27691 NodeRequest::CreateWorkspaceDirectory {
27692 workspace_id: workspace_id.clone(),
27693 path: directory_path.clone(),
27694 },
27695 )
27696 .await
27697 .unwrap();
27698 assert_eq!(
27699 response,
27700 NodeResponse::WorkspaceDirectoryCreated {
27701 workspace_id: workspace_id.clone(),
27702 entry: WorkspaceEntry {
27703 relative_path: directory_path,
27704 kind: WorkspaceEntryKind::Directory,
27705 },
27706 },
27707 );
27708 assert!(root.join("src/nested").is_dir());
27709
27710 let missing_parent = process_request_inner(
27711 &shared,
27712 77,
27713 ClientRole::Operator,
27714 NodeRequest::CreateWorkspaceFile {
27715 workspace_id: workspace_id.clone(),
27716 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27717 },
27718 )
27719 .await
27720 .unwrap_err();
27721 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27722 let non_directory_parent = process_request_inner(
27723 &shared,
27724 77,
27725 ClientRole::Operator,
27726 NodeRequest::CreateWorkspaceFile {
27727 workspace_id,
27728 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27729 },
27730 )
27731 .await
27732 .unwrap_err();
27733 assert_eq!(
27734 non_directory_parent.code,
27735 NodeFailureCode::RepositoryParentNotDirectory,
27736 );
27737 std::fs::remove_dir_all(root).unwrap();
27738 }
27739
27740 #[tokio::test]
27741 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27742 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27743 std::fs::create_dir_all(root.join("src")).unwrap();
27744 #[cfg(windows)]
27745 let root = std::fs::canonicalize(root).unwrap();
27746 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27747 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27748 let barrier = Arc::new(std::sync::Barrier::new(2));
27749 let done = Arc::new(AtomicBool::new(false));
27750 let task_commit_state = Arc::clone(&commit_state);
27751 let task_barrier = Arc::clone(&barrier);
27752 let task_done = Arc::clone(&done);
27753 let task_root = root.clone();
27754 let task = tokio::task::spawn_blocking(move || {
27755 task_barrier.wait();
27756 let result = create_workspace_file_on_disk(
27757 &task_root,
27758 &path,
27759 &task_commit_state,
27760 );
27761 task_done.store(true, Ordering::Release);
27762 result
27763 });
27764
27765 let error = settle_workspace_entry_create(
27766 task,
27767 Arc::clone(&commit_state),
27768 Duration::from_millis(10),
27769 "fixture deadline",
27770 "fixture task failed",
27771 )
27772 .await
27773 .unwrap_err();
27774 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27775 assert_eq!(
27776 commit_state.load(Ordering::Acquire),
27777 WORKSPACE_ENTRY_CREATE_CANCELED,
27778 );
27779
27780 barrier.wait();
27781 timeout(Duration::from_secs(1), async {
27782 while !done.load(Ordering::Acquire) {
27783 tokio::task::yield_now().await;
27784 }
27785 })
27786 .await
27787 .expect("canceled blocking create did not settle");
27788 assert!(!root.join("src/late.rs").exists());
27789 std::fs::remove_dir_all(root).unwrap();
27790 }
27791}
27792
27793#[cfg(test)]
27794mod provisional_spawn_tests {
27795 use super::*;
27796 use std::sync::Arc;
27797
27798 fn shared() -> NodeShared {
27799 let catalog = active_registry().unwrap();
27800 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27801 let workspace = WorkspaceConfig::new(
27802 WorkspaceId::new("primary").unwrap(),
27803 std::env::current_dir().unwrap(),
27804 )
27805 .unwrap()
27806 .with_worktree_service_mode(WorktreeServiceMode::Manual);
27807 NodeShared::new(
27808 handle,
27809 "fixture-token".to_owned(),
27810 NodeId::new("node-provisional-test").unwrap(),
27811 vec![workspace],
27812 vec![AgentId::new("claude").unwrap()],
27813 )
27814 }
27815
27816 #[test]
27819 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27820 let shared = shared();
27821 let ws = WorkspaceId::new("primary").unwrap();
27822 let mut ids = Vec::with_capacity(50);
27823 for i in 1u64..=50 {
27824 let id = AgentInstanceId(i);
27825 ids.push(id);
27826 let address = SessionAddress {
27827 workspace_id: ws.clone(),
27828 session: SessionKey {
27829 instance_id: id,
27830 generation: SessionGeneration(1),
27831 },
27832 };
27833 shared.mark_spawn_provisional(address);
27834 }
27835 assert_eq!(
27836 shared
27837 .provisional_spawns
27838 .lock()
27839 .unwrap_or_else(|p| p.into_inner())
27840 .len(),
27841 50
27842 );
27843 for id in ids.iter().step_by(2) {
27844 shared.sessionize_spawn(*id);
27845 }
27846 assert_eq!(
27847 shared
27848 .provisional_spawns
27849 .lock()
27850 .unwrap_or_else(|p| p.into_inner())
27851 .len(),
27852 25
27853 );
27854 assert!(shared.take_due_provisional_spawns().is_empty());
27855 {
27856 let mut map = shared
27857 .provisional_spawns
27858 .lock()
27859 .unwrap_or_else(|p| p.into_inner());
27860 let past = Instant::now() - Duration::from_secs(30);
27861 for (_, (_, at)) in map.iter_mut() {
27862 *at = past;
27863 }
27864 }
27865 let due = shared.take_due_provisional_spawns();
27866 assert_eq!(due.len(), 25);
27867 let due_ids: std::collections::BTreeSet<_> =
27868 due.iter().map(|a| a.session.instance_id.0).collect();
27869 let expected: std::collections::BTreeSet<_> =
27870 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27871 assert_eq!(due_ids, expected);
27872 assert!(shared
27873 .provisional_spawns
27874 .lock()
27875 .unwrap_or_else(|p| p.into_inner())
27876 .is_empty());
27877 }
27878
27879 #[test]
27882 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27883 let shared = Arc::new(shared());
27884 let ws = WorkspaceId::new("primary").unwrap();
27885 let mut handles = Vec::new();
27886 for i in 1u64..=20 {
27887 let shared = Arc::clone(&shared);
27888 let ws = ws.clone();
27889 handles.push(std::thread::spawn(move || {
27890 let id = AgentInstanceId(i);
27891 let address = SessionAddress {
27892 workspace_id: ws,
27893 session: SessionKey {
27894 instance_id: id,
27895 generation: SessionGeneration(1),
27896 },
27897 };
27898 shared.mark_spawn_provisional(address);
27899 id
27900 }));
27901 }
27902 let mut ids = Vec::new();
27903 for h in handles {
27904 ids.push(h.join().unwrap());
27905 }
27906 ids.sort_by_key(|id| id.0);
27907 assert_eq!(ids.len(), 20);
27908 ids.dedup();
27909 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27910 assert_eq!(
27911 shared
27912 .provisional_spawns
27913 .lock()
27914 .unwrap_or_else(|p| p.into_inner())
27915 .len(),
27916 20
27917 );
27918 shared.sessionize_spawn(AgentInstanceId(7));
27919 let left: Vec<_> = shared
27920 .provisional_spawns
27921 .lock()
27922 .unwrap_or_else(|p| p.into_inner())
27923 .keys()
27924 .map(|id| id.0)
27925 .collect();
27926 assert!(!left.contains(&7));
27927 assert_eq!(left.len(), 19);
27928 }
27929}
27930
27931
27932#[cfg(unix)]
27933extern "C" {
27934 #[link_name = "geteuid"]
27935 fn libc_geteuid() -> u32;
27936}