1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215pub fn spawn_model() -> Option<String> {
217 SPAWN_SESSION_OPTIONS.try_with(|o| o.as_ref().map(|o| o.model.clone())).ok().flatten().filter(|m| !m.is_empty())
218}
219
220tokio::task_local! {
221 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
224 static RESUME_PROVIDER_SESSION: Option<String>;
226 static SPAWN_ORIGIN: Option<String>;
228 static SPAWN_PARENT: Option<String>;
229}
230
231fn spawn_selection(
233 options: &gate4agent_node_protocol::SpawnSessionOptions,
234) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
235 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
236 match (&options.model, &options.effort) {
237 (None, None) => Ok(None),
238 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
239 (Some(model), effort) => {
240 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
241 if let Some(e) = effort {
242 sel = sel.with_value("effort", e.clone());
243 }
244 Ok(Some(sel))
245 }
246 }
247}
248
249mod browser_bridge;
250
251use gate4agent_node_core::drive_loop_profile;
252
253#[path = "bundle_delivery.rs"]
254mod bundle_delivery;
255
256#[path = "context_pack_store.rs"]
257mod context_pack_store;
258
259#[cfg(windows)]
260pub use crate::platform::DEFAULT_NODE_ENDPOINT;
261
262const NODE_EVENT_HISTORY_MAX: usize = 4_096;
263const NODE_BROADCAST_CAPACITY: usize = 1_024;
264const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
265const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
271const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
278const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
279const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
280const MAX_PREAUTH_CONNECTIONS: usize = 32;
281const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
282const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
283const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
284const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
285const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
286const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
292const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
300const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
308const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
315const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
320const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
321const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
322const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
323const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
324const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
325const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
326const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
327const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
328const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
329const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
330const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
331const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
332const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
335const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
342const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
345const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
346const GIT_STATUS_MAX_ENTRIES: usize = 128;
347const GIT_COMMIT_MAX_ENTRIES: usize = 12;
348const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
349const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
350const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
351const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
352const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
353const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
354const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
355const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
356const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
357const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
358 "provider-session-identity-allocation-failed";
359const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
360const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
361const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
362const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
363const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
364const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
365const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
366 "durable-state-path-semantics-unsupported";
367
368fn opaque_windows_path(value: String) -> OpaqueHostPath {
369 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
370}
371
372fn agent_progress_from_provider_snapshot(
373 address: SessionAddress,
374 provider: &ProviderSnapshot,
375) -> Option<SessionAgentProgress> {
376 let mut truncated = false;
377 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
378 let mut active_tool_labels = Vec::with_capacity(
379 provider
380 .active_tools
381 .len()
382 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
383 );
384 for tool in &provider.active_tools {
385 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
386 truncated = true;
387 break;
388 }
389 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
390 active_tool_labels.push(label);
391 }
392 }
393 let attention = provider
394 .interactions
395 .iter()
396 .find(|interaction| {
397 matches!(
398 interaction.status,
399 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
400 )
401 })
402 .map(|interaction| AgentProgressAttentionV1 {
403 kind: match interaction.interaction_kind {
404 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
405 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
406 },
407 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
408 });
409 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
410 let usage = (provider.completed_turns > 0
411 || provider.usage.input_tokens > 0
412 || provider.usage.output_tokens > 0
413 || provider.usage.cache_read_tokens > 0
414 || provider.usage.cache_write_tokens > 0
415 || provider.usage.reasoning_tokens > 0)
416 .then_some(AgentProgressUsageV1 {
417 input_tokens: provider.usage.input_tokens,
418 output_tokens: provider.usage.output_tokens,
419 cache_read_tokens: provider.usage.cache_read_tokens,
420 cache_write_tokens: provider.usage.cache_write_tokens,
421 reasoning_tokens: provider.usage.reasoning_tokens,
422 });
423 let progress = AgentProgressV1 {
424 provider_sequence: provider.sequence,
425 activity: provider.activity,
426 completed_turns: provider.completed_turns,
427 usage,
428 current: AgentProgressCurrentV1::from(provider.activity),
429 active_tool_labels,
430 active_tool_count,
431 attention,
432 subagent_count,
433 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
434 gap_count: provider.gap_count,
435 stale: provider.stale,
436 truncated,
437 };
438 let entry = SessionAgentProgress { address, progress };
439 serde_json::to_vec(&entry)
440 .ok()
441 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
442 .map(|_| entry)
443}
444
445fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
446 match u32::try_from(count) {
447 Ok(count) => count,
448 Err(_) => {
449 *truncated = true;
450 u32::MAX
451 }
452 }
453}
454
455fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
456 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
457 return None;
458 }
459 Some(match event {
460 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
461 ProviderEvent::SessionIdentityObserved { .. } => {
462 AgentProgressEventKindV1::SessionIdentityObserved
463 }
464 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
465 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
466 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
467 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
468 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
469 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
470 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
471 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
472 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
473 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
474 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
475 ProviderEvent::InteractionRequested { .. } => {
476 AgentProgressEventKindV1::InteractionRequested
477 }
478 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
479 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
480 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
481 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
482 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
483 ProviderEvent::UnrecognizedNotification { .. } => {
484 AgentProgressEventKindV1::UnrecognizedNotification
485 }
486 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
487 ProviderEvent::UserMessage { .. }
498 | ProviderEvent::Plan { .. }
499 | ProviderEvent::AvailableCommandsUpdated { .. }
500 | ProviderEvent::ModeChanged { .. }
501 | ProviderEvent::SessionInfoUpdated { .. }
502 | ProviderEvent::UsageUpdated { .. }
503 | ProviderEvent::ConfigOptionsUpdated { .. } => {
504 AgentProgressEventKindV1::UnrecognizedNotification
505 }
506 })
507}
508
509fn provider_family_is_live(family: AdapterFamily) -> bool {
522 match family {
523 AdapterFamily::PtySemantic
524 | AdapterFamily::Hook
525 | AdapterFamily::ManagedHook
526 | AdapterFamily::Pipe
527 | AdapterFamily::OneShot
528 | AdapterFamily::Acp => true,
529 AdapterFamily::History
530 | AdapterFamily::Resume
531 | AdapterFamily::SessionOptions
532 | AdapterFamily::CapabilityProbe => false,
533 }
534}
535
536fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
537 match by {
538 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
539 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
540 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
541 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
542 }
543}
544
545fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
557 match authority {
558 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
559 BlockAuthorityV1::HostPolicy => "denied by host policy",
560 BlockAuthorityV1::HostDeadline => {
561 "denied by host policy after the operator deadline expired"
562 }
563 BlockAuthorityV1::Operator => "declined by the operator",
564 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
565 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
566 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
567 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
568 BlockAuthorityV1::UserRejected => "rejected by the user",
569 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
570 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
571 }
572}
573
574fn host_request_denied_block(
577 method: &str,
578 by: ProviderHostDecisionAuthority,
579 reason: Option<&str>,
580) -> (String, BlockAuthorityV1, String) {
581 let authority = block_authority(by);
582 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
583 let (reason_text, _) =
587 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
588 (tool_class_label(method), authority, reason_text)
589}
590
591fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
610 match kind? {
611 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
612 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
613 _ => None,
614 }
615}
616
617fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
629 let trimmed = text.trim();
630 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
631 return (trimmed.to_owned(), None);
632 };
633 let tail = after_reason.trim_start();
634 let Some((reason, help)) = tail.split_once(". ") else {
635 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
639 return (reason.to_owned(), None);
640 };
641 let help = help.trim();
642 if help.is_empty() {
643 (reason.trim().to_owned(), None)
644 } else {
645 (reason.trim().to_owned(), Some(help.to_owned()))
646 }
647}
648
649fn tool_class_from_denial_help(help: Option<&str>) -> String {
659 help.and_then(|text| text.split("add a ").nth(1))
660 .and_then(|tail| tail.split(' ').next())
661 .filter(|word| !word.is_empty())
662 .map(str::to_owned)
663 .unwrap_or_else(|| "Tool".to_owned())
664}
665
666fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
674 fn names_quota(s: &str) -> bool {
675 let lower = s.to_ascii_lowercase();
676 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
677 }
678 vendor_code.is_some_and(names_quota) || names_quota(message)
679}
680
681fn provider_session_end_block(
697 stop_reason: &ProviderStopReason,
698) -> Option<(BlockAuthorityV1, String, String)> {
699 match stop_reason {
700 ProviderStopReason::Refusal => Some((
701 BlockAuthorityV1::ProviderRefusal,
702 "refusal".to_owned(),
703 "the provider ended the turn with stopReason: refusal".to_owned(),
704 )),
705 ProviderStopReason::ProviderError { message, vendor_code, .. }
706 if provider_error_is_quota(vendor_code.as_deref(), message) =>
707 {
708 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
709 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
710 }
711 _ => None,
712 }
713}
714
715fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
756 let ControlEventKind::ProviderEvent {
757 sequence: provider_sequence,
758 source,
759 source_sequence,
760 event: provider_event,
761 } = &event.event
762 else {
763 return None;
764 };
765 if !provider_family_is_live(source.family) {
766 return None;
767 }
768 let kind = match provider_event {
769 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
770 text: text.clone(),
771 is_delta: *is_delta,
772 },
773 ProviderEvent::Thinking { text } => {
774 AgentStreamChunkKindV1::Thinking { text: text.clone() }
775 }
776 ProviderEvent::InteractionRequested {
777 interaction_kind,
778 tool_name,
779 title,
780 prompt,
781 options,
782 ..
783 } => AgentStreamChunkKindV1::InteractionPrompt {
784 correlation_id: interaction_correlation(
785 event.instance_id,
786 event.generation,
787 *provider_sequence,
788 ),
789 interaction_kind: *interaction_kind,
790 tool_name: tool_name.clone(),
791 title: title.clone(),
800 prompt: prompt.clone(),
801 options: options
802 .iter()
803 .map(|option| AgentStreamInteractionOptionV1 {
804 option_id: option.option_id.clone(),
805 name: option.name.clone(),
806 kind: option.kind.clone(),
807 })
808 .collect(),
809 },
810 ProviderEvent::ConfigOptionsUpdated { options } => {
811 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
812 }
813 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
824 current: Some(mode_id.clone()),
825 available: available
826 .iter()
827 .map(|mode| AgentStreamNamedIdV1 {
828 id: mode.id.clone(),
829 name: mode.name.clone(),
830 description: mode.description.clone(),
831 })
832 .collect(),
833 },
834 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
838 let ProviderHostRequestDecision::Denied { by } = decision else {
839 return None;
840 };
841 let (tool_class, authority, reason_text) =
842 host_request_denied_block(method, *by, reason.as_deref());
843 AgentStreamChunkKindV1::Blocked {
844 correlation_id: None,
845 tool_class,
846 authority,
847 reason_kind: None,
848 reason: reason_text,
849 help: None,
850 }
851 }
852 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
856 let (authority, reason_kind) =
857 provider_tool_block_authority(non_execution_kind.as_deref())?;
858 let (reason, help) = split_provider_denial_text(output);
859 let (reason, _) =
860 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
861 let help = help.map(|help| {
862 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
863 });
864 AgentStreamChunkKindV1::Blocked {
865 correlation_id: Some(tool_correlation(event, source, id)),
866 tool_class: tool_class_from_denial_help(help.as_deref()),
867 authority,
868 reason_kind: Some(reason_kind.to_owned()),
869 reason,
870 help,
871 }
872 }
873 ProviderEvent::SessionEnded { stop_reason, .. } => {
877 let (authority, reason_kind, reason) =
878 stop_reason.as_ref().and_then(provider_session_end_block)?;
879 let (reason, _) =
880 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
881 AgentStreamChunkKindV1::Blocked {
882 correlation_id: None,
883 tool_class: "Session".to_owned(),
884 authority,
885 reason_kind: Some(reason_kind),
886 reason,
887 help: None,
888 }
889 }
890 ProviderEvent::SessionStarted { .. }
891 | ProviderEvent::SessionIdentityObserved { .. }
892 | ProviderEvent::TurnStarted { .. }
893 | ProviderEvent::WorkingObserved
894 | ProviderEvent::ToolStarted { .. }
895 | ProviderEvent::TurnCompleted { .. }
896 | ProviderEvent::ContextWindowUsage { .. }
897 | ProviderEvent::TurnInterrupted
898 | ProviderEvent::Error { .. }
899 | ProviderEvent::Ready
900 | ProviderEvent::InteractionResolved { .. }
901 | ProviderEvent::SubagentStarted { .. }
902 | ProviderEvent::SubagentStopped { .. }
903 | ProviderEvent::RateLimited { .. }
904 | ProviderEvent::UnrecognizedNotification { .. }
905 | ProviderEvent::UserMessage { .. }
906 | ProviderEvent::Plan { .. }
907 | ProviderEvent::AvailableCommandsUpdated { .. }
908 | ProviderEvent::SessionInfoUpdated { .. }
909 | ProviderEvent::UsageUpdated { .. } => return None,
910 };
911 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
912}
913
914async fn run_native_session_catalog_operation<T, E, F>(
915 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
916 operation: &'static str,
917 execute: F,
918) -> Result<T, NodeFailure>
919where
920 T: Send + 'static,
921 E: std::fmt::Display + Send + 'static,
922 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
923{
924 let task = tokio::task::spawn_blocking(move || {
925 let mut catalog = match catalog.try_lock() {
926 Ok(catalog) => catalog,
927 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
928 Err(std::sync::TryLockError::WouldBlock) => {
929 return Err(failure(
930 NodeFailureCode::BackendBusy,
931 "native session catalog operation is already running",
932 ));
933 }
934 };
935 execute(&mut catalog).map_err(|error| {
936 failure(
937 NodeFailureCode::BackendOperationFailed,
938 &format!("{operation} failed: {error}"),
939 )
940 })
941 });
942 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
943 .await
944 .map_err(|_| {
945 failure(
946 NodeFailureCode::BackendBusy,
947 &format!("{operation} exceeded its bounded deadline"),
948 )
949 })?
950 .map_err(|_| {
951 failure(
952 NodeFailureCode::BackendOperationFailed,
953 &format!("{operation} task failed"),
954 )
955 })?
956}
957
958fn windows_path_text(path: &OpaqueHostPath) -> &str {
959 path.as_utf8()
960 .expect("Windows node only creates UTF-8 opaque host paths")
961}
962
963fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
964 path.as_utf8()
965 .map(str::to_owned)
966 .ok_or_else(|| failure(
967 NodeFailureCode::InvalidRequest,
968 "this node requires UTF-8 host paths",
969 ))
970}
971
972fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
973 GitWorktreeSnapshot {
974 path: opaque_windows_path(worktree.path),
975 head: worktree.head,
976 branch: worktree.branch,
977 is_bare: worktree.is_bare,
978 is_main: worktree.is_main,
979 locked: worktree.locked,
980 lock_reason: worktree.lock_reason,
981 prunable: worktree.prunable,
982 prunable_reason: worktree.prunable_reason,
983 workspace_id: worktree.workspace_id.take(),
984 }
985}
986
987#[derive(Clone, Debug, Eq, PartialEq)]
988pub struct WorkspaceConfig {
989 workspace_id: WorkspaceId,
990 canonical_root: String,
991 worktree_service_mode: WorktreeServiceMode,
992 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
993}
994
995impl WorkspaceConfig {
996 pub fn new(
997 workspace_id: WorkspaceId,
998 root: impl AsRef<Path>,
999 ) -> Result<Self, NodeServerError> {
1000 let root = root.as_ref();
1001 if !root.is_absolute() {
1002 return Err(NodeServerError::InvalidWorkspaceRoot {
1003 workspace_id,
1004 path: root.to_string_lossy().into_owned(),
1005 message: "path is not absolute".to_owned(),
1006 });
1007 }
1008 let canonical = std::fs::canonicalize(root).map_err(|error| {
1009 NodeServerError::InvalidWorkspaceRoot {
1010 workspace_id: workspace_id.clone(),
1011 path: root.to_string_lossy().into_owned(),
1012 message: error.to_string(),
1013 }
1014 })?;
1015 if !canonical.is_dir() {
1016 return Err(NodeServerError::InvalidWorkspaceRoot {
1017 workspace_id,
1018 path: root.to_string_lossy().into_owned(),
1019 message: "path is not a directory".to_owned(),
1020 });
1021 }
1022 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1023 NodeServerError::InvalidWorkspaceRoot {
1024 workspace_id: workspace_id.clone(),
1025 path: path.to_string_lossy().into_owned(),
1026 message: "canonical path is not valid Unicode".to_owned(),
1027 }
1028 })?;
1029 let canonical_root = platform::normalize_canonical_root(canonical_root);
1030 validate_workspace_root(&workspace_id, &canonical_root)?;
1031 Ok(Self {
1032 workspace_id,
1033 canonical_root,
1034 worktree_service_mode: WorktreeServiceMode::Manual,
1035 managed_worktree_profiles: BTreeMap::new(),
1036 })
1037 }
1038
1039 pub fn workspace_id(&self) -> &WorkspaceId {
1040 &self.workspace_id
1041 }
1042
1043 pub fn canonical_root(&self) -> &str {
1044 &self.canonical_root
1045 }
1046
1047 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1048 self.worktree_service_mode = mode;
1049 self
1050 }
1051
1052 pub fn with_managed_worktree_profile(
1053 mut self,
1054 profile: ManagedWorktreeProfile,
1055 ) -> Result<Self, NodeServerError> {
1056 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1057 NodeServerError::InvalidManagedWorktreeProfile {
1058 workspace_id: self.workspace_id.clone(),
1059 profile_id: profile.profile_id().clone(),
1060 message,
1061 }
1062 })?;
1063 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1064 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1065 workspace_id: self.workspace_id,
1066 profile_id: profile.profile_id().clone(),
1067 });
1068 }
1069 if self.managed_worktree_profiles.len()
1070 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1071 {
1072 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1073 workspace_id: self.workspace_id,
1074 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1075 });
1076 }
1077 self.managed_worktree_profiles
1078 .insert(profile.profile_id().clone(), profile);
1079 Ok(self)
1080 }
1081}
1082
1083#[derive(Clone)]
1084pub struct NodeServerConfig {
1085 pub endpoint: String,
1086 api_listen: Option<std::net::SocketAddr>,
1087 bridge_listen: Option<std::net::SocketAddr>,
1090 bridge_token: Option<String>,
1093 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1096 call_home: Option<std::net::SocketAddr>,
1099 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1103 pub node_id: NodeId,
1104 pub workspaces: Vec<WorkspaceConfig>,
1105 access_token: String,
1106 pub runtime: NativeRuntimeConfig,
1107 session_record_retention: SessionRecordRetentionConfig,
1108 state_path: Option<PathBuf>,
1109 spawn_profiles: SpawnProfileRegistry,
1110 session_environment: Option<NodeSessionEnvironmentConfig>,
1111 history: Option<NativeHistoryConfig>,
1112 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1113 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1117 #[cfg(feature = "fixture")]
1118 fixture_raw_pty_runtime: bool,
1119}
1120
1121#[derive(Clone)]
1122struct NodeSessionEnvironmentConfig {
1123 root: PathBuf,
1124 resolver: Arc<dyn NodeSecretResolver>,
1125}
1126
1127impl NodeServerConfig {
1128 pub fn new(
1129 endpoint: impl Into<String>,
1130 access_token: impl Into<String>,
1131 node_id: NodeId,
1132 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1133 ) -> Result<Self, NodeServerError> {
1134 let endpoint = endpoint.into();
1135 let access_token = access_token.into();
1136 if !platform::validate_endpoint(&endpoint) {
1137 return Err(NodeServerError::InvalidEndpoint);
1138 }
1139 if access_token.is_empty() || access_token.len() > 4_096 {
1140 return Err(NodeServerError::InvalidAccessToken);
1141 }
1142 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1143 if workspaces.is_empty() {
1144 return Err(NodeServerError::NoWorkspaces);
1145 }
1146 let mut workspace_ids = BTreeMap::new();
1147 let mut workspace_roots = BTreeMap::new();
1148 for workspace in &workspaces {
1149 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1150 && workspace.managed_worktree_profiles.is_empty()
1151 {
1152 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1153 workspace.workspace_id.clone(),
1154 ));
1155 }
1156 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1157 && !workspace.managed_worktree_profiles.is_empty()
1158 {
1159 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1160 workspace.workspace_id.clone(),
1161 ));
1162 }
1163 if workspace_ids
1164 .insert(workspace.workspace_id.clone(), ())
1165 .is_some()
1166 {
1167 return Err(NodeServerError::DuplicateWorkspaceId(
1168 workspace.workspace_id.clone(),
1169 ));
1170 }
1171 let root_key = platform::root_identity(&workspace.canonical_root);
1172 if let Some(existing) = workspace_roots.insert(
1173 root_key,
1174 workspace.workspace_id.clone(),
1175 ) {
1176 return Err(NodeServerError::DuplicateWorkspaceRoot {
1177 first: existing,
1178 second: workspace.workspace_id.clone(),
1179 root: workspace.canonical_root.clone(),
1180 });
1181 }
1182 }
1183 Ok(Self {
1184 endpoint,
1185 api_listen: None,
1186 bridge_listen: None,
1187 bridge_token: None,
1188 bridge_underlay: None,
1189 call_home: None,
1190 call_home_identity: None,
1191 node_id,
1192 workspaces,
1193 access_token,
1194 runtime: NativeRuntimeConfig {
1197 worker_poll_interval_ms: 4,
1198 ..NativeRuntimeConfig::default()
1199 },
1200 session_record_retention: SessionRecordRetentionConfig::default(),
1201 state_path: None,
1202 spawn_profiles: SpawnProfileRegistry::default(),
1203 session_environment: None,
1204 history: None,
1205 harness_mcp_helper: None,
1206 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1207 #[cfg(feature = "fixture")]
1208 fixture_raw_pty_runtime: false,
1209 })
1210 }
1211
1212 pub fn with_api_listen(
1213 mut self,
1214 api_listen: std::net::SocketAddr,
1215 ) -> Result<Self, NodeServerError> {
1216 if !api_listen.ip().is_loopback() {
1217 return Err(NodeServerError::InvalidApiListen(api_listen));
1218 }
1219 self.api_listen = Some(api_listen);
1220 Ok(self)
1221 }
1222
1223 pub fn with_bridge_listen(
1227 mut self,
1228 bridge_listen: std::net::SocketAddr,
1229 ) -> Result<Self, NodeServerError> {
1230 if !bridge_listen.ip().is_loopback() {
1231 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1232 }
1233 self.bridge_listen = Some(bridge_listen);
1234 Ok(self)
1235 }
1236
1237 pub fn with_bridge_token(
1240 mut self,
1241 bridge_token: impl Into<String>,
1242 ) -> Result<Self, NodeServerError> {
1243 let bridge_token = bridge_token.into();
1244 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1245 return Err(NodeServerError::InvalidBridgeToken);
1246 }
1247 self.bridge_token = Some(bridge_token);
1248 Ok(self)
1249 }
1250
1251 pub fn with_bridge_underlay(
1256 mut self,
1257 bind: std::net::SocketAddr,
1258 transport_key: [u8; 32],
1259 underlay_token: impl Into<String>,
1260 ) -> Result<Self, NodeServerError> {
1261 if self.bridge_listen.is_none() {
1262 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1263 }
1264 if self.bridge_token.is_none() {
1265 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1266 }
1267 let underlay_token = underlay_token.into();
1268 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1269 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1270 }
1271 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1273 bind,
1274 transport_key,
1275 underlay_token,
1276 });
1277 Ok(self)
1278 }
1279
1280 pub fn with_call_home(
1301 mut self,
1302 relay: std::net::SocketAddr,
1303 ) -> Result<Self, NodeServerError> {
1304 if relay.port() == 0 || relay.ip().is_unspecified() {
1307 return Err(NodeServerError::InvalidCallHome(relay));
1308 }
1309 self.call_home = Some(relay);
1310 Ok(self)
1311 }
1312
1313 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1315 self.call_home_identity = Some(identity);
1316 self
1317 }
1318
1319 pub fn with_state_path(
1320 mut self,
1321 state_path: impl Into<PathBuf>,
1322 ) -> Result<Self, NodeServerError> {
1323 let state_path = state_path.into();
1324 if !state_path.is_absolute() || state_path.file_name().is_none() {
1325 return Err(NodeServerError::InvalidStatePath(
1326 state_path.to_string_lossy().into_owned(),
1327 ));
1328 }
1329 self.state_path = Some(state_path);
1330 Ok(self)
1331 }
1332
1333 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1334 self.spawn_profiles = spawn_profiles;
1335 self
1336 }
1337
1338 pub fn with_session_record_retention(
1344 mut self,
1345 retention: SessionRecordRetentionConfig,
1346 ) -> Self {
1347 self.session_record_retention = retention;
1348 self
1349 }
1350
1351 pub fn with_session_environment_materialization(
1352 mut self,
1353 root: impl Into<PathBuf>,
1354 resolver: Arc<dyn NodeSecretResolver>,
1355 ) -> Result<Self, NodeServerError> {
1356 let root = root.into();
1357 if !root.is_absolute() {
1358 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1359 }
1360 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1361 Ok(self)
1362 }
1363
1364 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1367 self.history = Some(history);
1368 self
1369 }
1370
1371 pub fn with_harness_mcp_helper(
1377 mut self,
1378 helper_program: impl Into<PathBuf>,
1379 ) -> Result<Self, NodeServerError> {
1380 self.harness_mcp_helper = Some(
1381 ReviewedHarnessMcpProgram::review(helper_program.into())
1382 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1383 );
1384 Ok(self)
1385 }
1386
1387 pub fn with_network_allowlist_catalog(
1392 mut self,
1393 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1394 ) -> Result<Self, NodeServerError> {
1395 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1396 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1397 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1398 });
1399 }
1400 self.network_allowlist_catalog = catalog;
1401 Ok(self)
1402 }
1403
1404 pub fn with_network_allowlist_catalog_file(
1408 self,
1409 path: impl AsRef<std::path::Path>,
1410 ) -> Result<Self, NodeServerError> {
1411 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1412 .map_err(NodeServerError::from)?;
1413 self.with_network_allowlist_catalog(catalog)
1414 }
1415}
1416
1417pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1418 platform::default_state_path(node_id.as_str())
1419 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1420}
1421
1422pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1423 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1424}
1425
1426fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1427 let parent = state_path.parent()?;
1428 let mut name = state_path.file_name()?.to_os_string();
1429 name.push(".delivery-store");
1430 Some(parent.join(name))
1431}
1432
1433fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1434 let parent = state_path.parent()?;
1435 let mut name = state_path.file_name()?.to_os_string();
1436 name.push(".context-pack-store");
1437 Some(parent.join(name))
1438}
1439
1440pub struct NodeServer {
1441 config: NodeServerConfig,
1442 runtime: NativeRuntime,
1443 shared: Arc<NodeShared>,
1444 events: EventSubscription,
1445 state_path_lock: Option<session_registry::StatePathLock>,
1446}
1447
1448#[cfg(feature = "fixture")]
1449#[derive(Clone, Default)]
1450pub struct SpawnManagedWorktreeV2FailureProbe {
1451 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1452}
1453
1454#[cfg(feature = "fixture")]
1455impl SpawnManagedWorktreeV2FailureProbe {
1456 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1457 *self
1458 .latest_code
1459 .lock()
1460 .unwrap_or_else(|poisoned| poisoned.into_inner())
1461 }
1462
1463 fn record(&self, code: Option<NodeFailureCode>) {
1464 *self
1465 .latest_code
1466 .lock()
1467 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1468 }
1469}
1470
1471#[cfg(all(feature = "fixture", windows))]
1472struct IsolatedCodexHomeFixtureEnvironment;
1473
1474#[cfg(all(feature = "fixture", windows))]
1475impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1476 for IsolatedCodexHomeFixtureEnvironment
1477{
1478 fn resolve_child_environment(
1479 &self,
1480 ) -> Result<
1481 Vec<gate4agent_catalog::EnvMutation>,
1482 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1483 > {
1484 Ok(vec![gate4agent_catalog::EnvMutation {
1485 key: OsString::from("USERPROFILE"),
1486 value: None,
1487 }])
1488 }
1489}
1490
1491impl NodeServer {
1492 #[cfg(unix)]
1496 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1497 self.shared.signer_proxy.set(proxy).is_ok()
1498 }
1499
1500 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1501 self.shared.m4a_accounts.as_ref()?;
1502 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1503 }
1504
1505 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1506 let catalog = active_registry()?;
1507 Self::new_with_registry(config, catalog)
1508 }
1509
1510 #[cfg(feature = "fixture")]
1511 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1512 let mut spec = gate4agent_testkit::interactive_agent_spec();
1513 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1514 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1515 Self::new_fixture_with_spec(config, spec)
1516 }
1517
1518 #[cfg(feature = "fixture")]
1519 pub fn new_clean_exit_fixture(
1520 config: NodeServerConfig,
1521 fixture_root: PathBuf,
1522 started_marker: PathBuf,
1523 release_signal: PathBuf,
1524 ) -> Result<Self, NodeServerError> {
1525 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1526 &fixture_root,
1527 &started_marker,
1528 &release_signal,
1529 )
1530 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1531 spec.id = AgentId::new("claude")
1532 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1533 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1534 Self::new_fixture_with_spec(config, spec)
1535 }
1536
1537 #[cfg(all(feature = "fixture", windows))]
1547 pub fn new_durable_context_pack_clean_exit_fixture(
1548 config: NodeServerConfig,
1549 fixture_root: PathBuf,
1550 started_marker: PathBuf,
1551 release_signal: PathBuf,
1552 session_id: &str,
1553 ) -> Result<Self, NodeServerError> {
1554 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1555 &fixture_root,
1556 &started_marker,
1557 &release_signal,
1558 session_id,
1559 )
1560 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1561 let source_id = AgentId::new("claude")
1562 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1563 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1564 NodeServerError::Registry(
1565 "durable context pack clean-exit fixture source adapter is unavailable"
1566 .to_owned(),
1567 )
1568 })?;
1569 source_spec.capabilities.adapters.history =
1570 source_provider.capabilities.adapters.history.clone();
1571 source_spec.id = source_id;
1572 source_spec.display_name =
1573 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1574 source_spec.detection.command =
1580 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1581 source_spec.detection.aliases.clear();
1582
1583 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1584 target_spec.id = AgentId::new("codex")
1585 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1586 target_spec.display_name =
1587 "Controlled Codex durable context-pack target fixture".to_owned();
1588 target_spec.detection.command =
1589 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1590 target_spec.detection.aliases.clear();
1591
1592 let catalog = AgentRegistry::new([source_spec, target_spec])
1593 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1594 let mut server = Self::new_with_registry(config, catalog)?;
1595 Arc::get_mut(&mut server.shared)
1596 .expect("fixture Node shared state must remain uniquely owned before run")
1597 .fixture_semantic_hook_policy = true;
1598 Ok(server)
1599 }
1600
1601 #[cfg(feature = "fixture")]
1602 pub fn new_monitoring_hook_fixture(
1603 config: NodeServerConfig,
1604 ) -> Result<Self, NodeServerError> {
1605 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1606 spec.id = AgentId::new("claude")
1607 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1608 let mut server = Self::new_fixture_with_spec(config, spec)?;
1609 Arc::get_mut(&mut server.shared)
1610 .expect("fixture Node shared state must remain uniquely owned before run")
1611 .fixture_semantic_hook_policy = true;
1612 Ok(server)
1613 }
1614
1615 #[cfg(all(feature = "fixture", windows))]
1616 pub fn new_provider_bundle_argv_fixture(
1617 config: NodeServerConfig,
1618 agent_id: AgentId,
1619 proof_path: PathBuf,
1620 ) -> Result<Self, NodeServerError> {
1621 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1622 Self::new_fixture_with_spec(config, spec)
1623 }
1624
1625 #[cfg(all(feature = "fixture", windows))]
1626 pub fn new_provider_bundle_argv_hold_fixture(
1627 config: NodeServerConfig,
1628 agent_id: AgentId,
1629 proof_path: PathBuf,
1630 release_signal: PathBuf,
1631 ) -> Result<Self, NodeServerError> {
1632 let spec = Self::provider_bundle_argv_fixture_spec(
1633 agent_id,
1634 proof_path,
1635 Some(release_signal),
1636 )?;
1637 Self::new_fixture_with_spec(config, spec)
1638 }
1639
1640 #[cfg(all(feature = "fixture", windows))]
1641 fn provider_bundle_argv_fixture_spec(
1642 agent_id: AgentId,
1643 proof_path: PathBuf,
1644 release_signal: Option<PathBuf>,
1645 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1646 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1647 return Err(NodeServerError::Registry(
1648 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1649 ));
1650 }
1651 let mut spec = gate4agent_testkit::interactive_agent_spec();
1652 spec.id = agent_id.clone();
1653 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1654 let provider = builtin_registry()
1655 .get(&agent_id)
1656 .ok_or_else(|| {
1657 NodeServerError::Registry(
1658 "provider bundle argv fixture adapter is unavailable".to_owned(),
1659 )
1660 })?;
1661 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1662 if !proof_path.is_absolute()
1663 || !proof_path
1664 .parent()
1665 .is_some_and(|parent| parent.is_dir())
1666 {
1667 return Err(NodeServerError::Registry(
1668 "provider bundle argv proof path requires an existing absolute parent"
1669 .to_owned(),
1670 ));
1671 }
1672 if let Some(release_signal) = release_signal.as_ref() {
1673 if !release_signal.is_absolute()
1674 || !release_signal
1675 .parent()
1676 .is_some_and(|parent| parent.is_dir())
1677 {
1678 return Err(NodeServerError::Registry(
1679 "provider bundle argv release path requires an existing absolute parent"
1680 .to_owned(),
1681 ));
1682 }
1683 if release_signal.exists() {
1684 return Err(NodeServerError::Registry(
1685 "provider bundle argv release path must not exist".to_owned(),
1686 ));
1687 }
1688 let proof_parent = std::fs::canonicalize(
1689 proof_path
1690 .parent()
1691 .expect("validated provider bundle proof parent"),
1692 )
1693 .map_err(|_| {
1694 NodeServerError::Registry(
1695 "provider bundle argv proof parent is unavailable".to_owned(),
1696 )
1697 })?;
1698 let release_parent = std::fs::canonicalize(
1699 release_signal
1700 .parent()
1701 .expect("validated provider bundle release parent"),
1702 )
1703 .map_err(|_| {
1704 NodeServerError::Registry(
1705 "provider bundle argv release parent is unavailable".to_owned(),
1706 )
1707 })?;
1708 if proof_parent != release_parent {
1709 return Err(NodeServerError::Registry(
1710 "provider bundle argv release path must share the proof parent".to_owned(),
1711 ));
1712 }
1713 }
1714 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1715 NodeServerError::Registry(
1716 "provider bundle argv proof path is not valid Unicode".to_owned(),
1717 )
1718 })?;
1719 let release_signal = release_signal.map(|release_signal| {
1720 release_signal.into_os_string().into_string().map_err(|_| {
1721 NodeServerError::Registry(
1722 "provider bundle argv release path is not valid Unicode".to_owned(),
1723 )
1724 })
1725 }).transpose()?;
1726 let script = spec
1727 .launch
1728 .fixed_args
1729 .pop()
1730 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1731 let bundle_validation = if agent_id.as_str() == "codex" {
1732 "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') }"
1733 } else if agent_id.as_str() == "claude" {
1734 "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 }"
1735 } else {
1736 "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 }"
1737 };
1738 if let Some(release_signal) = release_signal {
1739 spec.launch.fixed_args.push(format!(
1740 "& {{ 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} }}",
1741 ));
1742 spec.launch.fixed_args.push(proof_path);
1743 spec.launch.fixed_args.push(release_signal);
1744 } else {
1745 spec.launch.fixed_args.push(format!(
1746 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1747 ));
1748 spec.launch.fixed_args.push(proof_path);
1749 }
1750 Ok(spec)
1751 }
1752
1753 #[cfg(all(feature = "fixture", windows))]
1754 pub fn new_context_pack_fixture(
1755 config: NodeServerConfig,
1756 proof_path: PathBuf,
1757 ) -> Result<Self, NodeServerError> {
1758 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1759 Self::new_with_registry(config, catalog)
1760 }
1761
1762 #[cfg(all(feature = "fixture", windows))]
1763 pub fn new_monitoring_context_pack_fixture(
1764 config: NodeServerConfig,
1765 proof_path: PathBuf,
1766 ) -> Result<Self, NodeServerError> {
1767 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1768 let mut server = Self::new_with_registry(config, catalog)?;
1769 Arc::get_mut(&mut server.shared)
1770 .expect("fixture Node shared state must remain uniquely owned before run")
1771 .fixture_semantic_hook_policy = true;
1772 Ok(server)
1773 }
1774
1775 #[cfg(all(feature = "fixture", windows))]
1776 pub fn install_isolated_codex_home_environment_fixture(
1777 &mut self,
1778 profile_id: SpawnEnvironmentProfileId,
1779 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1780 ) -> Result<(), NodeServerError> {
1781 let provider = AgentId::new("codex")
1782 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1783 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1784 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1785 "{}-pty",
1786 profile_id.as_str(),
1787 ))
1788 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1789 provider.clone(),
1790 TransportKind::Pty,
1791 vec![OsString::from("USERPROFILE")],
1792 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1793 )
1794 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1795 let materialization = NodeSessionMaterializationProfile::new(
1796 Vec::new(),
1797 vec![crate::session_environment::NodeSessionPathBinding::new(
1798 "CODEX_HOME",
1799 crate::session_environment::NodeSessionPathClass::ProviderHome,
1800 )
1801 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1802 Vec::new(),
1803 )
1804 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1805 self.install_environment_profile(
1806 NodeEnvironmentProfile::new_with_materialization(
1807 profile_id,
1808 profile_revision,
1809 provider,
1810 [native_profile],
1811 Some(materialization),
1812 )
1813 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1814 )
1815 }
1816
1817 #[cfg(all(feature = "fixture", windows))]
1818 pub fn new_context_only_proof_fixture(
1819 config: NodeServerConfig,
1820 target_provider: AgentId,
1821 proof_path: PathBuf,
1822 ) -> Result<Self, NodeServerError> {
1823 if target_provider.as_str() != "kimi" {
1824 return Err(NodeServerError::Registry(
1825 "context-only proof fixture requires Kimi".to_owned(),
1826 ));
1827 }
1828 let catalog = Self::context_pack_fixture_catalog(
1829 proof_path,
1830 Some(&target_provider),
1831 false,
1832 )?;
1833 Self::new_with_registry(config, catalog)
1834 }
1835
1836 #[cfg(all(feature = "fixture", windows))]
1837 fn context_pack_fixture_catalog(
1838 proof_path: PathBuf,
1839 context_only_target: Option<&AgentId>,
1840 monitoring_claude_source: bool,
1841 ) -> Result<AgentRegistry, NodeServerError> {
1842 if !proof_path.is_absolute()
1843 || !proof_path
1844 .parent()
1845 .is_some_and(|parent| parent.is_dir())
1846 {
1847 return Err(NodeServerError::Registry(
1848 "context pack proof path requires an existing absolute parent".to_owned(),
1849 ));
1850 }
1851 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1852 NodeServerError::Registry(
1853 "context pack proof path is not valid Unicode".to_owned(),
1854 )
1855 })?;
1856 let providers = ["claude", "codex", "grok", "kimi"];
1857 let builtins = builtin_registry();
1858 let mut specs = Vec::with_capacity(providers.len());
1859 for provider_id in providers {
1860 let agent_id = AgentId::new(provider_id)
1861 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1862 let provider = builtins.get(&agent_id).ok_or_else(|| {
1863 NodeServerError::Registry(
1864 "context pack fixture provider adapter is unavailable".to_owned(),
1865 )
1866 })?;
1867 let monitored_provider =
1868 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1869 let mut spec = if monitored_provider {
1870 gate4agent_testkit::monitoring_hook_agent_spec()
1871 } else {
1872 gate4agent_testkit::interactive_agent_spec()
1873 };
1874 spec.id = agent_id.clone();
1875 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1876 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1877 spec.detection.aliases.clear();
1878 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1879 if provider_id == "codex" {
1880 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1881 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1882 })?;
1883 let validation = r#"
1884$bundleArgs = @($bundleArgs)
1885if ($bundleArgs.Count -ne 0) { exit 91 }
1886$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1887$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1888$cwd = (Get-Location).ProviderPath
1889if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1890$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1891if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1892$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1893if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1894$sha = [Security.Cryptography.SHA256]::Create()
1895try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1896if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1897if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1898$contextPath = Join-Path $contextRoot 'context-pack.json'
1899if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1900$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1901if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1902$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1903$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1904$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1905$separator = [IO.Path]::DirectorySeparatorChar
1906if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1907if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1908$rawContext = [IO.File]::ReadAllText($contextPath)
1909if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1910try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1911$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1912$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1913foreach ($property in @($document.PSObject.Properties.Name)) {
1914 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1915}
1916foreach ($property in $requiredTopLevel) {
1917 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1918}
1919if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1920if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1921$messages = @($document.retained_messages)
1922if ($messages.Count -eq 0) { exit 107 }
1923foreach ($message in $messages) {
1924 $messageProperties = @($message.PSObject.Properties.Name)
1925 foreach ($property in $messageProperties) {
1926 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1927 }
1928 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1929}
1930$sha = [Security.Cryptography.SHA256]::Create()
1931try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1932[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))
1933$rawProof = [IO.File]::ReadAllText($proofPath)
1934if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1935[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1936"#;
1937 spec.launch.fixed_args.push(format!(
1938 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1939 ));
1940 spec.launch.fixed_args.push(proof_path.clone());
1941 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1942 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1943 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1944 })?;
1945 let validation = r#"
1946$bundleArgs = @($bundleArgs)
1947if ($bundleArgs.Count -ne 0) { exit 91 }
1948$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1949$cwd = (Get-Location).ProviderPath
1950if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1951$contextPath = Join-Path $contextRoot 'context-pack.json'
1952if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1953$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1954if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1955$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1956$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1957$separator = [IO.Path]::DirectorySeparatorChar
1958if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1959try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1960if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1961if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1962$messages = @($document.retained_messages)
1963if ($messages.Count -eq 0) { exit 99 }
1964$hasUser = $false
1965$hasAssistant = $false
1966foreach ($message in $messages) {
1967 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1968 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1969 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1970 else { exit 101 }
1971}
1972if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1973$sha = [Security.Cryptography.SHA256]::Create()
1974try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1975[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1976[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1977"#;
1978 spec.launch.fixed_args.push(format!(
1979 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1980 ));
1981 spec.launch.fixed_args.push(proof_path.clone());
1982 }
1983 specs.push(spec);
1984 }
1985 let catalog = AgentRegistry::new(specs)
1986 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1987 Ok(catalog)
1988 }
1989
1990 #[cfg(feature = "fixture")]
1991 pub fn new_exact_launcher_fixture(
1992 config: NodeServerConfig,
1993 agent_id: AgentId,
1994 launcher: String,
1995 ) -> Result<Self, NodeServerError> {
1996 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1997 return Err(NodeServerError::Registry(
1998 "exact launcher fixture requires a supported provider".to_owned(),
1999 ));
2000 }
2001 if launcher.contains('\0') {
2002 return Err(NodeServerError::Registry(
2003 "exact launcher fixture path contains NUL".to_owned(),
2004 ));
2005 }
2006 let launcher_path = Path::new(&launcher);
2007 if !launcher_path.is_absolute() {
2008 return Err(NodeServerError::Registry(
2009 "exact launcher fixture path must be absolute".to_owned(),
2010 ));
2011 }
2012 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2013 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2014 })?;
2015 if !metadata.is_file() {
2016 return Err(NodeServerError::Registry(
2017 "exact launcher fixture path must be a regular file".to_owned(),
2018 ));
2019 }
2020 let mut spec = builtin_registry()
2021 .get(&agent_id)
2022 .ok_or_else(|| {
2023 NodeServerError::Registry(
2024 "exact launcher fixture provider is unavailable".to_owned(),
2025 )
2026 })?
2027 .clone();
2028 spec.launch.program = launcher;
2029 Self::new_fixture_with_spec(config, spec)
2030 }
2031
2032 #[cfg(feature = "fixture")]
2041 fn h3b_provider_spec_with_program_and_args(
2042 agent_id: AgentId,
2043 provider_program: PathBuf,
2044 fixed_args: Vec<OsString>,
2045 ) -> Result<AgentSpec, NodeServerError> {
2046 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2047 return Err(NodeServerError::Registry(
2048 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2049 ));
2050 }
2051 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2052 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2053 })?;
2054 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2055 return Err(NodeServerError::Registry(
2056 "H3B fixture provider must be an exact regular file".to_owned(),
2057 ));
2058 }
2059 let program = provider_program.into_os_string().into_string().map_err(|_| {
2060 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2061 })?;
2062 let fixed_args = fixed_args.into_iter().map(|argument| {
2063 argument.into_string().map_err(|_| NodeServerError::Registry(
2064 "H3B fixture argument is not Unicode".to_owned(),
2065 ))
2066 }).collect::<Result<Vec<_>, _>>()?;
2067 let argument_bytes = fixed_args.iter()
2068 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2069 if program.contains('\0')
2070 || fixed_args.iter().any(|argument| argument.contains('\0'))
2071 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2072 {
2073 return Err(NodeServerError::Registry(
2074 "H3B fixture provider or arguments are invalid".to_owned(),
2075 ));
2076 }
2077 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2078 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2079 })?.clone();
2080 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2081 .ok_or_else(|| NodeServerError::Registry(
2082 "H3B fixture provider basename is unavailable".to_owned(),
2083 ))?.to_owned();
2084 spec.detection.command = process_name.clone();
2085 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2086 name: process_name,
2087 }];
2088 spec.launch.program = program;
2089 spec.launch.fixed_args = fixed_args;
2090 Ok(spec)
2091 }
2092
2093 #[cfg(feature = "fixture")]
2094 pub fn new_harness_mcp_proxy_fixture(
2095 config: NodeServerConfig,
2096 provider_program: PathBuf,
2097 fixed_args: Vec<OsString>,
2098 harness_mcp_program: PathBuf,
2099 ) -> Result<Self, NodeServerError> {
2100 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2101 let agent_id = AgentId::new("codex")
2102 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2103 let mut spec =
2104 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2105 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2106 config.fixture_raw_pty_runtime = true;
2107 Self::new_fixture_with_spec(config, spec)
2108 }
2109
2110 #[cfg(feature = "fixture")]
2134 pub fn new_harness_mcp_acp_launcher_fixture(
2135 config: NodeServerConfig,
2136 helper_program: PathBuf,
2137 provider: AgentId,
2138 program: PathBuf,
2139 args: Vec<OsString>,
2140 ) -> Result<Self, NodeServerError> {
2141 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2142 return Err(NodeServerError::Registry(
2143 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2144 ));
2145 }
2146 let config = config.with_harness_mcp_helper(helper_program)?;
2147 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2148 let launch = spec.launch.clone();
2149 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2150 NodeServerError::Registry(
2151 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2152 .to_owned(),
2153 )
2154 })?;
2155 acp.launch_override = Some(launch);
2163 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2164 Self::new_fixture_with_spec(config, spec)
2165 }
2166
2167 #[cfg(feature = "fixture")]
2168 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2169 let mut spec = gate4agent_testkit::interactive_agent_spec();
2170 let claude_id = AgentId::new("claude")
2171 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2172 let claude = builtin_registry()
2173 .get(&claude_id)
2174 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2175 spec.id = claude_id;
2176 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2177 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2178 name: "claude".to_owned(),
2179 }];
2180 spec.capabilities.transports.pty_adapter = claude
2181 .capabilities
2182 .transports
2183 .pty_adapter
2184 .clone();
2185 spec.capabilities.adapters.resume = claude
2186 .capabilities
2187 .adapters
2188 .resume
2189 .clone();
2190 let script = spec
2191 .launch
2192 .fixed_args
2193 .pop()
2194 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2195 spec.launch.fixed_args.push(format!(
2196 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2197 ));
2198 Self::new_fixture_with_spec(config, spec)
2199 }
2200
2201 #[cfg(feature = "fixture")]
2202 pub fn new_cmd_cwd_fixture(
2203 config: NodeServerConfig,
2204 command: String,
2205 ) -> Result<Self, NodeServerError> {
2206 let mut spec = gate4agent_testkit::interactive_agent_spec();
2207 spec.id = AgentId::new("claude")
2208 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2209 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2210 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2211 spec.launch.program = command;
2212 spec.launch.fixed_args.clear();
2213 Self::new_fixture_with_spec(config, spec)
2214 }
2215
2216 #[cfg(feature = "fixture")]
2217 fn new_fixture_with_spec(
2218 config: NodeServerConfig,
2219 spec: gate4agent_types::AgentSpec,
2220 ) -> Result<Self, NodeServerError> {
2221 let catalog = AgentRegistry::new([spec])
2222 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2223 Self::new_with_registry(config, catalog)
2224 }
2225
2226 fn new_with_registry(
2227 config: NodeServerConfig,
2228 catalog: AgentRegistry,
2229 ) -> Result<Self, NodeServerError> {
2230 for profile in config.spawn_profiles.iter() {
2231 if catalog.get(&profile.provider).is_none() {
2232 return Err(NodeServerError::Registry(
2233 "spawn profile references an unavailable provider".to_owned(),
2234 ));
2235 }
2236 }
2237 let input_settle_timeout_ms = catalog
2238 .iter()
2239 .map(|spec| spec.readiness.timeout_ms)
2240 .max()
2241 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2242 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2243 .max(MUTATION_SETTLE_TIMEOUT_MS);
2244 let (provider_contracts, provider_adapter_contracts) =
2245 provider_contract_manifest(&catalog)?;
2246 let compatibility_support = node_compatibility_support_for_manifest(
2247 &provider_contracts,
2248 &provider_adapter_contracts,
2249 )?;
2250 validate_node_negotiated_handshake_capacity(&compatibility_support)
2251 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2252 let enabled_providers = provider_contracts
2253 .iter()
2254 .map(|contract| contract.provider.clone())
2255 .collect();
2256 #[cfg(feature = "fixture")]
2257 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2258 #[cfg(not(feature = "fixture"))]
2259 let fixture_raw_pty_runtime = false;
2260 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2261 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2262 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2263 })).expect("fixture provider catalog remains bounded");
2264 (None, statuses)
2265 } else {
2266 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2267 let statuses = monitor.collect();
2268 (Some(monitor), statuses)
2269 };
2270 let state_path_lock =
2271 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2272 .map_err(|error| {
2273 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2274 })?;
2275 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2276 Some(state_path) => {
2277 let delivery_root = delivery_store_root_for_state_path(state_path)
2278 .ok_or_else(|| {
2279 NodeServerError::DeliveryStore(
2280 "delivery store root could not be derived from state path".to_owned(),
2281 )
2282 })?;
2283 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2284 let detail = match &error {
2285 DeliveryStoreError::Storage(io_error) => {
2286 format!("{error}: {io_error}")
2287 }
2288 _ => error.to_string(),
2289 };
2290 NodeServerError::DeliveryStore(detail)
2291 })?;
2292 (Some(store), bundles)
2293 }
2294 None => (None, Vec::new()),
2295 };
2296 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2297 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2298 })?;
2299 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2300 Some(state_path) => {
2301 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2302 .ok_or(NodeServerError::ContextPackStore)?;
2303 let (store, packs) = ContextPackStore::open(context_pack_root)
2304 .map_err(|_| NodeServerError::ContextPackStore)?;
2305 (Some(store), packs)
2306 }
2307 None => (None, Vec::new()),
2308 };
2309 let mut durable_context_catalog = ContextPackCatalog::default();
2310 for pack in durable_context_packs {
2311 durable_context_catalog
2312 .insert(pack)
2313 .map_err(|_| NodeServerError::ContextPackStore)?;
2314 }
2315 let session_environment_materializer = config
2316 .session_environment
2317 .as_ref()
2318 .map(|environment| {
2319 SessionEnvironmentMaterializer::new(
2320 environment.root.clone(),
2321 Arc::clone(&environment.resolver),
2322 )
2323 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2324 })
2325 .transpose()?;
2326 #[cfg(unix)]
2329 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2330 let native_session_catalog = config.history.clone().map(|history| {
2331 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2332 });
2333 let (handle, runtime) = match config.history.clone() {
2334 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2335 None => NativeRuntime::new(catalog, config.runtime),
2336 };
2337 let native_launch_profile_control = runtime.native_launch_profile_control();
2338 let harness_mcp_registry = config
2339 .harness_mcp_helper
2340 .clone()
2341 .map(HarnessMcpProxyRegistry::new);
2342 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2343 let incarnation_id = random_incarnation_id()
2344 .map_err(NodeServerError::IncarnationIdentity)?;
2345 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2346 .map_err(durable_state_load_error)?;
2347 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2348 let managed_worktree_tombstones =
2349 std::mem::take(&mut loaded.managed_worktree_tombstones);
2350 let materializations = std::mem::take(&mut loaded.materializations);
2351 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2352 let (workspaces, records, persistence_warning) =
2353 merge_durable_state(&config.workspaces, loaded)?;
2354 let mut shared = NodeShared::new_with_incarnation(
2355 handle,
2356 config.access_token.clone(),
2357 config.node_id.clone(),
2358 incarnation_id,
2359 workspaces,
2360 enabled_providers,
2361 provider_runtime_statuses,
2362 provider_runtime_monitor,
2363 provider_contracts,
2364 provider_adapter_contracts,
2365 config.spawn_profiles.clone(),
2366 Some(native_launch_profile_control),
2367 native_session_catalog,
2368 session_environment_materializer,
2369 config.state_path.clone(),
2370 records,
2371 managed_worktrees,
2372 managed_worktree_tombstones,
2373 materializations,
2374 persistence_warning,
2375 input_settle_timeout_ms,
2376 );
2377 shared.harness_mcp_registry = harness_mcp_registry.clone();
2378 shared.session_record_retention = config.session_record_retention;
2379 shared.network_allowlist_catalog =
2380 RwLock::new(config.network_allowlist_catalog.clone());
2381 shared.bundle_catalog = RwLock::new(delivered_catalog);
2382 shared.delivery_store = Mutex::new(delivery_store);
2383 shared.context_catalog = RwLock::new(durable_context_catalog);
2384 shared.context_pack_store = Mutex::new(context_pack_store);
2385 shared.managed_spawn_replays = Mutex::new(
2386 managed_spawn_replays
2387 .into_iter()
2388 .map(|record| (record.idempotency_key.clone(), record))
2389 .collect(),
2390 );
2391 let shared = Arc::new(shared);
2392 if let Some(registry) = harness_mcp_registry {
2393 let weak = Arc::downgrade(&shared);
2394 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2395 Some(shared) => shared.publish_transient(event),
2396 None => false,
2397 }));
2398 }
2399 if shared.persistence_error().is_none() {
2400 shared.persist_state().map_err(|error| {
2401 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2402 })?;
2403 }
2404 Ok(Self {
2405 config,
2406 runtime,
2407 shared,
2408 events,
2409 state_path_lock,
2410 })
2411 }
2412
2413 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2414 NodeShutdownHandle {
2415 shared: Arc::clone(&self.shared),
2416 }
2417 }
2418
2419 #[cfg(feature = "fixture")]
2420 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2421 &self,
2422 ) -> SpawnManagedWorktreeV2FailureProbe {
2423 self.shared
2424 .fixture_spawn_managed_worktree_v2_failure_probe
2425 .clone()
2426 }
2427
2428 pub fn install_environment_profile(
2433 &mut self,
2434 profile: NodeEnvironmentProfile,
2435 ) -> Result<(), NodeServerError> {
2436 if !self.shared.enabled_providers.contains(profile.provider()) {
2437 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2438 profile.provider().clone(),
2439 ));
2440 }
2441 let (binding, native_profiles, materialization) = profile.into_parts();
2442 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2443 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2444 }
2445 let mut profiles = self
2446 .shared
2447 .environment_profiles
2448 .write()
2449 .unwrap_or_else(|poisoned| poisoned.into_inner());
2450 if profiles.contains_key(&binding.id) {
2451 return Err(NodeServerError::DuplicateEnvironmentProfile(
2452 binding.id.clone(),
2453 ));
2454 }
2455 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2456 return Err(NodeServerError::EnvironmentProfileCapacity {
2457 max: MAX_NODE_ENVIRONMENT_PROFILES,
2458 });
2459 }
2460 for native_id in binding.native_profile_ids() {
2461 if profiles.values().any(|current| {
2462 current.native_profile_ids().any(|current_id| current_id == native_id)
2463 }) {
2464 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2465 native_id.to_string(),
2466 ));
2467 }
2468 }
2469
2470 let mut installed = Vec::with_capacity(native_profiles.len());
2471 for native_profile in native_profiles {
2472 let native_id = native_profile.id().clone();
2473 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2474 for installed_id in installed.into_iter().rev() {
2475 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2476 }
2477 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2478 }
2479 installed.push(native_id);
2480 }
2481 if let Some(materialization) = materialization {
2482 self.shared
2483 .environment_materialization_profiles
2484 .write()
2485 .unwrap_or_else(|poisoned| poisoned.into_inner())
2486 .insert(binding.id.clone(), materialization);
2487 }
2488 profiles.insert(binding.id.clone(), binding);
2489 Ok(())
2490 }
2491
2492 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2494 if self.shared.session_environment_materializer.is_none() {
2495 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2496 }
2497 let mut catalog = self
2498 .shared
2499 .bundle_catalog
2500 .write()
2501 .unwrap_or_else(|poisoned| poisoned.into_inner());
2502 catalog
2503 .insert_idempotent(bundle)
2504 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2505 Ok(())
2506 }
2507
2508 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2509 let shutdown = self.shutdown_handle();
2510 let run = self.run();
2511 tokio::pin!(run);
2512 tokio::select! {
2513 result = &mut run => result,
2514 signal = wait_for_ctrl_signal() => {
2515 signal?;
2516 let shutdown_result = shutdown.request_shutdown().await;
2517 let run_result = run.await;
2518 shutdown_result.and(run_result)
2519 }
2520 }
2521 }
2522
2523 pub async fn run(self) -> Result<(), NodeServerError> {
2524 let NodeServer {
2525 config,
2526 mut runtime,
2527 shared,
2528 events,
2529 state_path_lock: _state_path_lock,
2530 } = self;
2531 shared.reconcile_materializations();
2532 shared.reconcile_managed_worktrees().await;
2533 shared.retire_unavailable_session_records().await;
2534 shared.reconcile_context_pack_exports().await;
2535 {
2536 let restore = Arc::clone(&shared);
2537 tokio::spawn(async move {
2538 sleep(Duration::from_secs(3)).await;
2539 restore.restore_journalled_sessions().await;
2540 loop {
2541 restore.refresh_session_journal();
2542 if restore.shutdown.load(Ordering::Acquire) { break; }
2543 sleep(Duration::from_secs(10)).await;
2544 }
2545 });
2546 }
2547 let endpoint = config.endpoint.clone();
2551 let call_home = config.call_home;
2552 let call_home_identity = config.call_home_identity.clone();
2553 let api_listen = config.api_listen;
2554 let bridge_listen = config.bridge_listen;
2555 let bridge_underlay = config.bridge_underlay;
2556 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2557 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2558 let accept_shared = Arc::clone(&shared);
2559 let api_shared = Arc::clone(&shared);
2560 let bridge_shared = Arc::clone(&shared);
2561 let shutdown_shared = Arc::clone(&shared);
2562 let shutdown_timeout = Duration::from_millis(
2563 config.runtime.provider_shutdown_timeout_ms.max(1),
2564 );
2565 let result = {
2566 let runtime_loop = drive_runtime_until_shutdown(
2567 &mut runtime,
2568 events,
2569 Arc::clone(&shared),
2570 shutdown_timeout,
2571 );
2572 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2573 let api_loop = http_api::run(api_listen, api_shared);
2574 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2575 tokio::pin!(runtime_loop);
2576 tokio::pin!(accept_loop);
2577 tokio::pin!(api_loop);
2578 tokio::pin!(bridge_loop);
2579 tokio::select! {
2580 runtime_result = &mut runtime_loop => {
2581 let shutdown_result = shutdown_shared
2582 .begin_shutdown()
2583 .await
2584 .map_err(NodeServerError::ShutdownDispatch);
2585 let (accept_result, api_result, bridge_result) =
2586 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2587 match runtime_result {
2588 Err(error) => Err(error),
2589 Ok(()) => shutdown_result
2590 .and(accept_result)
2591 .and(api_result.map_err(NodeServerError::HttpApi))
2592 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2593 }
2594 }
2595 accept_result = &mut accept_loop => {
2596 let shutdown_result = shutdown_shared
2597 .begin_shutdown()
2598 .await
2599 .map_err(NodeServerError::ShutdownDispatch);
2600 let (runtime_result, api_result, bridge_result) =
2601 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2602 match accept_result {
2603 Err(error) => Err(error),
2604 Ok(()) => shutdown_result
2605 .and(runtime_result)
2606 .and(api_result.map_err(NodeServerError::HttpApi))
2607 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2608 }
2609 }
2610 api_result = &mut api_loop => {
2611 let shutdown_result = shutdown_shared
2612 .begin_shutdown()
2613 .await
2614 .map_err(NodeServerError::ShutdownDispatch);
2615 let (runtime_result, accept_result, bridge_result) =
2616 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2617 match api_result {
2618 Err(error) => Err(NodeServerError::HttpApi(error)),
2619 Ok(()) => shutdown_result
2620 .and(runtime_result)
2621 .and(accept_result)
2622 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2623 }
2624 }
2625 bridge_result = &mut bridge_loop => {
2626 let shutdown_result = shutdown_shared
2627 .begin_shutdown()
2628 .await
2629 .map_err(NodeServerError::ShutdownDispatch);
2630 let (runtime_result, accept_result, api_result) =
2631 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2632 match bridge_result {
2633 Err(error) => Err(NodeServerError::BridgeApi(error)),
2634 Ok(()) => shutdown_result
2635 .and(runtime_result)
2636 .and(accept_result)
2637 .and(api_result.map_err(NodeServerError::HttpApi)),
2638 }
2639 }
2640 }
2641 };
2642 if let Ok(mut guard) = shared.delivery_store.lock() {
2649 *guard = None;
2650 }
2651 if let Ok(mut guard) = shared.context_pack_store.lock() {
2652 *guard = None;
2653 }
2654 result
2655 }
2656}
2657
2658async fn drive_runtime_until_shutdown(
2659 runtime: &mut NativeRuntime,
2660 events: EventSubscription,
2661 shared: Arc<NodeShared>,
2662 shutdown_timeout: Duration,
2663) -> Result<(), NodeServerError> {
2664 let _ = shared
2668 .shell_efficiency_profile
2669 .set(runtime.shell_efficiency_profile());
2670 let mut shutdown_started = None;
2671 let mut last_retention_sweep = Instant::now();
2672 let mut last_provisional_reap = Instant::now();
2673 loop {
2674 let runtime_tick_start = Instant::now();
2681 let tick_result = runtime.tick().await;
2682 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2683
2684 let event_drain_start = Instant::now();
2685 for outcome in &tick_result.command_outcomes {
2693 if let Err(error) = &outcome.result {
2694 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2695 tracing::warn!(
2696 command_id = outcome.command_id.0,
2697 cause = %error,
2698 "control command rejected"
2699 );
2700 shared.record_rejected_command(
2701 outcome.command_id,
2702 error.to_string(),
2703 error.is_unsupported_transport(),
2704 );
2705 }
2706 }
2707 let mut events_drained: u64 = 0;
2708 while let Ok(event) = events.try_recv() {
2709 events_drained += 1;
2710 let clean_exit = matches!(
2711 event.event,
2712 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2713 );
2714 let instance_id = event.instance_id;
2715 let generation = event.generation;
2716 if clean_exit {
2728 if let Some((record_id, session)) =
2729 shared.managed_record_export_target_for_instance(instance_id, generation)
2730 {
2731 let export_shared = Arc::clone(&shared);
2732 tokio::spawn(async move {
2733 export_shared
2734 .reconcile_context_pack_export_for_record(&record_id, session)
2735 .await;
2736 });
2737 }
2738 }
2739 shared.publish_control(event);
2740 }
2741 let event_drain_elapsed = event_drain_start.elapsed();
2742
2743 let publish_terminal_frames_start = Instant::now();
2744 shared.publish_terminal_frames();
2745 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2746
2747 shared
2753 .native_session_gauge
2754 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2755 *shared
2756 .runtime_tick_profile
2757 .lock()
2758 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2759
2760 let remainder_start = Instant::now();
2761 if shared.shutdown.load(Ordering::Acquire) {
2762 let started = *shutdown_started.get_or_insert_with(Instant::now);
2763 let snapshot = shared.handle.snapshot();
2764 let sessions_settled = snapshot.sessions.iter().all(|session| {
2765 !matches!(
2766 session.status,
2767 gate4agent_types::SessionStatus::Starting
2768 | gate4agent_types::SessionStatus::Running
2769 | gate4agent_types::SessionStatus::Stopping
2770 )
2771 });
2772 if runtime.active_native_sessions() == 0 && sessions_settled {
2773 return Ok(());
2774 }
2775 if started.elapsed() >= shutdown_timeout {
2776 return Err(NodeServerError::ShutdownTimedOut {
2777 active_native_sessions: runtime.active_native_sessions(),
2778 });
2779 }
2780 }
2781 let remainder_elapsed = remainder_start.elapsed();
2782
2783 {
2784 let mut profile = shared
2785 .drive_loop_profile
2786 .lock()
2787 .unwrap_or_else(|poisoned| poisoned.into_inner());
2788 profile.record_runtime_tick(runtime_tick_elapsed);
2789 profile.record_event_drain(event_drain_elapsed);
2790 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2791 profile.record_remainder(remainder_elapsed);
2792 profile.note_iteration(Instant::now());
2793 }
2794
2795 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2807 if tick_result.observations_applied == 0 && events_drained == 0 {
2808 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2809 }
2810
2811 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2816 shared.retire_unavailable_session_records().await;
2817 last_retention_sweep = Instant::now();
2818 }
2819 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2821 for address in shared.take_due_provisional_spawns() {
2822 shared.reap_provisional_spawn(address).await;
2823 }
2824 last_provisional_reap = Instant::now();
2825 }
2826
2827 let hot = shared
2830 .pty_input_marks
2831 .lock()
2832 .unwrap_or_else(|poisoned| poisoned.into_inner())
2833 .values()
2834 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2835 if hot {
2836 sleep(Duration::from_millis(1)).await;
2837 } else {
2838 tokio::select! {
2839 _ = sleep(Duration::from_millis(10)) => {}
2840 _ = shared.pty_input_wake.notified() => {}
2841 }
2842 }
2843 }
2844}
2845
2846#[derive(Clone)]
2847pub struct NodeShutdownHandle {
2848 shared: Arc<NodeShared>,
2849}
2850
2851impl NodeShutdownHandle {
2852 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2853 self.shared
2854 .begin_shutdown()
2855 .await
2856 .map_err(NodeServerError::ShutdownDispatch)
2857 }
2858}
2859
2860async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2861 #[cfg(windows)]
2862 {
2863 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2864 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2865 return tokio::select! {
2866 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2867 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2868 };
2869 }
2870 #[cfg(unix)]
2871 {
2872 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2873 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2874 tokio::select! {
2875 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2876 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2877 }
2878 }
2879}
2880
2881fn shell_host_spec() -> AgentSpec {
2895 #[cfg(windows)]
2899 let (detect_command, program, fixed_args, process_name) = {
2900 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2901 r"C:\Windows\System32\cmd.exe".to_owned()
2902 });
2903 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2904 };
2905 #[cfg(not(windows))]
2906 let (detect_command, program, fixed_args, process_name) = (
2907 "bash".to_owned(),
2908 "/bin/bash".to_owned(),
2909 vec!["-i".to_owned()],
2910 "bash".to_owned(),
2911 );
2912 AgentSpec {
2913 id: AgentId::new("shell").expect("shell agent id"),
2914 revision: "hatchery:shell-pty-1".to_owned(),
2915 display_name: "Host shell".to_owned(),
2916 detection: DetectionSpec {
2917 command: detect_command,
2918 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2919 required_commands: Vec::new(),
2920 unsupported_platforms: Vec::new(),
2921 },
2922 launch: LaunchSpec {
2923 program,
2924 fixed_args,
2925 },
2926 expected_processes: vec![ProcessMatcher::Exact {
2927 name: process_name,
2928 }],
2929 prompt: PromptSpec {
2930 initial: InitialPromptMode::None,
2931 native_draft: None,
2932 },
2933 readiness: AgentReadinessSpec::default(),
2934 capabilities: AgentCapabilities {
2935 agent_commands: None,
2936 transports: AgentTransportCapabilities {
2937 pty: true,
2938 pty_adapter: None,
2939 pipe: None,
2940 acp: None,
2941 },
2942 adapters: AgentAdapterCapabilities::default(),
2943 },
2944 verification: SpecVerification::Gate4AgentVerified,
2945 }
2946}
2947
2948fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2949 let mut specs = vec![shell_host_spec()];
2950 specs.extend(
2951 builtin_registry()
2952 .iter()
2953 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2954 .cloned(),
2955 );
2956 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2957}
2958
2959fn provider_contract_manifest(
2960 catalog: &AgentRegistry,
2961) -> Result<
2962 (
2963 Vec<ProviderContractSupport>,
2964 Vec<ProviderAdapterContractSupport>,
2965 ),
2966 NodeServerError,
2967> {
2968 let mut provider_contracts = Vec::new();
2969 let mut provider_adapter_contracts = Vec::new();
2970 for spec in catalog.iter() {
2971 let provider = spec.id.clone();
2972 let revision = ProviderContractRevision::new(spec.revision.clone())
2973 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2974 provider_contracts.push(ProviderContractSupport {
2975 provider: provider.clone(),
2976 revision,
2977 });
2978 for (family, binding) in declared_adapter_bindings(spec)? {
2979 let revision = AdapterContractRevision::new(binding.revision.clone())
2980 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2981 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2982 provider: provider.clone(),
2983 family,
2984 adapter_id: binding.id.clone(),
2985 revision,
2986 });
2987 }
2988 }
2989 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2990 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2991 Ok((provider_contracts, provider_adapter_contracts))
2992}
2993
2994fn declared_adapter_bindings(
2995 spec: &AgentSpec,
2996) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2997 let mut bindings = Vec::new();
2998 push_declared_binding(
2999 &mut bindings,
3000 AdapterFamily::PtySemantic,
3001 spec.capabilities.transports.pty_adapter.as_ref(),
3002 )?;
3003 push_declared_binding(
3004 &mut bindings,
3005 AdapterFamily::Pipe,
3006 spec.capabilities.adapters.pty_sidecar.as_ref(),
3007 )?;
3008 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3009 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3010 gate4agent_types::PipeProtocol::SemanticNdjson
3011 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3012 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3013 });
3014 push_declared_binding(
3015 &mut bindings,
3016 AdapterFamily::Pipe,
3017 pipe_transport
3018 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3019 .map(|transport| &transport.adapter),
3020 )?;
3021 push_declared_binding(
3022 &mut bindings,
3023 AdapterFamily::Acp,
3024 spec.capabilities
3025 .transports
3026 .acp
3027 .as_ref()
3028 .map(|transport| &transport.adapter),
3029 )?;
3030 push_declared_binding(
3031 &mut bindings,
3032 AdapterFamily::Hook,
3033 spec.capabilities.adapters.hook.as_ref(),
3034 )?;
3035 push_declared_binding(
3036 &mut bindings,
3037 AdapterFamily::ManagedHook,
3038 spec.capabilities.adapters.managed_hook.as_ref(),
3039 )?;
3040 push_declared_binding(
3041 &mut bindings,
3042 AdapterFamily::OneShot,
3043 pipe_transport
3044 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3045 .map(|transport| &transport.adapter),
3046 )?;
3047 push_declared_binding(
3048 &mut bindings,
3049 AdapterFamily::OneShot,
3050 spec.capabilities.adapters.one_shot.as_ref(),
3051 )?;
3052 push_declared_binding(
3053 &mut bindings,
3054 AdapterFamily::History,
3055 spec.capabilities.adapters.history.as_ref(),
3056 )?;
3057 push_declared_binding(
3058 &mut bindings,
3059 AdapterFamily::Resume,
3060 spec.capabilities.adapters.resume.as_ref(),
3061 )?;
3062 push_declared_binding(
3063 &mut bindings,
3064 AdapterFamily::SessionOptions,
3065 spec.capabilities.adapters.session_options.as_ref(),
3066 )?;
3067 push_declared_binding(
3068 &mut bindings,
3069 AdapterFamily::CapabilityProbe,
3070 spec.capabilities.adapters.capability_probe.as_ref(),
3071 )?;
3072 Ok(bindings)
3073}
3074
3075fn push_declared_binding<'a>(
3076 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3077 family: AdapterFamily,
3078 binding: Option<&'a AdapterBinding>,
3079) -> Result<(), NodeServerError> {
3080 let Some(binding) = binding else {
3081 return Ok(());
3082 };
3083 if let Some((_, existing)) = bindings
3084 .iter()
3085 .find(|(existing_family, _)| *existing_family == family)
3086 {
3087 if *existing == binding {
3088 return Ok(());
3089 }
3090 return Err(NodeServerError::ProviderContractManifest(format!(
3091 "provider declares conflicting {family:?} adapter bindings"
3092 )));
3093 }
3094 bindings.push((family, binding));
3095 Ok(())
3096}
3097
3098fn merge_durable_state(
3099 configured: &[WorkspaceConfig],
3100 mut loaded: LoadedNodeState,
3101) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3102 let mut workspaces = configured
3103 .iter()
3104 .cloned()
3105 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3106 .collect::<BTreeMap<_, _>>();
3107 let mut roots = workspaces
3108 .values()
3109 .map(|workspace| {
3110 (
3111 platform::root_identity(&workspace.canonical_root),
3112 workspace.workspace_id.clone(),
3113 )
3114 })
3115 .collect::<BTreeMap<_, _>>();
3116 let mut warning = loaded
3117 .warning
3118 .take()
3119 .map(|message| session_registry::sanitized_persistence_summary(&message));
3120
3121 for (workspace_id, canonical_root) in loaded.workspaces {
3122 if let Some(existing) = workspaces.get(&workspace_id) {
3123 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3124 return Err(NodeServerError::DurableState(io::Error::new(
3125 io::ErrorKind::InvalidData,
3126 DURABLE_STATE_CONFLICT_ERROR,
3127 )));
3128 }
3129 continue;
3130 }
3131 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3132 return Err(NodeServerError::DurableState(io::Error::new(
3133 io::ErrorKind::InvalidData,
3134 DURABLE_STATE_CONFLICT_ERROR,
3135 )));
3136 }
3137 if !Path::new(&canonical_root).is_dir() {
3138 warning.get_or_insert_with(|| {
3139 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3140 });
3141 continue;
3142 }
3143 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3144 NodeServerError::DurableState(io::Error::new(
3145 io::ErrorKind::InvalidData,
3146 DURABLE_STATE_CONFLICT_ERROR,
3147 ))
3148 })?;
3149 roots.insert(
3150 platform::root_identity(&workspace.canonical_root),
3151 workspace.workspace_id.clone(),
3152 );
3153 workspaces.insert(workspace_id, workspace);
3154 }
3155
3156 for record in &mut loaded.records {
3157 match workspaces.get(&record.workspace_id) {
3158 Some(workspace)
3159 if platform::roots_equal(
3160 &workspace.canonical_root,
3161 windows_path_text(&record.canonical_root),
3162 ) => {}
3163 Some(_) => {
3164 return Err(NodeServerError::DurableState(io::Error::new(
3165 io::ErrorKind::InvalidData,
3166 DURABLE_STATE_CONFLICT_ERROR,
3167 )));
3168 }
3169 None => {
3170 record.state = ManagedSessionState::Unavailable;
3171 record.active_session = None;
3172 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3173 }
3174 }
3175 }
3176 Ok((workspaces.into_values().collect(), loaded.records, warning))
3177}
3178
3179struct RejectedCommandReason {
3182 message: String,
3183 unsupported_transport: bool,
3190 expires_at: Instant,
3191}
3192
3193struct NodeShared {
3194 handle: Gate4AgentHandle,
3195 access_token: String,
3196 node_id: NodeId,
3197 incarnation_id: NodeIncarnationId,
3198 started_at_unix_ms: u64,
3199 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3200 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3201 managed_worktree_profiles:
3202 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3203 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3204 enabled_providers: Vec<AgentId>,
3205 provider_runtime_statuses: ProviderRuntimeStatuses,
3206 provider_runtime_status_updates:
3207 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3208 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3209 provider_contracts: Vec<ProviderContractSupport>,
3210 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3211 spawn_profiles: SpawnProfileRegistry,
3212 environment_profiles:
3213 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3214 environment_materialization_profiles:
3215 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3216 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3224 #[cfg(feature = "dig2-station-probe")]
3228 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3229 bundle_catalog: RwLock<BundleCatalog>,
3230 delivery_store: Mutex<Option<DeliveryStore>>,
3231 context_pack_store: Mutex<Option<ContextPackStore>>,
3232 context_catalog: RwLock<ContextPackCatalog>,
3233 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3234 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3235 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3236 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3237 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3239 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3241 m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3243 m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3245 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3247 #[cfg(unix)]
3250 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3251 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3252 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3253 managed_spawn_replays:
3254 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3255 controller: Mutex<Option<ControllerLease>>,
3256 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3259 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3261 history: Mutex<NodeEventHistory>,
3262 event_tx: broadcast::Sender<NodeEventEnvelope>,
3263 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3264 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3272 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3281 terminal_frame_watermarks:
3282 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3283 pty_input_wake: tokio::sync::Notify,
3285 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3288 next_connection_id: AtomicU64,
3289 next_instance_id: AtomicU64,
3290 next_command_id: AtomicU64,
3291 shutdown: AtomicBool,
3292 shutdown_notify: Notify,
3293 preauth_slots: Arc<Semaphore>,
3294 authenticated_slots: Arc<Semaphore>,
3295 inspection_slots: Arc<Semaphore>,
3296 mutation_gate: AsyncMutex<()>,
3297 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3298 session_records: Mutex<SessionRecords>,
3299 state_path: Option<PathBuf>,
3300 persistence_error: RwLock<Option<String>>,
3301 state_transaction: Mutex<()>,
3302 input_settle_timeout_ms: u64,
3303 drive_loop_profile: Mutex<DriveLoopProfiler>,
3307 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3313 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3325 native_session_gauge: AtomicUsize,
3332 connection_loop_iterations: AtomicU64,
3343 connection_events_sent: AtomicU64,
3344 rejected_commands_total: AtomicU64,
3353 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3373 drive_loop_iterations_total: AtomicU64,
3374 drive_loop_iterations_idle: AtomicU64,
3375 session_record_retention: SessionRecordRetentionConfig,
3379 retired_records_total: AtomicU64,
3385 #[cfg(feature = "fixture")]
3386 fixture_semantic_hook_policy: bool,
3387 #[cfg(feature = "fixture")]
3388 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3389}
3390
3391impl Drop for NodeShared {
3392 fn drop(&mut self) {
3393 SessionLifecycleHook::on_shutdown(self);
3394 }
3395}
3396
3397#[derive(Clone, Debug, Eq, PartialEq)]
3398struct SessionBinding {
3399 workspace_id: WorkspaceId,
3400 generation: SessionGeneration,
3401 runtime_policy: ProviderRuntimePolicy,
3402 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3403 record_id: Option<SessionRecordId>,
3404 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3405 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3406 bundle: Option<ResolvedBundleReceipt>,
3407 context: Option<ResolvedContextPackReceipt>,
3408 materialization_id: Option<MaterializationId>,
3409}
3410
3411#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3412enum SpawnRecordPolicy {
3413 ProviderIdentityOnly,
3414 Always,
3415}
3416
3417struct NativeEnvironmentSelectionGuard {
3418 control: Option<NativeLaunchProfileControl>,
3419 instance_id: AgentInstanceId,
3420}
3421
3422struct NativeInstanceOverlayGuard {
3423 control: Option<NativeLaunchProfileControl>,
3424 instance_id: AgentInstanceId,
3425}
3426
3427struct NativeHarnessMcpOverlayGuard {
3428 control: NativeLaunchProfileControl,
3429 instance_id: AgentInstanceId,
3430}
3431
3432impl NativeHarnessMcpOverlayGuard {
3433 fn retain(self) { std::mem::forget(self); }
3434}
3435
3436impl Drop for NativeHarnessMcpOverlayGuard {
3437 fn drop(&mut self) {
3438 self.control
3439 .clear_native_mcp_server_launch_overlay(self.instance_id);
3440 }
3441}
3442
3443impl NativeInstanceOverlayGuard {
3444 fn retain(mut self) {
3445 self.control = None;
3446 }
3447}
3448
3449impl Drop for NativeInstanceOverlayGuard {
3450 fn drop(&mut self) {
3451 if let Some(control) = self.control.as_ref() {
3452 control.clear_native_instance_launch_overlay(self.instance_id);
3453 }
3454 }
3455}
3456
3457enum PreparedNativeLaunchOverlay {
3458 Environment(NativeLaunchEnvironmentOverlay),
3459 Instance(NativeInstanceLaunchOverlay),
3460}
3461
3462impl NativeEnvironmentSelectionGuard {
3463 fn retain(mut self) {
3464 self.control = None;
3465 }
3466}
3467
3468impl Drop for NativeEnvironmentSelectionGuard {
3469 fn drop(&mut self) {
3470 if let Some(control) = self.control.as_ref() {
3471 control.clear_native_launch_profile_selection(self.instance_id);
3472 }
3473 }
3474}
3475
3476struct SessionMaterializationGuard<'a> {
3477 shared: &'a NodeShared,
3478 id: Option<MaterializationId>,
3479 provider_home: Option<PathBuf>,
3482}
3483
3484impl SessionMaterializationGuard<'_> {
3485 fn id(&self) -> Option<&MaterializationId> {
3486 self.id.as_ref()
3487 }
3488
3489 fn provider_home(&self) -> Option<&Path> {
3490 self.provider_home.as_deref()
3491 }
3492
3493 fn retain(mut self) {
3494 self.id = None;
3495 }
3496}
3497
3498impl Drop for SessionMaterializationGuard<'_> {
3499 fn drop(&mut self) {
3500 if let Some(id) = self.id.take() {
3501 let _ = self.shared.cleanup_materialization(&id);
3502 }
3503 }
3504}
3505
3506
3507trait SessionLifecycleHook {
3510 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3512 fn on_session_ended(&self, address: &SessionAddress);
3514 fn on_shutdown(&self);
3516 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3518 fn cleanup_session_materialization(
3520 &self,
3521 address: &SessionAddress,
3522 binding: &SessionBinding,
3523 ) -> Result<(), NodeFailure>;
3524}
3525
3526impl SessionLifecycleHook for NodeShared {
3527 fn on_session_ended(&self, address: &SessionAddress) {
3528 self.retire_session_grok_leader(address.session.instance_id);
3529 if !self.shutdown.load(Ordering::Acquire) {
3530 if let Some(file) = self.session_resume_journal() {
3531 crate::pty_resume::remove(&file, address.session.instance_id.0);
3532 }
3533 }
3534 if let Some(accounts) = self.m4a_accounts.as_ref() {
3535 let instance = address.session.instance_id;
3536 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3537 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3538 accounts.remove_session_file(nick);
3539 }
3540 }
3541 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3542 registry.revoke_session(address);
3543 }
3544 if let Some(control) = self.native_launch_profile_control.as_ref() {
3545 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3546 }
3547 }
3548
3549 fn on_shutdown(&self) {
3550 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3551 .into_values()
3552 .collect();
3553 for socket in leaders {
3554 crate::grok_leader::retire(&socket);
3555 }
3556 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3557 let instances = registry.shutdown();
3558 if let Some(control) = self.native_launch_profile_control.as_ref() {
3559 for instance_id in instances {
3560 control.clear_native_mcp_server_launch_overlay(instance_id);
3561 }
3562 }
3563 }
3564 }
3565
3566 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3567 if let Some(control) = self.native_launch_profile_control.as_ref() {
3568 control.clear_native_mcp_server_launch_overlay(instance_id);
3569 }
3570 }
3571
3572 fn cleanup_session_materialization(
3573 &self,
3574 address: &SessionAddress,
3575 binding: &SessionBinding,
3576 ) -> Result<(), NodeFailure> {
3577 self.cleanup_session_owned_materialization(address, binding)
3578 }
3579
3580 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3581 #[cfg(feature = "dig2-station-probe")]
3584 {
3585 crate::dig2_station_lease::release_lease_for_holder(
3586 &self.browser_station_leases,
3587 address.session.instance_id,
3588 );
3589 }
3590 if let Some(control) = self.native_launch_profile_control.as_ref() {
3591 control.clear_native_mcp_server_launch_overlay(
3592 address.session.instance_id,
3593 );
3594 }
3595 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3596 registry.revoke_session(address);
3597 }
3598 if removed
3599 .is_some_and(|binding| {
3600 binding.environment_profile.is_some()
3601 || binding.bundle.is_some()
3602 || binding.context.is_some()
3603 })
3604 {
3605 if let Some(control) = self.native_launch_profile_control.as_ref() {
3606 control.clear_native_launch_profile_selection(
3607 address.session.instance_id,
3608 );
3609 control.clear_native_instance_launch_overlay(
3610 address.session.instance_id,
3611 );
3612 }
3613 }
3614 }
3615}
3616
3617struct SpawnPrepareRequest<'r> {
3618 instance_id: AgentInstanceId,
3619 address: &'r SessionAddress,
3620 provider: &'r AgentId,
3621 mode: SessionMode,
3622 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3623 bundle: Option<&'r ResolvedBundleReceipt>,
3624 context: Option<&'r ResolvedContextPackReceipt>,
3625 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3626 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3627 approval_level: ApprovalLevel,
3628 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3629 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3630}
3631
3632struct PreparedSpawn<'a> {
3636 instance_overlay: Option<NativeInstanceOverlayGuard>,
3637 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3638 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3639 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3640 #[cfg(feature = "dig2-station-probe")]
3641 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3642 materialization_id: Option<MaterializationId>,
3643}
3644
3645impl PreparedSpawn<'_> {
3646 fn retain_session_guards(&mut self) {
3648 if let Some(selection) = self.environment_selection.take() {
3649 selection.retain();
3650 }
3651 if let Some(overlay) = self.instance_overlay.take() {
3652 overlay.retain();
3653 }
3654 if let Some(guard) = self.materialization_guard.take() {
3655 guard.retain();
3656 }
3657 }
3658
3659 fn retain_launch_guards(&mut self) {
3661 if let Some(overlay) = self.harness_mcp_overlay.take() {
3662 overlay.retain();
3663 }
3664 #[cfg(feature = "dig2-station-probe")]
3665 if let Some(guard) = self.browser_station_lease_guard.take() {
3666 guard.retain();
3667 }
3668 }
3669}
3670
3671trait SpawnPreparer {
3675 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3676 fn select_environment(
3678 &self,
3679 instance_id: AgentInstanceId,
3680 provider: &AgentId,
3681 mode: SessionMode,
3682 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3683 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3684 fn install_launch_overlay(
3686 &self,
3687 instance_id: AgentInstanceId,
3688 overlay: PreparedNativeLaunchOverlay,
3689 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3690}
3691
3692impl NodeShared {
3693 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3696 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3697 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3698 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3699 if let Some(file) = self.session_resume_journal() {
3700 crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3701 }
3702 key
3703 }
3704
3705 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3706 let accounts = self.m4a_accounts.as_ref()?;
3707 let nick = self.m4a_nick_of(instance_id)?;
3708 match accounts.session_workdir(root, provider.as_str(), &nick) {
3709 Ok(dir) => dir,
3710 Err(error) => {
3711 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3712 None
3713 }
3714 }
3715 }
3716
3717 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3718 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3719 }
3720
3721 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3724 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3725 for record in records.records.values_mut() {
3726 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3727 record.display_name = nick.to_owned();
3728 }
3729 }
3730 }
3731
3732 fn m4a_session_overlay(
3735 &self,
3736 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3737 m4a_provider: &str,
3738 provider: &AgentId,
3739 instance_id: AgentInstanceId,
3740 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3741 session_key: &str,
3742 mode: SessionMode,
3743 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3744 let session_key = session_key.to_owned();
3745 if mode == SessionMode::Acp {
3746 return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3747 }
3748 Ok( match accounts.provision(m4a_provider, &session_key) {
3749 Ok(account) => {
3750 crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3751 let mut identity = account.env_mutations();
3752 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3753 let merged = match environment_overlay {
3754 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3755 .map(PreparedNativeLaunchOverlay::Instance),
3756 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3757 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3758 }
3759 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3760 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3761 }
3762 }
3763 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3764 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3765 let mut merged = merged;
3766 match accounts.write_session_file(&account) {
3767 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3768 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3769 Ok(args) if !args.is_empty() => {
3770 merged = match merged {
3771 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3772 .append_extra_args(args)
3773 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3774 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3775 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3776 .into_instance_with_extra_args(args)
3777 .map(PreparedNativeLaunchOverlay::Instance)
3778 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3779 };
3780 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3781 }
3782 Ok(_) => {}
3783 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3784 },
3785 }
3786 self.m4a_live
3787 .lock()
3788 .unwrap_or_else(|p| p.into_inner())
3789 .insert(account.nick.clone(), (instance_id, provider.clone()));
3790 self.name_record_after_nick(instance_id, &account.nick);
3791 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3792 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3794 Some(merged)
3795 }
3796 Err(error) => {
3797 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3798 environment_overlay
3799 }
3800 })
3801 }
3802}
3803
3804impl NodeShared {
3805 fn m4a_acp_mcp_overlay(
3808 &self,
3809 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3810 m4a_provider: &str,
3811 provider: &AgentId,
3812 instance_id: AgentInstanceId,
3813 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3814 session_key: &str,
3815 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3816 let Some(control) = self.native_launch_profile_control.as_ref() else {
3817 tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3818 return Ok(environment_overlay);
3819 };
3820 let account = match accounts.provision(m4a_provider, session_key) {
3821 Ok(a) => a,
3822 Err(error) => {
3823 tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3824 return Ok(environment_overlay);
3825 }
3826 };
3827 let session_file = match accounts.write_session_file(&account) {
3828 Ok(p) => p,
3829 Err(error) => {
3830 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3831 return Ok(environment_overlay);
3832 }
3833 };
3834 let env_entries: Vec<(OsString, OsString)> = account
3835 .env_mutations()
3836 .into_iter()
3837 .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3838 .filter_map(|m| m.value.map(|v| (m.key, v)))
3839 .collect();
3840 let args: Vec<OsString> = vec![
3841 "--session-file".into(),
3842 session_file.into_os_string(),
3843 "mcp".into(),
3844 "--provider".into(),
3845 provider.as_str().into(),
3846 ];
3847 let overlay = NativeMcpServerLaunchOverlay::new(
3848 provider.clone(),
3849 crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3850 accounts.bin().as_os_str().to_os_string(),
3851 args,
3852 env_entries,
3853 Vec::new(),
3854 );
3855 match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3856 Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3857 Err(error) => {
3858 tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3859 return Ok(environment_overlay);
3860 }
3861 }
3862 self.m4a_live
3863 .lock()
3864 .unwrap_or_else(|p| p.into_inner())
3865 .insert(account.nick.clone(), (instance_id, provider.clone()));
3866 self.name_record_after_nick(instance_id, &account.nick);
3867 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3868 Ok(environment_overlay)
3869 }
3870}
3871
3872impl SpawnPreparer for NodeShared {
3873 fn select_environment(
3874 &self,
3875 instance_id: AgentInstanceId,
3876 provider: &AgentId,
3877 mode: SessionMode,
3878 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3879 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3880 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3881 }
3882
3883 fn install_launch_overlay(
3884 &self,
3885 instance_id: AgentInstanceId,
3886 overlay: PreparedNativeLaunchOverlay,
3887 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3888 self.install_prepared_launch_overlay(instance_id, overlay)
3889 }
3890
3891 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3892 let SpawnPrepareRequest {
3893 instance_id,
3894 address,
3895 provider,
3896 mode,
3897 environment_profile,
3898 bundle,
3899 context,
3900 managed_authority,
3901 harness_mcp,
3902 approval_level,
3903 network_allowlist,
3904 browser_profile_id,
3905 } = request;
3906 #[cfg(feature = "dig2-station-probe")]
3911 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3912 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3913 match acquire_lease(
3914 &self.browser_station_leases,
3915 profile_id,
3916 instance_id,
3917 unix_time_ms(),
3918 ) {
3919 Ok(guard) => Some(guard),
3920 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3921 return Err(failure(
3922 NodeFailureCode::BrowserStationProfileBusy,
3923 "dig2browser station profile is already leased by another session",
3924 ));
3925 }
3926 Err(BrowserStationLeaseError::CapacityExceeded) => {
3927 return Err(failure(
3928 NodeFailureCode::BrowserStationProfileBusy,
3929 "dig2browser station profile lease capacity exhausted",
3930 ));
3931 }
3932 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3933 return Err(failure(
3934 NodeFailureCode::BrowserStationProfileBusy,
3935 "session already holds a dig2browser station profile lease",
3936 ));
3937 }
3938 }
3939 } else {
3940 None
3941 };
3942 #[cfg(not(feature = "dig2-station-probe"))]
3943 let _ = browser_profile_id;
3944 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3945 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3954 {
3955 return Err(failure(
3956 NodeFailureCode::BindingMismatch,
3957 "harness MCP reservation does not match the exact PTY/ACP provider",
3958 ));
3959 }
3960 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3961 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3962 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3963 })?;
3964 let launch = prepared.launch();
3969 let mut env_entries = vec![
3970 (
3971 OsString::from(launch.endpoint_env.as_str()),
3972 prepared.endpoint().as_os_str().to_os_string(),
3973 ),
3974 (
3975 OsString::from(launch.token_env.as_str()),
3976 prepared.token().expose().into(),
3977 ),
3978 (
3979 OsString::from(launch.program_env.as_str()),
3980 prepared.helper_program().as_os_str().to_os_string(),
3981 ),
3982 ];
3983 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3984 env_entries.push(trace_entry);
3985 }
3986 if let Some(door) = crate::door::door_listen_addr() {
3987 env_entries.push((
3988 OsString::from("HATCHERY_NODE_DOOR"),
3989 OsString::from(door.to_string()),
3990 ));
3991 let session_key = format!(
3995 "pty-{}-{}-{}",
3996 self.incarnation_id,
3997 address.workspace_id.as_str(),
3998 address.session.instance_id.0
3999 );
4000 env_entries.push((
4001 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4002 OsString::from(session_key),
4003 ));
4004 }
4005 let overlay = NativeMcpServerLaunchOverlay::new(
4006 provider.clone(),
4007 launch.server_name.as_str(),
4008 prepared.helper_program().as_os_str().to_os_string(),
4009 launch.args.iter().map(OsString::from).collect(),
4010 env_entries,
4011 launch.scrub_env.iter().map(OsString::from).collect(),
4012 ).map_err(|_| failure(
4013 NodeFailureCode::HarnessMcpUnavailable,
4014 "harness MCP launch overlay is unavailable",
4015 ))?;
4016 control
4017 .install_native_mcp_server_launch_overlay(instance_id, overlay)
4018 .map_err(|_| failure(
4019 NodeFailureCode::HarnessMcpUnavailable,
4020 "harness MCP launch overlay could not be installed",
4021 ))?;
4022 tracing::info!(
4028 node_id = %self.node_id,
4029 instance_id = ?instance_id,
4030 provider = %provider,
4031 mode = ?mode,
4032 "harness MCP overlay installed for spawn",
4033 );
4034 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4035 } else {
4036 None
4037 };
4038 let prepared_materialization = self.prepare_session_materialization(
4039 &address,
4040 &provider,
4041 mode,
4042 environment_profile,
4043 bundle,
4044 context,
4045 managed_authority.cloned(),
4046 )?;
4047 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4048 Some((overlay, guard)) => (Some(overlay), Some(guard)),
4049 None => (None, None),
4050 };
4051 let materialization_id = materialization_guard
4052 .as_ref()
4053 .and_then(SessionMaterializationGuard::id)
4054 .cloned();
4055 let mut environment_selection = SpawnPreparer::select_environment(self,
4056 instance_id,
4057 &provider,
4058 mode,
4059 environment_profile,
4060 )?;
4061 let provider_home = materialization_guard
4064 .as_ref()
4065 .and_then(SessionMaterializationGuard::provider_home);
4066 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4067 &provider,
4068 mode,
4069 approval_level,
4070 network_allowlist,
4071 provider_home,
4072 )?;
4073 let mut network_overlay_args = network_overlay_args;
4074 {
4076 let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4077 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4078 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4079 homes.extend(std::env::split_paths(&extra));
4080 }
4081 if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4082 crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4083 network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4084 }
4085 if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4086 crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4087 }
4088 if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4090 if let Some(file) = self.session_resume_journal() {
4091 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4092 instance_id: instance_id.0,
4093 provider: provider.as_str().to_owned(),
4094 workspace_id: address.workspace_id.as_str().to_owned(),
4095 provider_session_id: String::new(),
4096 session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4097 .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4098 .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4099 cwd: String::new(),
4100 acp: mode == SessionMode::Acp,
4101 options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4102 cols: 0,
4103 rows: 0,
4104 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4105 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4106 });
4107 if let Some(dir) = file.parent() {
4108 crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4109 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4110 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4111 provider: Some(provider.as_str().to_owned()),
4112 ..crate::session_meta::Meta::default()
4113 });
4114 crate::session_meta::revive(dir, instance_id.0);
4115 }
4116 tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4117 }
4118 }
4119 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4120 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4121 }
4122 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4123 (prepared, args) if args.is_empty() => prepared,
4124 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4125 overlay.append_extra_args(args).map_err(|_| {
4126 failure(
4127 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4128 "network allowlist overlay could not merge with instance launch args",
4129 )
4130 })?;
4131 Some(PreparedNativeLaunchOverlay::Instance(overlay))
4132 }
4133 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4134 Some(PreparedNativeLaunchOverlay::Instance(
4135 overlay
4136 .into_instance_with_extra_args(args)
4137 .map_err(|_| {
4138 failure(
4139 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4140 "network allowlist overlay could not merge with environment overlay",
4141 )
4142 })?,
4143 ))
4144 }
4145 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4146 NativeInstanceLaunchOverlay::new(
4147 provider.clone(),
4148 match mode {
4149 SessionMode::Pty => TransportKind::Pty,
4150 SessionMode::Acp => TransportKind::Acp,
4151 SessionMode::Inline => TransportKind::Pipe,
4152 },
4153 Vec::new(),
4154 args,
4155 )
4156 .map_err(|_| {
4157 failure(
4158 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4159 "network allowlist overlay is invalid for this transport",
4160 )
4161 })?,
4162 )),
4163 };
4164 #[cfg(unix)]
4167 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4169 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4170 });
4171 #[cfg(unix)]
4172 let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4175 #[cfg(unix)]
4176 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4177 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4178 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4179 let uid = unsafe { libc_geteuid() };
4180 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4181 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4182 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4183 Some(vec![
4184 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4185 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4186 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4187 ])
4188 }
4189 _ => None,
4190 };
4191 #[cfg(not(unix))]
4192 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4193 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4194 (SessionMode::Pty, _, _, Some(identity)) => {
4195 let merged = match environment_overlay {
4196 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4197 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4198 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4199 }
4200 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4201 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4202 }
4203 }
4204 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4205 Some(merged)
4206 }
4207 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4208 let session_key = self.m4a_session_key(instance_id);
4209 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4210 }
4211 (SessionMode::Pty, Some(_), None, _) => {
4212 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4213 environment_overlay
4214 }
4215 _ => environment_overlay,
4216 };
4217 let mut instance_overlay = environment_overlay
4218 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4219 .transpose()?
4220 .flatten();
4221 Ok(PreparedSpawn {
4222 instance_overlay,
4223 environment_selection,
4224 materialization_guard,
4225 harness_mcp_overlay,
4226 #[cfg(feature = "dig2-station-probe")]
4227 browser_station_lease_guard,
4228 materialization_id,
4229 })
4230 }
4231}
4232
4233struct SessionRecords {
4234 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4235}
4236
4237struct SpawnIdempotencyCache {
4238 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4239}
4240
4241struct SpawnIdempotencyEntry {
4242 value: SpawnIdempotencyValue,
4243 expires_at: Instant,
4244}
4245
4246enum SpawnIdempotencyValue {
4247 Standard {
4248 spec: SpawnSpec,
4249 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4250 },
4251 Managed {
4252 request: ManagedWorktreeSpawnRequest,
4253 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4254 },
4255 HarnessMcp,
4256}
4257
4258enum DurableManagedSpawnReplayDecision {
4259 Reserved,
4260 PendingLinked,
4261 Committed(ManagedWorktreeSpawnReceipt),
4262}
4263
4264impl SpawnIdempotencyValue {
4265 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4266 match self {
4267 Self::Standard { result, .. } => result
4268 .as_ref()
4269 .ok()
4270 .and_then(|receipt| receipt.context.as_ref()),
4271 Self::Managed { result, .. } => result
4272 .as_ref()
4273 .ok()
4274 .and_then(|receipt| receipt.spawn.context.as_ref()),
4275 Self::HarnessMcp => None,
4276 }
4277 .is_some_and(|receipt| &receipt.id == context_id)
4278 }
4279}
4280
4281#[derive(Clone, Copy)]
4282struct ControllerLease {
4283 connection_id: u64,
4284 expires_at: Instant,
4285}
4286
4287struct NodeEventHistory {
4288 last_sequence: u64,
4289 replay_floor_sequence: u64,
4290 events: VecDeque<NodeEventEnvelope>,
4291 record_providers: BTreeMap<SessionRecordId, AgentId>,
4292 removed_record_providers: BTreeMap<u64, AgentId>,
4293}
4294
4295impl NodeEventHistory {
4296 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4297 Self {
4298 last_sequence: 0,
4299 replay_floor_sequence: 1,
4300 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4301 record_providers,
4302 removed_record_providers: BTreeMap::new(),
4303 }
4304 }
4305}
4306
4307impl NodeShared {
4308 fn session_resume_journal(&self) -> Option<PathBuf> {
4312 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4313 }
4314
4315 async fn restore_journalled_sessions(&self) {
4318 let Some(file) = self.session_resume_journal() else { return };
4319 if !crate::pty_resume::enabled() { return; }
4320 for e in crate::pty_resume::load(&file) {
4321 crate::pty_resume::remove(&file, e.instance_id);
4322 if !e.session_key.is_empty() {
4324 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4325 }
4326 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4327 if e.acp {
4328 let record_id = {
4329 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4330 records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4331 };
4332 let cur = self.next_instance_id.load(Ordering::Acquire);
4333 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4334 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4335 let resumed = match record_id {
4336 Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4337 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4338 };
4339 let r = match resumed {
4341 Ok(a) => Ok(a),
4342 Err(err) => {
4343 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4344 self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4345 SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4346 }
4347 };
4348 match r {
4349 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4350 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4351 }
4352 continue;
4353 }
4354 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4355 let cur = self.next_instance_id.load(Ordering::Acquire);
4357 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4358 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4359 match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4360 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4361 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4362 }
4363 continue;
4364 }
4365 let folder = std::path::Path::new(&e.cwd);
4366 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4367 let name = match (in_session_dir, folder.file_name()) {
4368 (true, Some(n)) => n.to_string_lossy().into_owned(),
4369 _ => format!("{} #{}", e.provider, e.instance_id),
4370 };
4371 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4372 let record = match self.index_provider_session(ws, provider, identity, name) {
4373 Ok(r) => r,
4374 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4375 };
4376 let cur = self.next_instance_id.load(Ordering::Acquire);
4377 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4378 let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4379 let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4380 match r {
4381 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4382 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4383 }
4384 }
4385 }
4386
4387 fn refresh_session_journal(&self) {
4388 let Some(file) = self.session_resume_journal() else { return };
4389 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4390 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4391 crate::pty_resume::refresh(&file, &homes);
4392 let ids: Vec<(u64, String)> = {
4393 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4394 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4395 };
4396 for (inst, sid) in ids {
4397 crate::pty_resume::set_session_id(&file, inst, &sid);
4398 }
4399 }
4400
4401 fn session_grok_leader_socket(
4402 &self,
4403 provider: &AgentId,
4404 mode: SessionMode,
4405 instance_id: AgentInstanceId,
4406 ) -> Option<std::path::PathBuf> {
4407 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4408 return None;
4409 }
4410 let root = self
4411 .m4a_accounts
4412 .as_ref()
4413 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4414 .unwrap_or_else(std::env::temp_dir);
4415 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4416 if let Some(previous) = self
4417 .grok_leaders
4418 .lock()
4419 .unwrap_or_else(|p| p.into_inner())
4420 .insert(instance_id, socket.clone())
4421 {
4422 crate::grok_leader::retire(&previous);
4423 }
4424 Some(socket)
4425 }
4426
4427 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4429 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4430 if let Some(socket) = socket {
4431 crate::grok_leader::retire(&socket);
4432 }
4433 }
4434
4435 fn new_with_incarnation(
4436 handle: Gate4AgentHandle,
4437 access_token: String,
4438 node_id: NodeId,
4439 incarnation_id: NodeIncarnationId,
4440 workspaces: Vec<WorkspaceConfig>,
4441 enabled_providers: Vec<AgentId>,
4442 provider_runtime_statuses: ProviderRuntimeStatuses,
4443 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4444 provider_contracts: Vec<ProviderContractSupport>,
4445 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4446 spawn_profiles: SpawnProfileRegistry,
4447 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4448 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4449 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4450 state_path: Option<PathBuf>,
4451 records: Vec<ManagedSessionRecord>,
4452 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4453 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4454 materializations: Vec<MaterializationOwnershipRecord>,
4455 persistence_warning: Option<String>,
4456 input_settle_timeout_ms: u64,
4457 ) -> Self {
4458 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4459 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4460 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4461 let (harness_mcp_proxy_event_tx, _) =
4462 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4463 let record_providers = records
4464 .iter()
4465 .map(|record| (record.record_id.clone(), record.provider.clone()))
4466 .collect();
4467 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4468 .iter()
4469 .map(|workspace| {
4470 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4471 })
4472 .collect();
4473 for lease in &managed_worktrees {
4474 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4475 }
4476 let managed_worktree_profiles = workspaces.iter()
4477 .map(|workspace| (
4478 workspace.workspace_id.clone(),
4479 workspace.managed_worktree_profiles.clone(),
4480 ))
4481 .collect();
4482 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4483 managed_worktrees,
4484 managed_worktree_tombstones,
4485 ).expect("durable managed worktree registry was validated while loading");
4486 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4487 managed_worktrees.reattach_record_holders(
4488 &managed_worktree_record_holders(&records),
4489 unix_time_ms(),
4490 );
4491 Self {
4492 handle,
4493 access_token,
4494 node_id,
4495 incarnation_id,
4496 started_at_unix_ms: std::time::SystemTime::now()
4497 .duration_since(std::time::UNIX_EPOCH)
4498 .unwrap_or_default()
4499 .as_millis()
4500 .min(u64::MAX as u128) as u64,
4501 workspaces: RwLock::new(
4502 workspaces
4503 .into_iter()
4504 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4505 .collect(),
4506 ),
4507 worktree_service_modes,
4508 managed_worktree_profiles,
4509 managed_worktrees: Mutex::new(managed_worktrees),
4510 enabled_providers,
4511 provider_runtime_statuses,
4512 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4513 provider_runtime_monitor,
4514 provider_contracts,
4515 provider_adapter_contracts,
4516 spawn_profiles,
4517 environment_profiles: RwLock::new(BTreeMap::new()),
4518 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4519 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4520 #[cfg(feature = "dig2-station-probe")]
4521 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4522 bundle_catalog: RwLock::new(BundleCatalog::default()),
4523 delivery_store: Mutex::new(None),
4524 context_pack_store: Mutex::new(None),
4525 context_catalog: RwLock::new(ContextPackCatalog::default()),
4526 native_launch_profile_control,
4527 harness_mcp_registry: None,
4528 native_session_catalog,
4529 session_environment_materializer,
4530 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4531 m4a_live: Mutex::new(BTreeMap::new()),
4532 m4a_keys: Mutex::new(BTreeMap::new()),
4533 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4534 grok_leaders: Mutex::new(BTreeMap::new()),
4535 #[cfg(unix)]
4536 #[cfg(unix)]
4537 signer_proxy: std::sync::OnceLock::new(),
4538 materializations: Mutex::new(
4539 materializations
4540 .into_iter()
4541 .map(|record| (record.id().clone(), record))
4542 .collect(),
4543 ),
4544 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4545 entries: BTreeMap::new(),
4546 }),
4547 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4548 controller: Mutex::new(None),
4549 provisional_spawns: Mutex::new(BTreeMap::new()),
4550 pending_acp_options: Mutex::new(BTreeMap::new()),
4551 history: Mutex::new(NodeEventHistory::new(record_providers)),
4552 event_tx,
4553 terminal_event_tx,
4554 agent_stream_event_tx,
4555 harness_mcp_proxy_event_tx,
4556 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4557 pty_input_wake: tokio::sync::Notify::new(),
4558 pty_input_marks: Mutex::new(BTreeMap::new()),
4559 next_connection_id: AtomicU64::new(1),
4560 next_instance_id: AtomicU64::new(1),
4561 next_command_id: AtomicU64::new(1),
4562 shutdown: AtomicBool::new(false),
4563 shutdown_notify: Notify::new(),
4564 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4565 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4566 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4567 mutation_gate: AsyncMutex::new(()),
4568 session_bindings: Mutex::new(BTreeMap::new()),
4569 session_records: Mutex::new(SessionRecords {
4570 records: records
4571 .into_iter()
4572 .map(|record| (record.record_id.clone(), record))
4573 .collect(),
4574 }),
4575 state_path,
4576 persistence_error: RwLock::new(
4577 persistence_warning
4578 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4579 ),
4580 state_transaction: Mutex::new(()),
4581 input_settle_timeout_ms,
4582 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4583 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4584 shell_efficiency_profile: OnceLock::new(),
4585 native_session_gauge: AtomicUsize::new(0),
4586 connection_loop_iterations: AtomicU64::new(0),
4587 connection_events_sent: AtomicU64::new(0),
4588 rejected_commands_total: AtomicU64::new(0),
4589 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4590 drive_loop_iterations_total: AtomicU64::new(0),
4591 drive_loop_iterations_idle: AtomicU64::new(0),
4592 session_record_retention: SessionRecordRetentionConfig::default(),
4593 retired_records_total: AtomicU64::new(0),
4594 #[cfg(feature = "fixture")]
4595 fixture_semantic_hook_policy: false,
4596 #[cfg(feature = "fixture")]
4597 fixture_spawn_managed_worktree_v2_failure_probe:
4598 SpawnManagedWorktreeV2FailureProbe::default(),
4599 }
4600 }
4601
4602 #[cfg(test)]
4603 fn new(
4604 handle: Gate4AgentHandle,
4605 access_token: String,
4606 node_id: NodeId,
4607 workspaces: Vec<WorkspaceConfig>,
4608 enabled_providers: Vec<AgentId>,
4609 ) -> Self {
4610 Self::new_with_incarnation(
4611 handle,
4612 access_token,
4613 node_id,
4614 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4615 workspaces,
4616 enabled_providers,
4617 ProviderRuntimeStatuses::default(),
4618 None,
4619 Vec::new(),
4620 Vec::new(),
4621 SpawnProfileRegistry::default(),
4622 None,
4623 None,
4624 None,
4625 None,
4626 Vec::new(),
4627 Vec::new(),
4628 Vec::new(),
4629 Vec::new(),
4630 None,
4631 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4632 )
4633 }
4634
4635 fn persistence_error(&self) -> Option<String> {
4636 self.persistence_error
4637 .read()
4638 .unwrap_or_else(|poisoned| poisoned.into_inner())
4639 .clone()
4640 }
4641
4642 async fn admit_provider_runtime(
4643 &self,
4644 provider: &AgentId,
4645 requirement: ProviderRuntimeRequirement,
4646 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4647 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4648 let pipe_transport = monitor.supports_pipe_transport(provider);
4649 let acp_transport = monitor.supports_acp_transport(provider);
4650 let provider = provider.clone();
4651 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4652 let (status, admission) = timeout(
4653 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4654 refresh,
4655 )
4656 .await
4657 .map_err(|_| failure(
4658 NodeFailureCode::BackendBusy,
4659 "provider runtime probe exceeded its bounded deadline",
4660 ))?
4661 .map_err(|_| failure(
4662 NodeFailureCode::BackendOperationFailed,
4663 "provider runtime probe task failed",
4664 ))?;
4665 if let Some(status) = status {
4666 self
4667 .provider_runtime_status_updates
4668 .write()
4669 .unwrap_or_else(|poisoned| poisoned.into_inner())
4670 .insert(status.provider().clone(), status);
4671 }
4672 admission.and_then(|policy| {
4673 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4674 })
4675 } else {
4676 crate::provider_runtime::admit_status(
4682 &self.provider_runtime_statuses,
4683 provider,
4684 false,
4685 false,
4686 requirement,
4687 )
4688 };
4689 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4690 admission.map_err(|error| match error {
4691 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4692 NodeFailureCode::BackendOperationFailed,
4693 "provider launcher is unavailable",
4694 ),
4695 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4696 NodeFailureCode::UnsupportedCapability,
4697 "provider semantic capability is not verified",
4698 ),
4699 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4700 NodeFailureCode::BackendBusy,
4701 "provider runtime probe is already in progress",
4702 ),
4703 })
4704 }
4705
4706 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4712 match self.provider_runtime_monitor.as_deref() {
4713 Some(monitor) => monitor.supports_pipe_transport(provider),
4714 None => false,
4715 }
4716 }
4717
4718 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4724 match self.provider_runtime_monitor.as_deref() {
4725 Some(monitor) => monitor.supports_acp_transport(provider),
4726 None => false,
4727 }
4728 }
4729
4730 fn admit_declared_hook_semantics(
4746 &self,
4747 provider: &AgentId,
4748 policy: ProviderRuntimePolicy,
4749 ) -> ProviderRuntimePolicy {
4750 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4751 return policy;
4752 }
4753 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4754 contract.provider == *provider && contract.family == AdapterFamily::Hook
4755 });
4756 if !declares_hook {
4757 return policy;
4758 }
4759 ProviderRuntimePolicy::new(
4760 policy.raw_pty_lifecycle,
4761 policy.semantic_readiness,
4762 policy.structured_prompt,
4763 policy.provider_session_identity,
4764 policy.semantic_resume,
4765 true,
4766 )
4767 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4768 }
4769
4770 fn resolve_network_allowlist_overlay_args(
4785 &self,
4786 provider: &AgentId,
4787 mode: SessionMode,
4788 approval_level: ApprovalLevel,
4789 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4790 provider_home: Option<&Path>,
4791 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4792 let Some(allowlist_id) = network_allowlist else {
4793 return Ok(Vec::new());
4794 };
4795 let catalog = self
4796 .network_allowlist_catalog
4797 .read()
4798 .unwrap_or_else(|poisoned| poisoned.into_inner());
4799 let Some(entry) = catalog.get(allowlist_id) else {
4800 return Err(failure(
4801 NodeFailureCode::UnknownNetworkAllowlist,
4802 "spawn network allowlist is unavailable on this node",
4803 ));
4804 };
4805
4806 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4808 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4809 |message| {
4810 failure(
4811 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4812 &message,
4813 )
4814 },
4815 )?;
4816 if !matches!(mode, SessionMode::Pty) {
4817 return Err(failure(
4818 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4819 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4820 ));
4821 }
4822 let Some(home) = provider_home else {
4823 return Err(failure(
4824 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4825 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4826 ));
4827 };
4828 let domains =
4829 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4830 if domains.is_empty() {
4831 return Err(failure(
4832 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4833 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4834 ));
4835 }
4836 let settings_path = home.join(
4837 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4838 );
4839 let json =
4840 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4841 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4842 |_| {
4843 failure(
4844 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4845 "Claude station network settings sidecar could not be written under ProviderHome",
4846 )
4847 },
4848 )?;
4849 let overlay =
4850 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4851 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4852 }
4853
4854 let Some(native) = entry.provider_native.as_ref() else {
4855 return Ok(Vec::new());
4856 };
4857 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4858 provider.as_str(),
4859 approval_level,
4860 native,
4861 )
4862 .map_err(|message| {
4863 failure(
4864 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4865 &message,
4866 )
4867 })?;
4868 if overlay.is_empty() {
4869 return Ok(Vec::new());
4870 }
4871 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4872 return Err(failure(
4873 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4874 "Codex network_access -c overlay requires PTY or ACP transport",
4875 ));
4876 }
4877 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4878 }
4879
4880 fn resolve_codex_network_access_overlay_args(
4882 &self,
4883 provider: &AgentId,
4884 mode: SessionMode,
4885 approval_level: ApprovalLevel,
4886 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4887 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4888 self.resolve_network_allowlist_overlay_args(
4889 provider,
4890 mode,
4891 approval_level,
4892 network_allowlist,
4893 None,
4894 )
4895 }
4896
4897 fn resolve_environment_profile(
4898 &self,
4899 resolved: &ResolvedSpawnSpec,
4900 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4901 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4907 let catalog = self
4908 .network_allowlist_catalog
4909 .read()
4910 .unwrap_or_else(|poisoned| poisoned.into_inner());
4911 let Some(entry) = catalog.get(allowlist_id) else {
4912 return Err(failure(
4913 NodeFailureCode::UnknownNetworkAllowlist,
4914 "spawn network allowlist is unavailable on this node",
4915 ));
4916 };
4917 if let Some(native) = entry.provider_native.as_ref() {
4920 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4921 resolved.provider.as_str(),
4922 resolved.approval_level,
4923 native,
4924 ) {
4925 return Err(failure(
4926 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4927 &message,
4928 ));
4929 }
4930 }
4931 }
4932 #[cfg(feature = "dig2-station-probe")]
4941 if resolved.browser_profile_id.is_some() {
4942 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4943 match probe_station_reachable() {
4944 Ok(()) => {}
4945 Err(StationProbeError::UnsupportedPlatform)
4946 | Err(StationProbeError::InvalidPipeSuffix) => {
4947 return Err(failure(
4948 NodeFailureCode::BrowserStationProbeUnavailable,
4949 "dig2browser station probe unavailable on this platform",
4950 ));
4951 }
4952 Err(StationProbeError::Unreachable) => {
4953 return Err(failure(
4954 NodeFailureCode::BrowserStationUnreachable,
4955 "dig2browser station named-pipe is unreachable",
4956 ));
4957 }
4958 }
4959 }
4960 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4961 return Ok(None);
4962 };
4963 let profiles = self
4964 .environment_profiles
4965 .read()
4966 .unwrap_or_else(|poisoned| poisoned.into_inner());
4967 let binding = profiles.get(profile_id).ok_or_else(|| {
4968 failure(
4969 NodeFailureCode::UnknownEnvironmentProfile,
4970 "spawn environment profile is unavailable on this node",
4971 )
4972 })?;
4973 if binding.provider != resolved.provider
4974 || binding.native_profile_id(resolved.mode).is_none()
4975 {
4976 return Err(failure(
4977 NodeFailureCode::EnvironmentProfileBindingMismatch,
4978 "spawn environment profile does not match the provider and mode",
4979 ));
4980 }
4981 Ok(Some(ResolvedEnvironmentProfileReceipt {
4985 profile_id: binding.id.clone(),
4986 profile_revision: binding.revision.clone(),
4987 network_allowlist: resolved.network_allowlist.clone(),
4988 browser_profile_id: resolved.browser_profile_id.clone(),
4989 }))
4990 }
4991
4992 fn resolve_bundle(
4993 &self,
4994 resolved: &ResolvedSpawnSpec,
4995 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4996 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4997 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4998 return Ok(None);
4999 };
5000 let catalog = self.bundle_catalog
5001 .read()
5002 .unwrap_or_else(|poisoned| poisoned.into_inner());
5003 let bundle = catalog.get(bundle_id).ok_or_else(|| {
5004 failure(
5005 NodeFailureCode::UnknownBundle,
5006 "spawn bundle is unavailable on this node",
5007 )
5008 })?;
5009 self.bundle_layout(
5010 &resolved.provider,
5011 resolved.mode,
5012 environment_profile,
5013 bundle,
5014 )?;
5015 Ok(Some(bundle.receipt()))
5016 }
5017
5018 fn begin_delivery_stage(
5019 &self,
5020 manifest: DeliveryBundleManifestV2,
5021 ) -> Result<NodeResponse, NodeFailure> {
5022 let mut store = self
5023 .delivery_store
5024 .lock()
5025 .unwrap_or_else(|poisoned| poisoned.into_inner());
5026 let store = store.as_mut().ok_or_else(|| {
5027 failure(
5028 NodeFailureCode::DeliveryStageStorageFailed,
5029 "node delivery store is unavailable",
5030 )
5031 })?;
5032 let begun = store.begin(manifest).map_err(delivery_failure)?;
5033 Ok(NodeResponse::DeliveryStageBegun {
5034 stage_id: begun.stage_id,
5035 manifest_digest: begun.manifest_digest,
5036 missing_blobs: begun.missing_blobs,
5037 })
5038 }
5039
5040 fn put_delivery_blob_chunk(
5041 &self,
5042 stage_id: DeliveryStageId,
5043 blob_digest: DeliveryBlobDigestV1,
5044 offset: u64,
5045 chunk_hex: DeliveryBlobChunkHexV1,
5046 ) -> Result<NodeResponse, NodeFailure> {
5047 let mut store = self
5048 .delivery_store
5049 .lock()
5050 .unwrap_or_else(|poisoned| poisoned.into_inner());
5051 let store = store.as_mut().ok_or_else(|| {
5052 failure(
5053 NodeFailureCode::DeliveryStageStorageFailed,
5054 "node delivery store is unavailable",
5055 )
5056 })?;
5057 let accepted = store
5058 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5059 .map_err(delivery_failure)?;
5060 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5061 stage_id: accepted.stage_id,
5062 blob_digest: accepted.blob_digest,
5063 next_offset: accepted.next_offset,
5064 })
5065 }
5066
5067 fn commit_delivery_stage(
5068 &self,
5069 stage_id: DeliveryStageId,
5070 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5071 let mut store = self
5072 .delivery_store
5073 .lock()
5074 .unwrap_or_else(|poisoned| poisoned.into_inner());
5075 let store = store.as_mut().ok_or_else(|| {
5076 failure(
5077 NodeFailureCode::DeliveryStageStorageFailed,
5078 "node delivery store is unavailable",
5079 )
5080 })?;
5081 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5082 let mut catalog = self
5083 .bundle_catalog
5084 .write()
5085 .unwrap_or_else(|poisoned| poisoned.into_inner());
5086 let mut next_catalog = catalog.clone();
5087 next_catalog
5088 .insert_idempotent(prepared.bundle().clone())
5089 .map_err(|_| {
5090 failure(
5091 NodeFailureCode::DeliveryStageConflict,
5092 "delivery bundle identity conflicts with installed content",
5093 )
5094 })?;
5095 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5096 *catalog = next_catalog;
5097 Ok(receipt)
5098 }
5099
5100 fn abort_delivery_stage(
5101 &self,
5102 stage_id: &DeliveryStageId,
5103 ) -> Result<(), NodeFailure> {
5104 let mut store = self
5105 .delivery_store
5106 .lock()
5107 .unwrap_or_else(|poisoned| poisoned.into_inner());
5108 let store = store.as_mut().ok_or_else(|| {
5109 failure(
5110 NodeFailureCode::DeliveryStageStorageFailed,
5111 "node delivery store is unavailable",
5112 )
5113 })?;
5114 store.abort(stage_id).map_err(delivery_failure)
5115 }
5116
5117 fn resolve_context(
5118 &self,
5119 resolved: &ResolvedSpawnSpec,
5120 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5121 let Some(context_id) = resolved.context_id.as_ref() else {
5122 return Ok(None);
5123 };
5124 self.context_catalog
5125 .read()
5126 .unwrap_or_else(|poisoned| poisoned.into_inner())
5127 .get(context_id)
5128 .map(|context| context.receipt().clone())
5129 .ok_or_else(|| {
5130 failure(
5131 NodeFailureCode::UnknownContextPack,
5132 "spawn context pack is unavailable on this node",
5133 )
5134 })
5135 .map(Some)
5136 }
5137
5138 fn bundle_layout(
5139 &self,
5140 provider: &AgentId,
5141 mode: SessionMode,
5142 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5143 bundle: &NodeBundle,
5144 ) -> Result<BundleProviderLayout, NodeFailure> {
5145 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5146 failure(
5147 NodeFailureCode::BundleBindingMismatch,
5148 "spawn bundle does not match the provider and mode",
5149 )
5150 })?;
5151 if layout != BundleProviderLayout::Codex {
5152 return Ok(layout);
5153 }
5154 let profile = environment_profile.and_then(|receipt| {
5155 let profiles = self
5156 .environment_profiles
5157 .read()
5158 .unwrap_or_else(|poisoned| poisoned.into_inner());
5159 let binding = profiles.get(&receipt.profile_id)?;
5160 if binding.revision != receipt.profile_revision
5161 || &binding.provider != provider
5162 || binding.native_profile_id(mode).is_none()
5163 {
5164 return None;
5165 }
5166 self.environment_materialization_profiles
5167 .read()
5168 .unwrap_or_else(|poisoned| poisoned.into_inner())
5169 .get(&receipt.profile_id)
5170 .cloned()
5171 });
5172 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5173 return Err(failure(
5174 NodeFailureCode::BundleBindingMismatch,
5175 "Codex bundle requires an exact isolated CODEX_HOME profile",
5176 ));
5177 }
5178 Ok(layout)
5179 }
5180
5181 fn select_environment_profile(
5182 &self,
5183 instance_id: AgentInstanceId,
5184 provider: &AgentId,
5185 mode: SessionMode,
5186 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5187 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5188 let Some(environment_profile) = environment_profile else {
5189 return Ok(None);
5190 };
5191 let native_profile_id = {
5192 let profiles = self
5193 .environment_profiles
5194 .read()
5195 .unwrap_or_else(|poisoned| poisoned.into_inner());
5196 let binding = profiles
5197 .get(&environment_profile.profile_id)
5198 .ok_or_else(|| {
5199 failure(
5200 NodeFailureCode::UnknownEnvironmentProfile,
5201 "session environment profile is unavailable on this node",
5202 )
5203 })?;
5204 if &binding.revision != &environment_profile.profile_revision
5205 || &binding.provider != provider
5206 {
5207 return Err(failure(
5208 NodeFailureCode::EnvironmentProfileBindingMismatch,
5209 "session environment profile revision or provider changed",
5210 ));
5211 }
5212 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5213 failure(
5214 NodeFailureCode::EnvironmentProfileBindingMismatch,
5215 "session environment profile does not support this mode",
5216 )
5217 })?
5218 };
5219 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5220 failure(
5221 NodeFailureCode::BackendOperationFailed,
5222 "native environment profile controller is unavailable",
5223 )
5224 })?;
5225 control
5226 .select_native_launch_profile(instance_id, native_profile_id)
5227 .map_err(|_| {
5228 failure(
5229 NodeFailureCode::BackendOperationFailed,
5230 "native environment profile selection failed",
5231 )
5232 })?;
5233 Ok(Some(NativeEnvironmentSelectionGuard {
5234 control: Some(control),
5235 instance_id,
5236 }))
5237 }
5238
5239 fn install_prepared_launch_overlay(
5240 &self,
5241 instance_id: AgentInstanceId,
5242 overlay: PreparedNativeLaunchOverlay,
5243 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5244 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5245 failure(
5246 NodeFailureCode::BackendOperationFailed,
5247 "native launch profile controller is unavailable",
5248 )
5249 })?;
5250 match overlay {
5251 PreparedNativeLaunchOverlay::Environment(overlay) => {
5252 control
5253 .install_native_launch_environment_overlay(instance_id, overlay)
5254 .map_err(|_| failure(
5255 NodeFailureCode::BackendOperationFailed,
5256 "session environment overlay installation failed",
5257 ))?;
5258 Ok(None)
5259 }
5260 PreparedNativeLaunchOverlay::Instance(overlay) => {
5261 control
5262 .install_native_instance_launch_overlay(instance_id, overlay)
5263 .map_err(|_| failure(
5264 NodeFailureCode::BundleBindingMismatch,
5265 "session bundle launch overlay installation failed",
5266 ))?;
5267 Ok(Some(NativeInstanceOverlayGuard {
5268 control: Some(control),
5269 instance_id,
5270 }))
5271 }
5272 }
5273 }
5274
5275 fn environment_materialization_profile(
5276 &self,
5277 environment_profile: &ResolvedEnvironmentProfileReceipt,
5278 ) -> Option<NodeSessionMaterializationProfile> {
5279 self.environment_materialization_profiles
5280 .read()
5281 .unwrap_or_else(|poisoned| poisoned.into_inner())
5282 .get(&environment_profile.profile_id)
5283 .cloned()
5284 }
5285
5286 fn materialization_profile(
5287 &self,
5288 provider: &AgentId,
5289 mode: SessionMode,
5290 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5291 bundle: Option<&ResolvedBundleReceipt>,
5292 context: Option<&ResolvedContextPackReceipt>,
5293 ) -> Result<Option<(
5294 NodeSessionMaterializationProfile,
5295 bool,
5296 Option<BundleProviderLayout>,
5297 )>, NodeFailure> {
5298 let environment = environment_profile
5299 .and_then(|receipt| self.environment_materialization_profile(receipt));
5300 let (bundle, bundle_layout) = match bundle {
5301 Some(receipt) => {
5302 let catalog = self.bundle_catalog.read()
5303 .unwrap_or_else(|poisoned| poisoned.into_inner());
5304 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5305 NodeFailureCode::UnknownBundle,
5306 "session bundle is unavailable on this node",
5307 ))?;
5308 if installed.receipt() != *receipt {
5309 return Err(failure(
5310 NodeFailureCode::BundleBindingMismatch,
5311 "session bundle revision or digest changed",
5312 ));
5313 }
5314 let layout = self.bundle_layout(
5315 provider,
5316 mode,
5317 environment_profile,
5318 installed,
5319 )?;
5320 (Some(installed.clone()), Some(layout))
5321 }
5322 None => (None, None),
5323 };
5324 let has_environment_overlay = environment.is_some() || context.is_some();
5325 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5326 (None, None, None) => None,
5327 (Some(profile), None, None) => Some(Ok(profile)),
5328 (None, Some(bundle), Some(layout)) => {
5329 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5330 }
5331 (Some(profile), Some(bundle), Some(layout)) => {
5332 Some(profile.with_bundle(bundle, layout))
5333 }
5334 _ => {
5335 return Err(failure(
5336 NodeFailureCode::BundleBindingMismatch,
5337 "session bundle provider layout is unavailable",
5338 ));
5339 }
5340 };
5341 let profile = profile
5342 .transpose()
5343 .map_err(|_| failure(
5344 NodeFailureCode::BundleMaterializationFailed,
5345 "session bundle materialization profile is invalid",
5346 ))?;
5347 let profile = match (profile, context) {
5348 (Some(profile), Some(receipt)) => {
5349 let catalog = self.context_catalog
5350 .read()
5351 .unwrap_or_else(|poisoned| poisoned.into_inner());
5352 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5353 NodeFailureCode::UnknownContextPack,
5354 "session context pack is unavailable on this node",
5355 ))?;
5356 if pack.receipt() != receipt {
5357 return Err(failure(
5358 NodeFailureCode::ContextPackMaterializationFailed,
5359 "session context pack identity changed",
5360 ));
5361 }
5362 profile.with_context(pack)
5363 }
5364 (None, Some(receipt)) => {
5365 let catalog = self.context_catalog
5366 .read()
5367 .unwrap_or_else(|poisoned| poisoned.into_inner());
5368 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5369 NodeFailureCode::UnknownContextPack,
5370 "session context pack is unavailable on this node",
5371 ))?;
5372 if pack.receipt() != receipt {
5373 return Err(failure(
5374 NodeFailureCode::ContextPackMaterializationFailed,
5375 "session context pack identity changed",
5376 ));
5377 }
5378 NodeSessionMaterializationProfile::from_context(pack)
5379 }
5380 (Some(profile), None) => Ok(profile),
5381 (None, None) => return Ok(None),
5382 }
5383 .map_err(|_| failure(
5384 NodeFailureCode::ContextPackMaterializationFailed,
5385 "session context pack materialization profile is invalid",
5386 ))?;
5387 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5388 }
5389
5390 fn prepare_session_materialization<'a>(
5391 &'a self,
5392 address: &SessionAddress,
5393 provider: &AgentId,
5394 mode: SessionMode,
5395 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5396 bundle: Option<&ResolvedBundleReceipt>,
5397 context: Option<&ResolvedContextPackReceipt>,
5398 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5399 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5400 let Some((profile, has_environment_overlay, bundle_layout)) =
5401 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5402 else {
5403 return Ok(None);
5404 };
5405 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5406 failure(
5407 NodeFailureCode::BackendOperationFailed,
5408 "session environment materializer is unavailable",
5409 )
5410 })?;
5411 let materialization_failure_code = if context.is_some() {
5412 NodeFailureCode::ContextPackMaterializationFailed
5413 } else if bundle.is_some() {
5414 NodeFailureCode::BundleMaterializationFailed
5415 } else {
5416 NodeFailureCode::BackendOperationFailed
5417 };
5418 let id = MaterializationId::new(format!(
5419 "mat-{}-{}-{}",
5420 self.incarnation_id,
5421 address.session.instance_id.0,
5422 address.session.generation.0,
5423 ))
5424 .map_err(|_| {
5425 failure(
5426 NodeFailureCode::BackendOperationFailed,
5427 "session environment materialization identity failed",
5428 )
5429 })?;
5430 let owner = MaterializationOwner::Session {
5431 incarnation_id: self.incarnation_id,
5432 instance_id: address.session.instance_id,
5433 generation: address.session.generation,
5434 };
5435 let mut ownership = materializer
5436 .begin(
5437 id.clone(),
5438 environment_profile.cloned(),
5439 bundle.cloned(),
5440 context.cloned(),
5441 owner,
5442 managed_lease_id,
5443 &profile,
5444 unix_time_ms(),
5445 )
5446 .map_err(|_| {
5447 failure(
5448 materialization_failure_code,
5449 "session materialization preparation failed",
5450 )
5451 })?;
5452 {
5453 let _transaction = self
5454 .state_transaction
5455 .lock()
5456 .unwrap_or_else(|poisoned| poisoned.into_inner());
5457 let duplicate = self
5458 .materializations
5459 .lock()
5460 .unwrap_or_else(|poisoned| poisoned.into_inner())
5461 .insert(id.clone(), ownership.clone());
5462 if duplicate.is_some() {
5463 return Err(failure(
5464 NodeFailureCode::BackendOperationFailed,
5465 "session environment materialization identity conflict",
5466 ));
5467 }
5468 if let Err(error) = self.persist_state_locked() {
5469 self.materializations
5470 .lock()
5471 .unwrap_or_else(|poisoned| poisoned.into_inner())
5472 .remove(&id);
5473 return Err(persistence_failure(error));
5474 }
5475 }
5476 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5477 Ok(environment) => environment,
5478 Err(_) => {
5479 let transaction = self
5480 .state_transaction
5481 .lock()
5482 .unwrap_or_else(|poisoned| poisoned.into_inner());
5483 self.materializations
5484 .lock()
5485 .unwrap_or_else(|poisoned| poisoned.into_inner())
5486 .insert(id.clone(), ownership.clone());
5487 if self.persist_state_locked().is_err() {
5488 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5489 }
5490 drop(transaction);
5491 if ownership.state() == MaterializationState::CleanupRequired {
5492 let _ = self.cleanup_materialization(&id);
5493 }
5494 return Err(failure(
5495 materialization_failure_code,
5496 "session materialization failed",
5497 ));
5498 }
5499 };
5500 {
5501 let _transaction = self
5502 .state_transaction
5503 .lock()
5504 .unwrap_or_else(|poisoned| poisoned.into_inner());
5505 self.materializations
5506 .lock()
5507 .unwrap_or_else(|poisoned| poisoned.into_inner())
5508 .insert(id.clone(), ownership.clone());
5509 if let Err(error) = self.persist_state_locked() {
5510 drop(_transaction);
5511 let _ = self.cleanup_materialization(&id);
5512 return Err(persistence_failure(error));
5513 }
5514 }
5515 let guard = SessionMaterializationGuard {
5516 shared: self,
5517 id: Some(id),
5518 provider_home: Some(ownership.provider_home().to_path_buf()),
5519 };
5520 let bundle_arguments = match bundle {
5521 Some(receipt) => {
5522 let catalog = self.bundle_catalog.read()
5523 .unwrap_or_else(|poisoned| poisoned.into_inner());
5524 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5525 NodeFailureCode::UnknownBundle,
5526 "session bundle is unavailable on this node",
5527 ))?;
5528 if installed.receipt() != *receipt {
5529 return Err(failure(
5530 NodeFailureCode::BundleBindingMismatch,
5531 "session bundle revision or digest changed",
5532 ));
5533 }
5534 let layout = bundle_layout.ok_or_else(|| failure(
5535 NodeFailureCode::BundleBindingMismatch,
5536 "materialized bundle provider layout is unavailable",
5537 ))?;
5538 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5539 failure(
5540 NodeFailureCode::BundleMaterializationFailed,
5541 "session bundle byte revalidation failed",
5542 )
5543 })?;
5544 Some(bundle_launch_arguments(
5545 layout,
5546 ownership.bundle_root(),
5547 ownership.provider_home(),
5548 ).map_err(|_| failure(
5549 NodeFailureCode::BundleBindingMismatch,
5550 "session bundle launch binding is invalid",
5551 ))?)
5552 }
5553 None => None,
5554 };
5555 if let Some(receipt) = context {
5556 materializer
5557 .revalidate_context(&ownership, receipt)
5558 .map_err(|_| failure(
5559 NodeFailureCode::ContextPackMaterializationFailed,
5560 "session context pack byte revalidation failed",
5561 ))?;
5562 }
5563 let transport = match mode {
5564 SessionMode::Pty => TransportKind::Pty,
5565 SessionMode::Inline => TransportKind::Pipe,
5566 SessionMode::Acp => TransportKind::Acp,
5567 };
5568 let overlay = if let Some(extra_args) = bundle_arguments {
5569 Some(PreparedNativeLaunchOverlay::Instance(
5570 NativeInstanceLaunchOverlay::new(
5571 provider.clone(),
5572 transport,
5573 if has_environment_overlay {
5574 environment
5575 } else {
5576 Vec::new()
5577 },
5578 extra_args,
5579 )
5580 .map_err(|_| {
5581 failure(
5582 NodeFailureCode::BundleBindingMismatch,
5583 "session bundle launch overlay is invalid",
5584 )
5585 })?,
5586 ))
5587 } else if has_environment_overlay {
5588 let context_only = environment_profile.is_none()
5589 && bundle.is_none()
5590 && context.is_some();
5591 let overlay = if context_only {
5592 NativeLaunchEnvironmentOverlay::new_context_root(
5593 provider.clone(),
5594 transport,
5595 environment,
5596 )
5597 .map_err(|_| {
5598 failure(
5599 NodeFailureCode::ContextPackMaterializationFailed,
5600 "context-only launch overlay is invalid",
5601 )
5602 })?
5603 } else {
5604 NativeLaunchEnvironmentOverlay::new(
5605 provider.clone(),
5606 transport,
5607 environment,
5608 )
5609 .map_err(|_| {
5610 failure(
5611 NodeFailureCode::BackendOperationFailed,
5612 "session environment overlay is invalid",
5613 )
5614 })?
5615 };
5616 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5617 } else {
5618 None
5619 };
5620 Ok(Some((overlay, guard)))
5621 }
5622
5623 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5624 let Some(mut ownership) = self
5625 .materializations
5626 .lock()
5627 .unwrap_or_else(|poisoned| poisoned.into_inner())
5628 .get(id)
5629 .cloned()
5630 else {
5631 return Ok(());
5632 };
5633 if ownership.state() != MaterializationState::CleanupRequired {
5634 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5635 failure(
5636 NodeFailureCode::BackendOperationFailed,
5637 "session environment cleanup state is invalid",
5638 )
5639 })?;
5640 }
5641 {
5642 let _transaction = self
5643 .state_transaction
5644 .lock()
5645 .unwrap_or_else(|poisoned| poisoned.into_inner());
5646 let previous = self
5647 .materializations
5648 .lock()
5649 .unwrap_or_else(|poisoned| poisoned.into_inner())
5650 .insert(id.clone(), ownership.clone());
5651 if let Err(error) = self.persist_state_locked() {
5652 let mut materializations = self
5653 .materializations
5654 .lock()
5655 .unwrap_or_else(|poisoned| poisoned.into_inner());
5656 if let Some(previous) = previous {
5657 materializations.insert(id.clone(), previous);
5658 }
5659 return Err(persistence_failure(error));
5660 }
5661 }
5662 let cleanup = self
5663 .session_environment_materializer
5664 .as_ref()
5665 .ok_or(())
5666 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5667 if cleanup.is_err() {
5668 let _ = ownership.mark_recovery_required(unix_time_ms());
5669 self.materializations
5670 .lock()
5671 .unwrap_or_else(|poisoned| poisoned.into_inner())
5672 .insert(id.clone(), ownership);
5673 if self.persist_state().is_err() {
5674 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5675 }
5676 return Err(failure(
5677 NodeFailureCode::BackendOperationFailed,
5678 "session environment cleanup requires recovery",
5679 ));
5680 }
5681 let _transaction = self
5682 .state_transaction
5683 .lock()
5684 .unwrap_or_else(|poisoned| poisoned.into_inner());
5685 self.materializations
5686 .lock()
5687 .unwrap_or_else(|poisoned| poisoned.into_inner())
5688 .remove(id);
5689 if let Err(error) = self.persist_state_locked() {
5690 self.materializations
5691 .lock()
5692 .unwrap_or_else(|poisoned| poisoned.into_inner())
5693 .insert(id.clone(), ownership);
5694 return Err(persistence_failure(error));
5695 }
5696 Ok(())
5697 }
5698
5699 fn resolve_record_materialization(
5700 &self,
5701 record_id: &SessionRecordId,
5702 provider: &AgentId,
5703 mode: SessionMode,
5704 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5705 bundle: Option<&ResolvedBundleReceipt>,
5706 context: Option<&ResolvedContextPackReceipt>,
5707 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5708 let ownership = {
5709 let materializations = self
5710 .materializations
5711 .lock()
5712 .unwrap_or_else(|poisoned| poisoned.into_inner());
5713 let mut matches = materializations.values().filter(|record| {
5714 record.owner()
5715 == &MaterializationOwner::Record {
5716 record_id: record_id.clone(),
5717 }
5718 });
5719 let ownership = matches.next().cloned();
5720 if matches.next().is_some() {
5721 return Err(failure(
5722 NodeFailureCode::BackendOperationFailed,
5723 "managed session has conflicting environment ownership",
5724 ));
5725 }
5726 ownership
5727 };
5728 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5729 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5730 failure(
5731 NodeFailureCode::ContextPackMaterializationFailed,
5732 "session context materializer is unavailable",
5733 )
5734 })?;
5735 let pack = materializer
5736 .revalidate_context(ownership, receipt)
5737 .map_err(|_| {
5738 self.mark_materialization_recovery_required(ownership.id());
5739 failure(
5740 NodeFailureCode::ContextPackMaterializationFailed,
5741 "managed session context pack revalidation failed",
5742 )
5743 })?;
5744 self.context_catalog
5745 .write()
5746 .unwrap_or_else(|poisoned| poisoned.into_inner())
5747 .insert(pack)
5748 .map_err(|_| failure(
5749 NodeFailureCode::ContextPackMaterializationFailed,
5750 "managed session context pack catalog restore failed",
5751 ))?;
5752 }
5753 let materialization = self.materialization_profile(
5754 provider,
5755 mode,
5756 environment_profile,
5757 bundle,
5758 context,
5759 )?;
5760 match (ownership, materialization) {
5761 (None, None) => Ok(None),
5762 (None, Some(_)) | (Some(_), None) => Err(failure(
5763 NodeFailureCode::BackendOperationFailed,
5764 "managed session materialization is unavailable",
5765 )),
5766 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5767 if ownership.environment_profile() != environment_profile
5768 || ownership.bundle() != bundle
5769 || ownership.context() != context
5770 || ownership.state() != MaterializationState::Ready
5771 {
5772 self.mark_materialization_recovery_required(ownership.id());
5773 return Err(failure(
5774 NodeFailureCode::BackendOperationFailed,
5775 "managed session environment ownership requires recovery",
5776 ));
5777 }
5778 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5779 failure(
5780 NodeFailureCode::BackendOperationFailed,
5781 "session environment materializer is unavailable",
5782 )
5783 })?;
5784 if materializer.revalidate(&ownership).is_err() {
5785 self.mark_materialization_recovery_required(ownership.id());
5786 return Err(failure(
5787 NodeFailureCode::BackendOperationFailed,
5788 "managed session environment revalidation failed",
5789 ));
5790 }
5791 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5792 let catalog = self.bundle_catalog.read()
5793 .unwrap_or_else(|poisoned| poisoned.into_inner());
5794 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5795 return Err(failure(
5796 NodeFailureCode::UnknownBundle,
5797 "managed session bundle is unavailable",
5798 ));
5799 };
5800 let layout = bundle_layout.ok_or_else(|| failure(
5801 NodeFailureCode::BundleBindingMismatch,
5802 "record bundle provider layout is unavailable",
5803 ))?;
5804 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5805 drop(catalog);
5806 self.mark_materialization_recovery_required(ownership.id());
5807 return Err(failure(
5808 NodeFailureCode::BundleMaterializationFailed,
5809 "managed session bundle revalidation failed",
5810 ));
5811 }
5812 Some(bundle_launch_arguments(
5813 layout,
5814 ownership.bundle_root(),
5815 ownership.provider_home(),
5816 ).map_err(|_| failure(
5817 NodeFailureCode::BundleBindingMismatch,
5818 "managed session bundle launch binding is invalid",
5819 ))?)
5820 } else {
5821 None
5822 };
5823 if !has_environment_materialization {
5824 let overlay = bundle_arguments.map(|extra_args| {
5825 NativeInstanceLaunchOverlay::new(
5826 provider.clone(),
5827 TransportKind::Pty,
5828 Vec::new(),
5829 extra_args,
5830 )
5831 .map(PreparedNativeLaunchOverlay::Instance)
5832 .map_err(|_| failure(
5833 NodeFailureCode::BundleBindingMismatch,
5834 "managed session bundle launch overlay is invalid",
5835 ))
5836 }).transpose()?;
5837 return Ok(Some((ownership.id().clone(), overlay)));
5838 }
5839 let environment = match materializer.resolve_environment(&ownership, &profile) {
5840 Ok(environment) => environment,
5841 Err(
5842 SessionEnvironmentMaterializeError::SecretUnavailable
5843 | SessionEnvironmentMaterializeError::SecretDenied
5844 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5845 ) => {
5846 return Err(failure(
5847 NodeFailureCode::BackendOperationFailed,
5848 "managed session environment resolution failed",
5849 ));
5850 }
5851 Err(
5852 SessionEnvironmentMaterializeError::InvalidRoot
5853 | SessionEnvironmentMaterializeError::OwnershipMismatch
5854 | SessionEnvironmentMaterializeError::Filesystem(_)
5855 | SessionEnvironmentMaterializeError::Record(_),
5856 ) => {
5857 self.mark_materialization_recovery_required(ownership.id());
5858 return Err(failure(
5859 NodeFailureCode::BackendOperationFailed,
5860 "managed session environment resolution failed",
5861 ));
5862 }
5863 };
5864 let transport = match mode {
5865 SessionMode::Pty => TransportKind::Pty,
5866 SessionMode::Inline => TransportKind::Pipe,
5867 SessionMode::Acp => TransportKind::Acp,
5868 };
5869 let overlay = match bundle_arguments {
5870 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5871 NativeInstanceLaunchOverlay::new(
5872 provider.clone(),
5873 transport,
5874 environment,
5875 extra_args,
5876 )
5877 .map_err(|_| failure(
5878 NodeFailureCode::BundleBindingMismatch,
5879 "managed session bundle launch overlay is invalid",
5880 ))?,
5881 ),
5882 None => PreparedNativeLaunchOverlay::Environment(
5883 NativeLaunchEnvironmentOverlay::new(
5884 provider.clone(),
5885 transport,
5886 environment,
5887 )
5888 .map_err(|_| {
5889 failure(
5890 NodeFailureCode::BackendOperationFailed,
5891 "managed session environment overlay is invalid",
5892 )
5893 })?,
5894 ),
5895 };
5896 Ok(Some((ownership.id().clone(), Some(overlay))))
5897 }
5898 }
5899 }
5900
5901 fn destroy_record_materialization(
5902 &self,
5903 record_id: &SessionRecordId,
5904 ) -> Result<bool, NodeFailure> {
5905 let ids = self
5906 .materializations
5907 .lock()
5908 .unwrap_or_else(|poisoned| poisoned.into_inner())
5909 .values()
5910 .filter_map(|record| {
5911 (record.owner()
5912 == &MaterializationOwner::Record {
5913 record_id: record_id.clone(),
5914 })
5915 .then(|| record.id().clone())
5916 })
5917 .collect::<Vec<_>>();
5918 if ids.len() > 1 {
5919 return Err(failure(
5920 NodeFailureCode::BackendOperationFailed,
5921 "managed session has conflicting environment ownership",
5922 ));
5923 }
5924 let Some(id) = ids.first().cloned() else {
5925 return Ok(false);
5926 };
5927 let (terminal_record, mut cleanup_ownership) = {
5928 let _transaction = self
5929 .state_transaction
5930 .lock()
5931 .unwrap_or_else(|poisoned| poisoned.into_inner());
5932 let original_record = self.record(record_id)?;
5933 let mut terminal_record = original_record.clone();
5934 terminal_record.provider_session = None;
5935 terminal_record.active_session = None;
5936 terminal_record.state = ManagedSessionState::Unavailable;
5937 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5938 terminal_record.updated_at_unix_ms = unix_time_ms();
5939 let original_ownership = self
5940 .materializations
5941 .lock()
5942 .unwrap_or_else(|poisoned| poisoned.into_inner())
5943 .get(&id)
5944 .cloned()
5945 .ok_or_else(|| {
5946 failure(
5947 NodeFailureCode::BackendOperationFailed,
5948 "managed session environment ownership disappeared during cleanup",
5949 )
5950 })?;
5951 let mut cleanup_ownership = original_ownership.clone();
5952 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5953 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5954 failure(
5955 NodeFailureCode::BackendOperationFailed,
5956 "managed session environment cleanup state is invalid",
5957 )
5958 })?;
5959 }
5960 self.session_records
5961 .lock()
5962 .unwrap_or_else(|poisoned| poisoned.into_inner())
5963 .records
5964 .insert(record_id.clone(), terminal_record.clone());
5965 self.materializations
5966 .lock()
5967 .unwrap_or_else(|poisoned| poisoned.into_inner())
5968 .insert(id.clone(), cleanup_ownership.clone());
5969 if let Err(error) = self.persist_state_locked() {
5970 self.session_records
5971 .lock()
5972 .unwrap_or_else(|poisoned| poisoned.into_inner())
5973 .records
5974 .insert(record_id.clone(), original_record);
5975 self.materializations
5976 .lock()
5977 .unwrap_or_else(|poisoned| poisoned.into_inner())
5978 .insert(id.clone(), original_ownership);
5979 return Err(persistence_failure(error));
5980 }
5981 (terminal_record, cleanup_ownership)
5982 };
5983 let cleanup = self
5984 .session_environment_materializer
5985 .as_ref()
5986 .ok_or(())
5987 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5988 if cleanup.is_err() {
5989 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5990 self.materializations
5991 .lock()
5992 .unwrap_or_else(|poisoned| poisoned.into_inner())
5993 .insert(id.clone(), cleanup_ownership);
5994 if self.persist_state().is_err() {
5995 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5996 }
5997 return Err(failure(
5998 NodeFailureCode::BackendOperationFailed,
5999 "managed session environment cleanup requires recovery",
6000 ));
6001 }
6002 let _transaction = self
6003 .state_transaction
6004 .lock()
6005 .unwrap_or_else(|poisoned| poisoned.into_inner());
6006 self.remove_record_memory(record_id);
6007 self.materializations
6008 .lock()
6009 .unwrap_or_else(|poisoned| poisoned.into_inner())
6010 .remove(&id);
6011 if let Err(error) = self.persist_state_locked() {
6012 self.session_records
6013 .lock()
6014 .unwrap_or_else(|poisoned| poisoned.into_inner())
6015 .records
6016 .insert(record_id.clone(), terminal_record);
6017 self.materializations
6018 .lock()
6019 .unwrap_or_else(|poisoned| poisoned.into_inner())
6020 .insert(id, cleanup_ownership);
6021 return Err(persistence_failure(error));
6022 }
6023 Ok(true)
6024 }
6025
6026 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6027 let changed = {
6028 let mut materializations = self
6029 .materializations
6030 .lock()
6031 .unwrap_or_else(|poisoned| poisoned.into_inner());
6032 let Some(ownership) = materializations.get_mut(id) else {
6033 return;
6034 };
6035 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6036 };
6037 if changed && self.persist_state().is_err() {
6038 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6039 }
6040 }
6041
6042 fn reconcile_materializations(&self) {
6043 let records = self
6044 .materializations
6045 .lock()
6046 .unwrap_or_else(|poisoned| poisoned.into_inner())
6047 .values()
6048 .cloned()
6049 .collect::<Vec<_>>();
6050 for mut ownership in records {
6051 let result = match (ownership.state(), ownership.owner()) {
6052 (MaterializationState::Preparing, _)
6053 | (MaterializationState::CleanupRequired, _) => {
6054 self.cleanup_materialization(ownership.id())
6055 }
6056 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6057 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6058 self.materializations
6059 .lock()
6060 .unwrap_or_else(|poisoned| poisoned.into_inner())
6061 .insert(ownership.id().clone(), ownership.clone());
6062 self.persist_state().map_err(persistence_failure)
6063 } else {
6064 Err(failure(
6065 NodeFailureCode::BackendOperationFailed,
6066 "session environment reconciliation failed",
6067 ))
6068 }
6069 }
6070 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6071 .session_environment_materializer
6072 .as_ref()
6073 .ok_or_else(|| {
6074 failure(
6075 NodeFailureCode::BackendOperationFailed,
6076 "session environment materializer is unavailable",
6077 )
6078 })
6079 .and_then(|materializer| {
6080 materializer.revalidate(&ownership).map_err(|_| failure(
6081 NodeFailureCode::BackendOperationFailed,
6082 "session materialization revalidation failed",
6083 ))?;
6084 let record = self.record(record_id)?;
6085 if record.environment_profile.as_ref() != ownership.environment_profile()
6086 || record.bundle.as_ref() != ownership.bundle()
6087 || record.context.as_ref() != ownership.context()
6088 {
6089 return Err(failure(
6090 NodeFailureCode::BackendOperationFailed,
6091 "session materialization ownership changed after restart",
6092 ));
6093 }
6094 if let Some(receipt) = ownership.bundle() {
6095 let catalog = self.bundle_catalog.read()
6096 .unwrap_or_else(|poisoned| poisoned.into_inner());
6097 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6098 NodeFailureCode::UnknownBundle,
6099 "session bundle is unavailable after restart",
6100 ))?;
6101 if bundle.receipt() != *receipt {
6102 return Err(failure(
6103 NodeFailureCode::BundleBindingMismatch,
6104 "session bundle changed after restart",
6105 ));
6106 }
6107 let layout = self.bundle_layout(
6108 &record.provider,
6109 record.mode,
6110 record.environment_profile.as_ref(),
6111 bundle,
6112 )?;
6113 materializer
6114 .revalidate_bundle(&ownership, bundle, layout)
6115 .map_err(|_| failure(
6116 NodeFailureCode::BundleMaterializationFailed,
6117 "session bundle revalidation failed",
6118 ))?;
6119 }
6120 if let Some(receipt) = ownership.context() {
6121 let pack = materializer
6122 .revalidate_context(&ownership, receipt)
6123 .map_err(|_| {
6124 failure(
6125 NodeFailureCode::ContextPackMaterializationFailed,
6126 "session context revalidation failed",
6127 )
6128 })?;
6129 self.context_catalog
6130 .write()
6131 .unwrap_or_else(|poisoned| poisoned.into_inner())
6132 .insert(pack)
6133 .map_err(|_| {
6134 failure(
6135 NodeFailureCode::ContextPackMaterializationFailed,
6136 "session context catalog restore failed",
6137 )
6138 })?;
6139 }
6140 Ok(())
6141 }),
6142 (MaterializationState::RecoveryRequired, _) => Ok(()),
6143 };
6144 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6145 let _ = ownership.mark_recovery_required(unix_time_ms());
6146 self.materializations
6147 .lock()
6148 .unwrap_or_else(|poisoned| poisoned.into_inner())
6149 .insert(ownership.id().clone(), ownership);
6150 if self.persist_state().is_err() {
6151 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6152 }
6153 }
6154 }
6155 let materialized_profiles = self
6156 .environment_materialization_profiles
6157 .read()
6158 .unwrap_or_else(|poisoned| poisoned.into_inner())
6159 .keys()
6160 .cloned()
6161 .collect::<Vec<_>>();
6162 let ownership = self
6163 .materializations
6164 .lock()
6165 .unwrap_or_else(|poisoned| poisoned.into_inner())
6166 .values()
6167 .cloned()
6168 .collect::<Vec<_>>();
6169 let changed = {
6170 let mut session_records = self
6171 .session_records
6172 .lock()
6173 .unwrap_or_else(|poisoned| poisoned.into_inner());
6174 let mut changed = false;
6175 for record in session_records.records.values_mut() {
6176 let environment_profile = record.environment_profile.as_ref();
6177 if environment_profile.is_none()
6178 && record.bundle.is_none()
6179 && record.context.is_none()
6180 {
6181 continue;
6182 }
6183 if record.bundle.is_none()
6184 && environment_profile.is_some_and(|profile| {
6185 !materialized_profiles.contains(&profile.profile_id)
6186 })
6187 {
6188 continue;
6189 }
6190 let has_exact_ownership = ownership.iter().any(|candidate| {
6191 candidate.state() == MaterializationState::Ready
6192 && candidate.environment_profile() == environment_profile
6193 && candidate.bundle() == record.bundle.as_ref()
6194 && candidate.context() == record.context.as_ref()
6195 && candidate.owner()
6196 == &MaterializationOwner::Record {
6197 record_id: record.record_id.clone(),
6198 }
6199 });
6200 if !has_exact_ownership {
6201 record.provider_session = None;
6202 record.active_session = None;
6203 record.state = ManagedSessionState::Unavailable;
6204 record.last_error = Some(if record.bundle.is_some() {
6205 "bundle-unavailable".to_owned()
6206 } else if record.context.is_some() {
6207 "context-unavailable".to_owned()
6208 } else {
6209 "environment-profile-unavailable".to_owned()
6210 });
6211 record.updated_at_unix_ms = unix_time_ms();
6212 changed = true;
6213 }
6214 }
6215 changed
6216 };
6217 if changed && self.persist_state().is_err() {
6218 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6219 }
6220 }
6221
6222 fn replay_spawn_spec(
6223 &self,
6224 spec: &SpawnSpec,
6225 now: Instant,
6226 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6227 let mut cache = self
6228 .spawn_idempotency
6229 .lock()
6230 .unwrap_or_else(|poisoned| poisoned.into_inner());
6231 cache.entries.retain(|_, entry| entry.expires_at > now);
6232 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6233 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6234 return Err(failure(
6235 NodeFailureCode::SpawnIdempotencyConflict,
6236 "spawn idempotency key was reused across spawn kinds",
6237 ));
6238 };
6239 if existing != spec {
6240 return Err(failure(
6241 NodeFailureCode::SpawnIdempotencyConflict,
6242 "spawn idempotency key was reused with a different specification",
6243 ));
6244 }
6245 return Ok(Some(result.clone()));
6246 }
6247 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6248 return Err(failure(
6249 NodeFailureCode::SpawnIdempotencyCapacity,
6250 "spawn idempotency cache reached its bounded capacity",
6251 ));
6252 }
6253 Ok(None)
6254 }
6255
6256 fn remember_spawn_spec(
6257 &self,
6258 spec: SpawnSpec,
6259 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6260 accepted_at: Instant,
6261 ) {
6262 self
6263 .spawn_idempotency
6264 .lock()
6265 .unwrap_or_else(|poisoned| poisoned.into_inner())
6266 .entries
6267 .insert(
6268 spec.idempotency_key.clone(),
6269 SpawnIdempotencyEntry {
6270 value: SpawnIdempotencyValue::Standard { spec, result },
6271 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6272 },
6273 );
6274 }
6275
6276 fn claim_harness_mcp_spawn(
6277 &self,
6278 _reservation_id: &HarnessMcpReservationId,
6279 _activation_digest: &HarnessMcpActivationDigest,
6280 spec: &SpawnSpec,
6281 now: Instant,
6282 ) -> Result<(), NodeFailure> {
6283 let mut cache = self.spawn_idempotency.lock()
6284 .unwrap_or_else(|poisoned| poisoned.into_inner());
6285 cache.entries.retain(|_, entry| entry.expires_at > now);
6286 if cache.entries.contains_key(&spec.idempotency_key) {
6287 return Err(failure(
6288 NodeFailureCode::SpawnIdempotencyConflict,
6289 "spawn idempotency key was reused across spawn kinds",
6290 ));
6291 }
6292 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6293 return Err(failure(
6294 NodeFailureCode::SpawnIdempotencyCapacity,
6295 "spawn idempotency cache reached its bounded capacity",
6296 ));
6297 }
6298 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6299 value: SpawnIdempotencyValue::HarnessMcp,
6300 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6301 });
6302 Ok(())
6303 }
6304
6305 fn replay_managed_spawn(
6306 &self,
6307 request: &ManagedWorktreeSpawnRequest,
6308 now: Instant,
6309 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6310 let mut cache = self.spawn_idempotency.lock()
6311 .unwrap_or_else(|poisoned| poisoned.into_inner());
6312 cache.entries.retain(|_, entry| entry.expires_at > now);
6313 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6314 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6315 return Err(failure(
6316 NodeFailureCode::SpawnIdempotencyConflict,
6317 "spawn idempotency key was reused across spawn kinds",
6318 ));
6319 };
6320 if existing != request {
6321 return Err(failure(
6322 NodeFailureCode::SpawnIdempotencyConflict,
6323 "spawn idempotency key was reused with a different managed specification",
6324 ));
6325 }
6326 return Ok(Some(result.clone()));
6327 }
6328 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6329 return Err(failure(
6330 NodeFailureCode::SpawnIdempotencyCapacity,
6331 "spawn idempotency cache reached its bounded capacity",
6332 ));
6333 }
6334 Ok(None)
6335 }
6336
6337 fn remember_managed_spawn(
6338 &self,
6339 request: ManagedWorktreeSpawnRequest,
6340 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6341 accepted_at: Instant,
6342 ) {
6343 self.spawn_idempotency.lock()
6344 .unwrap_or_else(|poisoned| poisoned.into_inner())
6345 .entries
6346 .insert(
6347 request.spawn_spec.idempotency_key.clone(),
6348 SpawnIdempotencyEntry {
6349 value: SpawnIdempotencyValue::Managed { request, result },
6350 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6351 },
6352 );
6353 }
6354
6355 fn remember_managed_spawn_attempt(
6356 &self,
6357 durable_v2_key: Option<&SpawnIdempotencyKey>,
6358 request: ManagedWorktreeSpawnRequest,
6359 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6360 accepted_at: Instant,
6361 ) {
6362 if durable_v2_key.is_none() {
6363 self.remember_managed_spawn(request, result, accepted_at);
6364 }
6365 }
6366
6367 fn acquire_durable_managed_spawn_v2(
6368 &self,
6369 request: &ManagedWorktreeSpawnRequestV2,
6370 request_digest: String,
6371 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6372 if self.state_path.is_none() {
6373 return Err(failure(
6374 NodeFailureCode::BackendOperationFailed,
6375 "managed worktree V2 requires durable node state",
6376 ));
6377 }
6378 let _transaction = self.state_transaction
6379 .lock()
6380 .unwrap_or_else(|poisoned| poisoned.into_inner());
6381 let key = request.spawn_spec.idempotency_key.clone();
6382 let mut previous = None;
6383 {
6384 let mut replays = self.managed_spawn_replays
6385 .lock()
6386 .unwrap_or_else(|poisoned| poisoned.into_inner());
6387 if let Some(record) = replays.get_mut(&key) {
6388 if record.request_digest != request_digest {
6389 return Err(failure(
6390 NodeFailureCode::SpawnIdempotencyConflict,
6391 "managed worktree V2 idempotency key was reused with a different request",
6392 ));
6393 }
6394 match &record.state {
6395 ManagedWorktreeSpawnReplayStateV10::Pending {
6396 owner_incarnation_id,
6397 lease_id: None,
6398 } if owner_incarnation_id != &self.incarnation_id => {
6399 previous = Some(record.clone());
6400 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6401 owner_incarnation_id: self.incarnation_id,
6402 lease_id: None,
6403 };
6404 record.updated_at_unix_ms = unix_time_ms();
6405 }
6406 ManagedWorktreeSpawnReplayStateV10::Pending {
6407 lease_id: None,
6408 ..
6409 } => {
6410 return Err(failure(
6411 NodeFailureCode::BackendBusy,
6412 "managed worktree V2 reservation is already active",
6413 ));
6414 }
6415 ManagedWorktreeSpawnReplayStateV10::Pending {
6416 lease_id: Some(_),
6417 ..
6418 } => {
6419 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6420 }
6421 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6422 return Ok(DurableManagedSpawnReplayDecision::Committed(
6423 receipt.clone(),
6424 ));
6425 }
6426 }
6427 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6428 return Err(failure(
6429 NodeFailureCode::SpawnIdempotencyCapacity,
6430 "durable managed worktree replay ledger reached its bounded capacity",
6431 ));
6432 } else {
6433 let now = unix_time_ms();
6434 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6435 idempotency_key: key.clone(),
6436 request_digest,
6437 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6438 profile_id: request.worktree_profile_id.clone(),
6439 expected_profile_revision: request.expected_profile_revision.clone(),
6440 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6441 owner_incarnation_id: self.incarnation_id,
6442 lease_id: None,
6443 },
6444 created_at_unix_ms: now,
6445 updated_at_unix_ms: now,
6446 });
6447 }
6448 }
6449 if let Err(error) = self.persist_state_locked() {
6450 let mut replays = self.managed_spawn_replays
6451 .lock()
6452 .unwrap_or_else(|poisoned| poisoned.into_inner());
6453 match previous {
6454 Some(previous) => {
6455 replays.insert(key, previous);
6456 }
6457 None => {
6458 replays.remove(&key);
6459 }
6460 }
6461 return Err(persistence_failure(error));
6462 }
6463 Ok(DurableManagedSpawnReplayDecision::Reserved)
6464 }
6465
6466 fn link_durable_managed_spawn_v2_lease_locked(
6467 &self,
6468 key: &SpawnIdempotencyKey,
6469 lease_id: &ManagedWorktreeLeaseId,
6470 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6471 let mut replays = self.managed_spawn_replays
6472 .lock()
6473 .unwrap_or_else(|poisoned| poisoned.into_inner());
6474 let record = replays.get_mut(key).ok_or_else(|| {
6475 failure(
6476 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6477 "managed worktree V2 reservation disappeared before allocation",
6478 )
6479 })?;
6480 let previous = record.clone();
6481 match &mut record.state {
6482 ManagedWorktreeSpawnReplayStateV10::Pending {
6483 lease_id: current,
6484 ..
6485 }
6486 if current.is_none() =>
6487 {
6488 *current = Some(lease_id.clone());
6489 record.updated_at_unix_ms = unix_time_ms();
6490 Ok(previous)
6491 }
6492 _ => Err(failure(
6493 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6494 "managed worktree V2 reservation is not allocation-ready",
6495 )),
6496 }
6497 }
6498
6499 fn restore_durable_managed_spawn_v2_locked(
6500 &self,
6501 key: &SpawnIdempotencyKey,
6502 previous: ManagedWorktreeSpawnReplayRecordV10,
6503 ) {
6504 self.managed_spawn_replays
6505 .lock()
6506 .unwrap_or_else(|poisoned| poisoned.into_inner())
6507 .insert(key.clone(), previous);
6508 }
6509
6510 fn commit_durable_managed_spawn_v2(
6511 &self,
6512 request: &ManagedWorktreeSpawnRequestV2,
6513 receipt: ManagedWorktreeSpawnReceipt,
6514 ) -> Result<(), NodeFailure> {
6515 let _transaction = self.state_transaction
6516 .lock()
6517 .unwrap_or_else(|poisoned| poisoned.into_inner());
6518 let key = &request.spawn_spec.idempotency_key;
6519 let previous = {
6520 let mut replays = self.managed_spawn_replays
6521 .lock()
6522 .unwrap_or_else(|poisoned| poisoned.into_inner());
6523 let record = replays.get_mut(key).ok_or_else(|| {
6524 failure(
6525 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6526 "managed worktree V2 reservation disappeared before commit",
6527 )
6528 })?;
6529 let previous = record.clone();
6530 match &record.state {
6531 ManagedWorktreeSpawnReplayStateV10::Pending {
6532 lease_id: Some(lease_id),
6533 ..
6534 } if lease_id == &receipt.lease.lease_id => {}
6535 _ => {
6536 return Err(failure(
6537 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6538 "managed worktree V2 reservation is not linked to the spawned lease",
6539 ));
6540 }
6541 }
6542 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6543 record.updated_at_unix_ms = unix_time_ms();
6544 previous
6545 };
6546 if let Err(error) = self.persist_state_locked() {
6547 self.restore_durable_managed_spawn_v2_locked(key, previous);
6548 return Err(persistence_failure(error));
6549 }
6550 Ok(())
6551 }
6552
6553 fn remove_unallocated_durable_managed_spawn_v2(
6554 &self,
6555 request: &ManagedWorktreeSpawnRequestV2,
6556 ) -> Result<(), NodeFailure> {
6557 let _transaction = self.state_transaction
6558 .lock()
6559 .unwrap_or_else(|poisoned| poisoned.into_inner());
6560 let key = &request.spawn_spec.idempotency_key;
6561 let removed = {
6562 let mut replays = self.managed_spawn_replays
6563 .lock()
6564 .unwrap_or_else(|poisoned| poisoned.into_inner());
6565 match replays.get(key).map(|record| &record.state) {
6566 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6567 lease_id: None,
6568 ..
6569 }) => {
6570 replays.remove(key)
6571 }
6572 _ => None,
6573 }
6574 };
6575 let Some(removed) = removed else {
6576 return Ok(());
6577 };
6578 if let Err(error) = self.persist_state_locked() {
6579 self.managed_spawn_replays
6580 .lock()
6581 .unwrap_or_else(|poisoned| poisoned.into_inner())
6582 .insert(key.clone(), removed);
6583 return Err(persistence_failure(error));
6584 }
6585 Ok(())
6586 }
6587
6588 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6589 if spec.target.node_id != self.node_id {
6590 return Err(failure(
6591 NodeFailureCode::SpawnTargetMismatch,
6592 "spawn target does not match this node",
6593 ));
6594 }
6595 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6596 failure(
6597 NodeFailureCode::UnknownSpawnProfile,
6598 "spawn profile is unavailable on this node",
6599 )
6600 })?;
6601 let resolved = spec.resolve(defaults).map_err(|error| match error {
6602 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6603 NodeFailureCode::SpawnProfileRevisionMismatch,
6604 "spawn profile revision does not match the loaded profile",
6605 ),
6606 _ => failure(
6607 NodeFailureCode::InvalidRequest,
6608 "spawn specification could not be resolved",
6609 ),
6610 })?;
6611 if resolved.target.worktree_id.is_some() {
6612 self.require_worktree_service(&resolved.target.workspace_id)?;
6613 }
6614 let environment_profile = self.resolve_environment_profile(&resolved)?;
6615 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6616 self.resolve_context(&resolved)?;
6617 Ok(resolved)
6618 }
6619
6620 async fn resolve_spawn_workspace(
6621 &self,
6622 resolved: &ResolvedSpawnSpec,
6623 deadline: Instant,
6624 ) -> Result<WorkspaceId, NodeFailure> {
6625 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6626 return Ok(resolved.target.workspace_id.clone());
6627 };
6628 if self.managed_worktrees.lock()
6629 .unwrap_or_else(|poisoned| poisoned.into_inner())
6630 .lease_for_workspace(worktree_id)
6631 .is_some()
6632 {
6633 return Err(failure(
6634 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6635 "managed worktree targets may only be spawned through their lease request",
6636 ));
6637 }
6638 self.require_worktree_service(&resolved.target.workspace_id)?;
6639 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6640 let selected_root = self.workspace_root(worktree_id)?;
6641 let remaining = spawn_deadline_remaining(deadline)?;
6642 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6643 .await
6644 .map_err(|_| {
6645 failure(
6646 NodeFailureCode::SpawnDeadlineExceeded,
6647 "Git worktree selection exceeded the spawn deadline",
6648 )
6649 })?
6650 .map_err(git_worktree_failure)?;
6651 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6652 Ok(worktree_id.clone())
6653 }
6654
6655 async fn spawn_from_spec(
6656 &self,
6657 spec: SpawnSpec,
6658 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6659 self.spawn_from_spec_with_proxy(spec, None).await
6660 }
6661
6662 async fn spawn_from_spec_with_proxy(
6663 &self,
6664 spec: SpawnSpec,
6665 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6666 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6667 let accepted_at = Instant::now();
6668 if harness_mcp.is_none() {
6669 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6670 return replayed;
6671 }
6672 }
6673 let resolved = self.resolve_spawn_spec(&spec)?;
6674 let environment_profile = self.resolve_environment_profile(&resolved)?;
6675 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6676 let context = self.resolve_context(&resolved)?;
6677 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6678 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6679 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6680 let spawn_workspace_id = match spawn_workspace_id {
6681 Ok(workspace_id) => workspace_id,
6682 Err(error) => {
6683 let result = Err(error);
6684 if harness_mcp.is_none() {
6685 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6686 }
6687 return result;
6688 }
6689 };
6690 if self.managed_worktrees.lock()
6691 .unwrap_or_else(|poisoned| poisoned.into_inner())
6692 .lease_for_workspace(&spawn_workspace_id)
6693 .is_some()
6694 {
6695 let result = Err(failure(
6696 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6697 "managed worktree targets may only be spawned through their lease request",
6698 ));
6699 if harness_mcp.is_none() {
6700 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6701 }
6702 return result;
6703 }
6704 let result = self
6705 .spawn_session_with_deadline(
6706 spawn_workspace_id,
6707 resolved.provider.clone(),
6708 resolved.mode,
6709 resolved.terminal_size,
6710 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6711 environment_profile.clone(),
6712 bundle.clone(),
6713 context.clone(),
6714 None,
6715 None,
6716 SpawnRecordPolicy::Always,
6717 Some(deadline),
6718 &required_capabilities,
6719 harness_mcp,
6720 resolved.approval_level,
6721 resolved.network_allowlist.clone(),
6722 resolved.browser_profile_id.clone(),
6723 )
6724 .await
6725 .map(|(session, _runtime_policy)| {
6726 resolved.receipt_with_materialization(
6727 self.incarnation_id,
6728 session,
6729 environment_profile,
6730 bundle,
6731 context,
6732 )
6733 });
6734 if harness_mcp.is_none() {
6735 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6736 }
6737 result
6738 }
6739
6740 async fn spawn_from_spec_with_harness_mcp(
6741 &self,
6742 reservation_id: HarnessMcpReservationId,
6743 activation_digest: HarnessMcpActivationDigest,
6744 spec: SpawnSpec,
6745 deadline_unix_ms: u64,
6746 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6747 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6748 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6749 })?;
6750 if let Some(receipt) = registry
6751 .replay_spawn(&reservation_id, &activation_digest, &spec)
6752 .map_err(harness_mcp_failure)?
6753 {
6754 return Ok(receipt);
6755 }
6756 let resolved = self.resolve_spawn_spec(&spec)?;
6757 let prepared = registry
6758 .prepare_spawn(
6759 &reservation_id,
6760 &activation_digest,
6761 &spec,
6762 deadline_unix_ms,
6763 resolved.provider.clone(),
6764 )
6765 .map_err(harness_mcp_failure)?;
6766 self.claim_harness_mcp_spawn(
6767 &reservation_id,
6768 &activation_digest,
6769 &spec,
6770 Instant::now(),
6771 )?;
6772 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6773 let mut receipt = match spawn {
6774 Ok(receipt) => receipt,
6775 Err(error) => {
6776 let _ = registry.abort(&reservation_id, &activation_digest);
6777 return Err(error);
6778 }
6779 };
6780 let record_id = self
6781 .session_bindings
6782 .lock()
6783 .unwrap_or_else(|poisoned| poisoned.into_inner())
6784 .get(&receipt.session.session.instance_id)
6785 .and_then(|binding| binding.record_id.clone());
6786 let record_id = match record_id {
6787 Some(record_id) => record_id,
6788 None => {
6789 let rollback = self.remove_session(&receipt.session).await;
6790 let _ = registry.abort(&reservation_id, &activation_digest);
6791 if let Err(rollback_error) = rollback {
6792 return Err(rollback_error);
6793 }
6794 return Err(failure(
6795 NodeFailureCode::BindingMismatch,
6796 "harness MCP spawn did not retain its exact managed record",
6797 ));
6798 }
6799 };
6800 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6801 reservation_id: reservation_id.clone(),
6802 activation_digest: activation_digest.clone(),
6803 });
6804 if let Err(error) = registry.mark_spawned(
6805 &prepared,
6806 receipt.session.clone(),
6807 record_id,
6808 receipt.clone(),
6809 ) {
6810 let rollback = self.remove_session(&receipt.session).await;
6811 let _ = registry.abort(&reservation_id, &activation_digest);
6812 if let Err(rollback_error) = rollback {
6813 return Err(rollback_error);
6814 }
6815 return Err(harness_mcp_failure(error));
6816 }
6817 Ok(receipt)
6818 }
6819
6820 async fn spawn_managed_worktree(
6821 &self,
6822 request: ManagedWorktreeSpawnRequest,
6823 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6824 self.spawn_managed_worktree_inner(request, None).await
6825 }
6826
6827 async fn spawn_managed_worktree_inner(
6828 &self,
6829 request: ManagedWorktreeSpawnRequest,
6830 durable_v2_key: Option<&SpawnIdempotencyKey>,
6831 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6832 let accepted_at = Instant::now();
6833 if durable_v2_key.is_none() {
6834 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6835 return replayed;
6836 }
6837 }
6838 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6839 if request.spawn_spec.target.worktree_id.is_some() {
6840 let result = Err(failure(
6841 NodeFailureCode::InvalidRequest,
6842 "managed worktree spawn must not select a caller-provided worktree",
6843 ));
6844 self.remember_managed_spawn_attempt(
6845 durable_v2_key,
6846 request,
6847 result.clone(),
6848 accepted_at,
6849 );
6850 return result;
6851 }
6852 let environment_profile = self.resolve_environment_profile(&resolved)?;
6853 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6854 let context = self.resolve_context(&resolved)?;
6855 let source_workspace_id = resolved.target.workspace_id.clone();
6856 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6857 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6858 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6859 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6860 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6861 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6862 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6863 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6864 };
6865 let runtime_policy = timeout(
6866 spawn_deadline_remaining(deadline)?,
6867 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6868 ).await.map_err(|_| failure(
6869 NodeFailureCode::SpawnDeadlineExceeded,
6870 "provider admission exceeded the managed spawn deadline",
6871 ))??;
6872 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6873 return Err(failure(
6874 NodeFailureCode::UnsupportedSpawnCapability,
6875 "provider runtime does not admit a required spawn capability",
6876 ));
6877 }
6878 self.ensure_binding_capacity()?;
6879 let source_root = self.workspace_root(&source_workspace_id)?;
6880 profile.validate_for_workspace(&source_root)
6881 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6882 let base_timeout_ms = u64::try_from(
6883 spawn_deadline_remaining(deadline)?.as_millis(),
6884 ).unwrap_or(u64::MAX).max(1);
6885 let base_commit = resolve_base_commit_with_timeout(
6886 &source_root,
6887 profile.base(),
6888 base_timeout_ms,
6889 ).await.map_err(|error| {
6890 if Instant::now() >= deadline || error.message.contains("timed out") {
6891 failure(
6892 NodeFailureCode::SpawnDeadlineExceeded,
6893 "managed worktree base resolution exceeded the spawn deadline",
6894 )
6895 } else {
6896 managed_git_worktree_failure(error)
6897 }
6898 })?;
6899
6900 let lease = {
6901 let _transaction = self.state_transaction.lock()
6902 .unwrap_or_else(|poisoned| poisoned.into_inner());
6903 let previous = self.managed_worktrees.lock()
6904 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6905 let lease = self.managed_worktrees.lock()
6906 .unwrap_or_else(|poisoned| poisoned.into_inner())
6907 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6908 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6909 let previous_replay = match durable_v2_key
6910 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6911 .transpose()
6912 {
6913 Ok(previous) => previous,
6914 Err(error) => {
6915 *self.managed_worktrees.lock()
6916 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6917 return Err(error);
6918 }
6919 };
6920 if let Err(error) = self.persist_state_locked() {
6921 *self.managed_worktrees.lock()
6922 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6923 if let (Some(key), Some(previous_replay)) =
6924 (durable_v2_key, previous_replay)
6925 {
6926 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6927 }
6928 return Err(persistence_failure(error));
6929 }
6930 lease
6931 };
6932 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6933 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6934 self.managed_worktrees.lock()
6935 .unwrap_or_else(|poisoned| poisoned.into_inner())
6936 .remove_unmutated(&lease.lease_id);
6937 if self.persist_state().is_err() {
6938 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6939 }
6940 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6941 self.remember_managed_spawn_attempt(
6942 durable_v2_key,
6943 request,
6944 result.clone(),
6945 accepted_at,
6946 );
6947 return result;
6948 }
6949 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6950 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6951 Err(error) => {
6952 self.reconcile_failed_allocation(&lease, &source_root).await;
6953 let result = Err(error);
6954 self.remember_managed_spawn_attempt(
6955 durable_v2_key,
6956 request,
6957 result.clone(),
6958 accepted_at,
6959 );
6960 return result;
6961 }
6962 };
6963 let created = match create_git_worktree_with_timeout(
6964 &source_root,
6965 &lease.target_root,
6966 &lease.branch,
6967 Some(&base_commit),
6968 mutation_timeout_ms,
6969 ).await {
6970 Ok(created) => created,
6971 Err(error) => {
6972 self.reconcile_failed_allocation(&lease, &source_root).await;
6973 let result = Err(if Instant::now() >= deadline
6974 || error.message.contains("timed out")
6975 || error.message.contains("deadline")
6976 {
6977 failure(
6978 NodeFailureCode::SpawnDeadlineExceeded,
6979 "managed worktree creation exceeded the spawn deadline",
6980 )
6981 } else {
6982 managed_git_worktree_failure(error)
6983 });
6984 self.remember_managed_spawn_attempt(
6985 durable_v2_key,
6986 request,
6987 result.clone(),
6988 accepted_at,
6989 );
6990 return result;
6991 }
6992 };
6993 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6994 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6995 let result = Err(failure(
6996 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6997 "created worktree identity did not match its durable lease",
6998 ));
6999 self.remember_managed_spawn_attempt(
7000 durable_v2_key,
7001 request,
7002 result.clone(),
7003 accepted_at,
7004 );
7005 return result;
7006 }
7007 {
7008 let mut registry = self.managed_worktrees.lock()
7009 .unwrap_or_else(|poisoned| poisoned.into_inner());
7010 let current = registry.get_mut(&lease.lease_id)
7011 .expect("allocated managed worktree lease remains present");
7012 current.expected_head = Some(created.head.clone());
7013 current.state = ManagedWorktreeLeaseState::Ready;
7014 current.updated_at_unix_ms = unix_time_ms();
7015 }
7016 if let Err(error) = self.persist_state() {
7017 self.mark_managed_recovery_required(
7018 &lease.lease_id,
7019 ManagedWorktreeCleanupFailure::Backend,
7020 );
7021 let result = Err(persistence_failure(error));
7022 self.remember_managed_spawn_attempt(
7023 durable_v2_key,
7024 request,
7025 result.clone(),
7026 accepted_at,
7027 );
7028 return result;
7029 }
7030 let ready = self.managed_worktrees.lock()
7031 .unwrap_or_else(|poisoned| poisoned.into_inner())
7032 .get(&lease.lease_id)
7033 .expect("ready managed worktree lease remains present")
7034 .snapshot();
7035 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7036 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7037 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7038 let result = Err(error);
7039 self.remember_managed_spawn_attempt(
7040 durable_v2_key,
7041 request,
7042 result.clone(),
7043 accepted_at,
7044 );
7045 return result;
7046 }
7047 let spawn = self.spawn_session_with_deadline(
7048 lease.workspace_id.clone(),
7049 resolved.provider.clone(),
7050 resolved.mode,
7051 resolved.terminal_size,
7052 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7053 environment_profile.clone(),
7054 bundle.clone(),
7055 context.clone(),
7056 Some(lease.lease_id.clone()),
7057 Some(runtime_policy),
7058 SpawnRecordPolicy::Always,
7059 Some(deadline),
7060 &required_capabilities,
7061 None,
7062 resolved.approval_level,
7063 resolved.network_allowlist.clone(),
7064 resolved.browser_profile_id.clone(),
7065 ).await;
7066 let (session, effective_runtime_policy) = match spawn {
7067 Ok(spawned) => spawned,
7068 Err(error) => {
7069 if self
7070 .cleanup_managed_worktree(&lease.lease_id, true)
7071 .await
7072 .is_err()
7073 {
7074 self.mark_managed_recovery_required(
7075 &lease.lease_id,
7076 ManagedWorktreeCleanupFailure::Backend,
7077 );
7078 }
7079 let result = Err(error);
7080 self.remember_managed_spawn_attempt(
7081 durable_v2_key,
7082 request,
7083 result.clone(),
7084 accepted_at,
7085 );
7086 return result;
7087 }
7088 };
7089 let (record_id, raw_inventory_record_id) = {
7090 let mut bindings = self.session_bindings.lock()
7091 .unwrap_or_else(|poisoned| poisoned.into_inner());
7092 let binding = bindings.get_mut(&session.session.instance_id)
7093 .expect("successful spawn retains its session binding");
7094 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7095 managed_spawn_record_ownership(binding, effective_runtime_policy)
7096 };
7097 let bound = self.managed_worktrees.lock()
7098 .unwrap_or_else(|poisoned| poisoned.into_inner())
7099 .bind_session(
7100 &lease.lease_id,
7101 ManagedWorktreeSessionHolder {
7102 incarnation_id: self.incarnation_id,
7103 instance_id: session.session.instance_id,
7104 generation: session.session.generation,
7105 },
7106 record_id,
7107 unix_time_ms(),
7108 )
7109 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7110 let snapshot = match bound {
7111 Ok(snapshot) => snapshot,
7112 Err(error) => {
7113 if raw_inventory_record_id
7114 .as_ref()
7115 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7116 {
7117 self.mark_managed_recovery_required(
7118 &lease.lease_id,
7119 ManagedWorktreeCleanupFailure::Backend,
7120 );
7121 }
7122 let _ = self.remove_session(&session).await;
7123 self.mark_managed_recovery_required(
7124 &lease.lease_id,
7125 ManagedWorktreeCleanupFailure::Backend,
7126 );
7127 let result = Err(error);
7128 self.remember_managed_spawn_attempt(
7129 durable_v2_key,
7130 request,
7131 result.clone(),
7132 accepted_at,
7133 );
7134 return result;
7135 }
7136 };
7137 if let Err(error) = self.persist_state() {
7138 if raw_inventory_record_id
7139 .as_ref()
7140 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7141 {
7142 self.mark_managed_recovery_required(
7143 &lease.lease_id,
7144 ManagedWorktreeCleanupFailure::Backend,
7145 );
7146 }
7147 let _ = self.remove_session(&session).await;
7148 if self
7149 .cleanup_managed_worktree(&lease.lease_id, true)
7150 .await
7151 .is_err()
7152 {
7153 self.mark_managed_recovery_required(
7154 &lease.lease_id,
7155 ManagedWorktreeCleanupFailure::Backend,
7156 );
7157 }
7158 let result = Err(persistence_failure(error));
7159 self.remember_managed_spawn_attempt(
7160 durable_v2_key,
7161 request,
7162 result.clone(),
7163 accepted_at,
7164 );
7165 return result;
7166 }
7167 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7168 let receipt = managed_spawn_receipt(
7169 &resolved,
7170 self.incarnation_id,
7171 session,
7172 snapshot,
7173 environment_profile,
7174 bundle,
7175 context,
7176 );
7177 let result = Ok(receipt);
7178 self.remember_managed_spawn_attempt(
7179 durable_v2_key,
7180 request,
7181 result.clone(),
7182 accepted_at,
7183 );
7184 result
7185 }
7186
7187 async fn spawn_managed_worktree_v2(
7188 &self,
7189 request: ManagedWorktreeSpawnRequestV2,
7190 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7191 if request.spawn_spec.target.node_id != self.node_id {
7192 return Err(failure(
7193 NodeFailureCode::SpawnTargetMismatch,
7194 "spawn target does not match this node",
7195 ));
7196 }
7197 if request.spawn_spec.target.worktree_id.is_some() {
7198 return Err(failure(
7199 NodeFailureCode::InvalidRequest,
7200 "managed worktree spawn must not select a caller-provided worktree",
7201 ));
7202 }
7203 let request_digest = managed_spawn_request_digest_v2(&request)?;
7204 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7205 match replay {
7206 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7207 DurableManagedSpawnReplayDecision::PendingLinked => {
7208 return Err(failure(
7209 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7210 "managed worktree V2 reservation has an incomplete durable allocation",
7211 ));
7212 }
7213 DurableManagedSpawnReplayDecision::Reserved => {}
7214 }
7215 let validated = (|| {
7216 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7217 let profile = self.managed_profile(
7218 &resolved.target.workspace_id,
7219 &request.worktree_profile_id,
7220 )?;
7221 if profile.revision() != &request.expected_profile_revision {
7222 return Err(failure(
7223 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7224 "managed worktree profile revision does not match the loaded profile",
7225 ));
7226 }
7227 Ok(())
7228 })();
7229 match validated {
7230 Ok(()) => {}
7231 Err(error) => {
7232 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7233 return Err(error);
7234 }
7235 }
7236 let legacy = ManagedWorktreeSpawnRequest {
7237 spawn_spec: request.spawn_spec.clone(),
7238 worktree_profile_id: request.worktree_profile_id.clone(),
7239 };
7240 match self
7241 .spawn_managed_worktree_inner(
7242 legacy,
7243 Some(&request.spawn_spec.idempotency_key),
7244 )
7245 .await
7246 {
7247 Ok(receipt) => {
7248 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7249 Ok(receipt)
7250 }
7251 Err(error) => {
7252 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7253 Err(error)
7254 }
7255 }
7256 }
7257
7258 fn managed_profile(
7259 &self,
7260 workspace_id: &WorkspaceId,
7261 profile_id: &WorktreeProfileId,
7262 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7263 if self.worktree_service_modes.get(workspace_id).copied()
7264 != Some(WorktreeServiceMode::Managed)
7265 {
7266 return Err(failure(
7267 NodeFailureCode::UnsupportedSpawnCapability,
7268 "workspace does not enable managed worktrees",
7269 ));
7270 }
7271 self.managed_worktree_profiles.get(workspace_id)
7272 .and_then(|profiles| profiles.get(profile_id))
7273 .cloned()
7274 .ok_or_else(|| failure(
7275 NodeFailureCode::InvalidRequest,
7276 "managed worktree profile is unavailable for this workspace",
7277 ))
7278 }
7279
7280 async fn reconcile_failed_allocation(
7281 &self,
7282 lease: &ManagedWorktreeLeaseRecord,
7283 source_root: &str,
7284 ) {
7285 match list_git_worktrees(source_root).await {
7286 Ok(worktrees) => {
7287 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7288 None => {
7289 match std::fs::symlink_metadata(&lease.target_root) {
7290 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7291 self.managed_worktrees.lock()
7292 .unwrap_or_else(|poisoned| poisoned.into_inner())
7293 .remove_unmutated(&lease.lease_id);
7294 let _ = self.persist_state();
7295 }
7296 _ => self.mark_managed_recovery_required(
7297 &lease.lease_id,
7298 ManagedWorktreeCleanupFailure::OwnershipConflict,
7299 ),
7300 }
7301 }
7302 Some(item) if exact_owned_worktree(
7303 lease,
7304 &item.path,
7305 item.branch.as_deref(),
7306 &item.head,
7307 ) => {
7308 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7309 }
7310 Some(_) => self.mark_managed_recovery_required(
7311 &lease.lease_id,
7312 ManagedWorktreeCleanupFailure::OwnershipConflict,
7313 ),
7314 }
7315 }
7316 Err(_) => self.mark_managed_recovery_required(
7317 &lease.lease_id,
7318 ManagedWorktreeCleanupFailure::Backend,
7319 ),
7320 }
7321 }
7322
7323 async fn reconcile_managed_worktrees(&self) {
7324 let leases = self.managed_worktrees.lock()
7325 .unwrap_or_else(|poisoned| poisoned.into_inner())
7326 .active_records()
7327 .cloned()
7328 .collect::<Vec<_>>();
7329 for lease in leases {
7330 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7331 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7332 _ => {
7333 self.mark_managed_recovery_required(
7334 &lease.lease_id,
7335 ManagedWorktreeCleanupFailure::OwnershipConflict,
7336 );
7337 continue;
7338 }
7339 };
7340 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7341 self.mark_managed_recovery_required(
7342 &lease.lease_id,
7343 ManagedWorktreeCleanupFailure::OwnershipConflict,
7344 );
7345 continue;
7346 }
7347 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7348 Ok(root) => root,
7349 Err(_) => {
7350 self.mark_managed_recovery_required(
7351 &lease.lease_id,
7352 ManagedWorktreeCleanupFailure::OwnershipConflict,
7353 );
7354 continue;
7355 }
7356 };
7357 let worktrees = match list_git_worktrees(&source_root).await {
7358 Ok(worktrees) => worktrees,
7359 Err(_) => {
7360 self.mark_managed_recovery_required(
7361 &lease.lease_id,
7362 ManagedWorktreeCleanupFailure::Backend,
7363 );
7364 continue;
7365 }
7366 };
7367 let listed = worktrees.iter()
7368 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7369 let Some(listed) = listed else {
7370 match std::fs::symlink_metadata(&lease.target_root) {
7371 Err(error) if error.kind() == io::ErrorKind::NotFound
7372 && lease.state == ManagedWorktreeLeaseState::Allocating
7373 && !lease.has_holders() => {
7374 let removed = self.managed_worktrees.lock()
7375 .unwrap_or_else(|poisoned| poisoned.into_inner())
7376 .tombstone(&lease.lease_id, unix_time_ms());
7377 if self.persist_state().is_ok() && removed.is_some() {
7378 self.publish(NodeEvent::ManagedWorktreeRemoved {
7379 lease_id: lease.lease_id.clone(),
7380 });
7381 }
7382 }
7383 _ => self.mark_managed_recovery_required(
7384 &lease.lease_id,
7385 ManagedWorktreeCleanupFailure::OwnershipConflict,
7386 ),
7387 }
7388 continue;
7389 };
7390 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7391 &lease,
7392 &listed.path,
7393 listed.branch.as_deref(),
7394 &listed.head,
7395 ) {
7396 self.mark_managed_recovery_required(
7397 &lease.lease_id,
7398 ManagedWorktreeCleanupFailure::OwnershipConflict,
7399 );
7400 continue;
7401 }
7402 if !self.workspaces.read()
7403 .unwrap_or_else(|poisoned| poisoned.into_inner())
7404 .contains_key(&lease.workspace_id)
7405 {
7406 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7407 self.mark_managed_recovery_required(
7408 &lease.lease_id,
7409 ManagedWorktreeCleanupFailure::Backend,
7410 );
7411 continue;
7412 }
7413 }
7414 let snapshot = {
7415 let mut registry = self.managed_worktrees.lock()
7416 .unwrap_or_else(|poisoned| poisoned.into_inner());
7417 let current = registry.get_mut(&lease.lease_id)
7418 .expect("reconciled managed lease remains present");
7419 current.expected_head = Some(listed.head.clone());
7420 current.cleanup_failure = None;
7421 current.state = if current.has_holders() {
7422 ManagedWorktreeLeaseState::InUse
7423 } else if current.retention == ManagedWorktreeRetention::Retain {
7424 ManagedWorktreeLeaseState::Retained
7425 } else {
7426 ManagedWorktreeLeaseState::Ready
7427 };
7428 current.updated_at_unix_ms = unix_time_ms();
7429 current.snapshot()
7430 };
7431 if self.persist_state().is_err() {
7432 self.mark_managed_recovery_required(
7433 &lease.lease_id,
7434 ManagedWorktreeCleanupFailure::Backend,
7435 );
7436 continue;
7437 }
7438 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7439 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7440 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7441 }
7442 }
7443 }
7444
7445 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7465 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7466 .session_records
7467 .lock()
7468 .unwrap_or_else(|poisoned| poisoned.into_inner())
7469 .records
7470 .values()
7471 .filter(|record| {
7472 record.state == ManagedSessionState::Live
7473 && record.exported_context.is_none()
7474 && record.provider_session.is_some()
7475 })
7476 .filter_map(|record| {
7477 record
7478 .active_session
7479 .clone()
7480 .map(|session| (record.record_id.clone(), session))
7481 })
7482 .collect();
7483 for (record_id, session) in candidates {
7484 let shared = Arc::clone(self);
7485 tokio::spawn(async move {
7486 shared
7487 .reconcile_context_pack_export_for_record(&record_id, session)
7488 .await;
7489 });
7490 }
7491 }
7492
7493 async fn reconcile_context_pack_export_for_record(
7512 &self,
7513 record_id: &SessionRecordId,
7514 session: SessionAddress,
7515 ) {
7516 let Ok(record) = self.record(record_id) else {
7517 return;
7518 };
7519 if !matches!(
7520 record.state,
7521 ManagedSessionState::Live | ManagedSessionState::Dormant
7522 ) || record.exported_context.is_some()
7523 || record.provider_session.is_none()
7524 {
7525 return;
7526 }
7527 let Ok(receipt) = self
7534 .export_context_pack_for_session_record(&record.record_id, &session, true)
7535 .await
7536 else {
7537 return;
7538 };
7539 self.set_exported_context(&record.record_id, receipt);
7540 }
7541
7542 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7547 let transaction = self
7548 .state_transaction
7549 .lock()
7550 .unwrap_or_else(|poisoned| poisoned.into_inner());
7551 let (previous, updated) = {
7552 let mut records = self
7553 .session_records
7554 .lock()
7555 .unwrap_or_else(|poisoned| poisoned.into_inner());
7556 let Some(record) = records.records.get_mut(record_id) else {
7557 return;
7558 };
7559 if record.exported_context.is_some() {
7560 return;
7561 }
7562 let previous = record.clone();
7563 record.exported_context = Some(receipt);
7564 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7565 (previous, record.clone())
7566 };
7567 if self.persist_state_locked().is_err() {
7568 self.session_records
7569 .lock()
7570 .unwrap_or_else(|poisoned| poisoned.into_inner())
7571 .records
7572 .insert(record_id.clone(), previous);
7573 return;
7574 }
7575 drop(transaction);
7576 self.publish_record(updated);
7577 }
7578
7579 fn managed_record_export_target_for_instance(
7589 &self,
7590 instance_id: AgentInstanceId,
7591 generation: SessionGeneration,
7592 ) -> Option<(SessionRecordId, SessionAddress)> {
7593 let bindings = self
7594 .session_bindings
7595 .lock()
7596 .unwrap_or_else(|poisoned| poisoned.into_inner());
7597 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7598 let record_id = binding.record_id.clone()?;
7599 Some((
7600 record_id,
7601 SessionAddress {
7602 workspace_id: binding.workspace_id.clone(),
7603 session: SessionKey { instance_id, generation },
7604 },
7605 ))
7606 }
7607
7608 fn mark_managed_recovery_required(
7609 &self,
7610 lease_id: &ManagedWorktreeLeaseId,
7611 failure_kind: ManagedWorktreeCleanupFailure,
7612 ) {
7613 let snapshot = {
7614 let mut registry = self.managed_worktrees.lock()
7615 .unwrap_or_else(|poisoned| poisoned.into_inner());
7616 let Some(lease) = registry.get_mut(lease_id) else { return };
7617 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7618 lease.cleanup_failure = Some(failure_kind);
7619 lease.updated_at_unix_ms = unix_time_ms();
7620 lease.snapshot()
7621 };
7622 let _ = self.persist_state();
7623 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7624 }
7625
7626 fn set_persistence_error(&self, error: Option<String>) {
7627 *self
7628 .persistence_error
7629 .write()
7630 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7631 .map(|message| session_registry::sanitized_persistence_summary(&message));
7632 }
7633
7634 fn persist_state(&self) -> io::Result<()> {
7635 let _transaction = self
7636 .state_transaction
7637 .lock()
7638 .unwrap_or_else(|poisoned| poisoned.into_inner());
7639 self.persist_state_locked()
7640 }
7641
7642 fn persist_state_locked(&self) -> io::Result<()> {
7643 let workspaces = self
7644 .workspaces
7645 .read()
7646 .unwrap_or_else(|poisoned| poisoned.into_inner())
7647 .clone();
7648 let records = self
7649 .session_records
7650 .lock()
7651 .unwrap_or_else(|poisoned| poisoned.into_inner())
7652 .records
7653 .values()
7654 .cloned()
7655 .collect::<Vec<_>>();
7656 let mut managed = self.managed_worktrees
7657 .lock()
7658 .unwrap_or_else(|poisoned| poisoned.into_inner());
7659 managed.reattach_record_holders(
7660 &managed_worktree_record_holders(&records),
7661 unix_time_ms(),
7662 );
7663 let materializations = self
7664 .materializations
7665 .lock()
7666 .unwrap_or_else(|poisoned| poisoned.into_inner())
7667 .values()
7668 .cloned()
7669 .collect::<Vec<_>>();
7670 let managed_spawn_replays = self.managed_spawn_replays
7671 .lock()
7672 .unwrap_or_else(|poisoned| poisoned.into_inner())
7673 .values()
7674 .cloned()
7675 .collect::<Vec<_>>();
7676 let result = session_registry::save_v10(
7677 self.state_path.as_deref(),
7678 &self.node_id,
7679 &workspaces,
7680 &records,
7681 &managed.records(),
7682 &managed.tombstones(),
7683 &materializations,
7684 &managed_spawn_replays,
7685 );
7686 match &result {
7687 Ok(warning) => self.set_persistence_error(warning.clone()),
7688 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7689 }
7690 result.map(|_| ())
7691 }
7692
7693 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7694 let _mutation_guard = self.mutation_gate.lock().await;
7695 self.begin_shutdown_locked().await
7696 }
7697
7698 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7699 if self.shutdown.swap(true, Ordering::AcqRel) {
7700 return Ok(());
7701 }
7702 SessionLifecycleHook::on_shutdown(self);
7703 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7704 let mut first_error = None;
7705 for session in &self.handle.snapshot().sessions {
7706 let remaining = deadline.saturating_duration_since(Instant::now());
7707 if remaining.is_zero() {
7708 first_error.get_or_insert_with(|| {
7709 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7710 });
7711 break;
7712 }
7713 if let Err(error) = self
7714 .dispatch_bounded(
7715 ControlCommand::Stop {
7716 instance_id: session.instance_id,
7717 force: true,
7718 },
7719 remaining,
7720 )
7721 .await
7722 {
7723 first_error.get_or_insert(error);
7724 }
7725 }
7726 self.shutdown_notify.notify_waiters();
7727 match first_error {
7728 Some(error) => Err(error),
7729 None => Ok(()),
7730 }
7731 }
7732
7733 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7734 let bindings = self
7735 .session_bindings
7736 .lock()
7737 .unwrap_or_else(|poisoned| poisoned.into_inner());
7738 if bindings.len() >= CONTROL_SESSIONS_MAX {
7739 Err(failure(
7740 NodeFailureCode::BackendBusy,
7741 "node session-binding capacity is full; remove an existing session first",
7742 ))
7743 } else {
7744 Ok(())
7745 }
7746 }
7747
7748 fn ensure_workspace_environment_profile_compatible(
7755 &self,
7756 workspace_id: &WorkspaceId,
7757 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7758 ) -> Result<(), NodeFailure> {
7759 let bindings = self
7760 .session_bindings
7761 .lock()
7762 .unwrap_or_else(|poisoned| poisoned.into_inner());
7763 for binding in bindings.values() {
7764 if &binding.workspace_id != workspace_id {
7765 continue;
7766 }
7767 if binding.environment_profile.as_ref() != environment_profile {
7768 return Err(failure(
7769 NodeFailureCode::WorkspaceBusy,
7770 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7771 ));
7772 }
7773 }
7774 Ok(())
7775 }
7776
7777 #[cfg(test)]
7778 fn bind_session(
7779 &self,
7780 instance_id: AgentInstanceId,
7781 workspace_id: WorkspaceId,
7782 generation: SessionGeneration,
7783 ) {
7784 self.bind_session_with_policy(
7785 &SessionAddress {
7786 workspace_id,
7787 session: SessionKey { instance_id, generation },
7788 },
7789 ProviderRuntimePolicy::raw_pty(),
7790 None,
7791 );
7792 }
7793
7794 #[cfg(test)]
7795 fn bind_session_with_policy(
7796 &self,
7797 address: &SessionAddress,
7798 runtime_policy: ProviderRuntimePolicy,
7799 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7800 ) {
7801 self.bind_session_with_materialization(
7802 address,
7803 runtime_policy,
7804 environment_profile,
7805 None,
7806 None,
7807 None,
7808 );
7809 }
7810
7811 fn bind_session_with_materialization(
7812 &self,
7813 address: &SessionAddress,
7814 runtime_policy: ProviderRuntimePolicy,
7815 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7816 bundle: Option<ResolvedBundleReceipt>,
7817 context: Option<ResolvedContextPackReceipt>,
7818 materialization_id: Option<MaterializationId>,
7819 ) {
7820 let mut bindings = self
7821 .session_bindings
7822 .lock()
7823 .unwrap_or_else(|poisoned| poisoned.into_inner());
7824 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7825 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7826 bindings.insert(address.session.instance_id, SessionBinding {
7827 workspace_id: address.workspace_id.clone(),
7828 generation: address.session.generation,
7829 runtime_policy,
7830 pending_resume: None,
7831 record_id: None,
7832 managed_worktree_lease_id: None,
7833 environment_profile,
7834 bundle,
7835 context,
7836 materialization_id,
7837 });
7838 }
7839
7840 #[cfg(test)]
7841 fn bind_managed_session(
7842 &self,
7843 address: &SessionAddress,
7844 record_id: SessionRecordId,
7845 runtime_policy: ProviderRuntimePolicy,
7846 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7847 ) {
7848 self.bind_managed_session_with_materialization(
7849 address,
7850 record_id,
7851 runtime_policy,
7852 environment_profile,
7853 None,
7854 None,
7855 None,
7856 );
7857 }
7858
7859 fn bind_managed_session_with_materialization(
7860 &self,
7861 address: &SessionAddress,
7862 record_id: SessionRecordId,
7863 runtime_policy: ProviderRuntimePolicy,
7864 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7865 bundle: Option<ResolvedBundleReceipt>,
7866 context: Option<ResolvedContextPackReceipt>,
7867 materialization_id: Option<MaterializationId>,
7868 ) {
7869 let mut bindings = self
7870 .session_bindings
7871 .lock()
7872 .unwrap_or_else(|poisoned| poisoned.into_inner());
7873 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7874 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7875 bindings.insert(
7876 address.session.instance_id,
7877 SessionBinding {
7878 workspace_id: address.workspace_id.clone(),
7879 generation: address.session.generation,
7880 runtime_policy,
7881 pending_resume: None,
7882 record_id: Some(record_id),
7883 managed_worktree_lease_id: None,
7884 environment_profile,
7885 bundle,
7886 context,
7887 materialization_id,
7888 },
7889 );
7890 }
7891
7892 #[cfg(test)]
7893 fn bind_spawn_session(
7894 &self,
7895 address: &SessionAddress,
7896 provider: AgentId,
7897 mode: SessionMode,
7898 runtime_policy: ProviderRuntimePolicy,
7899 record_policy: SpawnRecordPolicy,
7900 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7901 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7902 self.bind_spawn_session_with_materialization(
7903 address,
7904 provider,
7905 mode,
7906 runtime_policy,
7907 record_policy,
7908 environment_profile,
7909 None,
7910 None,
7911 None,
7912 )
7913 }
7914
7915 fn bind_spawn_session_with_materialization(
7916 &self,
7917 address: &SessionAddress,
7918 provider: AgentId,
7919 mode: SessionMode,
7920 runtime_policy: ProviderRuntimePolicy,
7921 record_policy: SpawnRecordPolicy,
7922 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7923 bundle: Option<ResolvedBundleReceipt>,
7924 context: Option<ResolvedContextPackReceipt>,
7925 materialization_id: Option<MaterializationId>,
7926 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7927 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7928 && !runtime_policy.provider_session_identity
7929 {
7930 self.bind_session_with_materialization(
7931 address,
7932 runtime_policy,
7933 environment_profile,
7934 bundle,
7935 context,
7936 materialization_id,
7937 );
7938 return Ok(None);
7939 }
7940 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7941 let record = self.new_record(
7942 address,
7943 provider,
7944 mode,
7945 active_record_state(runtime_policy.provider_session_identity, false),
7946 None,
7947 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7948 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7949 record_owns_lifecycle.then(|| context.clone()).flatten(),
7950 )?;
7951 let record_id = record.record_id.clone();
7952 let transaction = self
7953 .state_transaction
7954 .lock()
7955 .unwrap_or_else(|poisoned| poisoned.into_inner());
7956 self.insert_record(record.clone())?;
7957 let previous_materialization = if record_owns_lifecycle {
7958 if let Some(materialization_id) = materialization_id.as_ref() {
7959 let mut materializations = self
7960 .materializations
7961 .lock()
7962 .unwrap_or_else(|poisoned| poisoned.into_inner());
7963 let Some(ownership) = materializations.get_mut(materialization_id) else {
7964 drop(materializations);
7965 self.remove_record_memory(&record_id);
7966 return Err(failure(
7967 NodeFailureCode::BackendOperationFailed,
7968 "session environment ownership disappeared before binding",
7969 ));
7970 };
7971 let previous = ownership.clone();
7972 if ownership
7973 .transfer_to_record(record_id.clone(), unix_time_ms())
7974 .is_err()
7975 {
7976 drop(materializations);
7977 self.remove_record_memory(&record_id);
7978 return Err(failure(
7979 NodeFailureCode::BackendOperationFailed,
7980 "session environment ownership transfer failed",
7981 ));
7982 }
7983 Some(previous)
7984 } else {
7985 None
7986 }
7987 } else {
7988 None
7989 };
7990 self.bind_managed_session_with_materialization(
7991 address,
7992 record_id.clone(),
7993 runtime_policy,
7994 environment_profile,
7995 bundle,
7996 context,
7997 materialization_id.clone(),
7998 );
7999 if let Err(error) = self.persist_state_locked() {
8000 self.remove_binding(address);
8001 self.remove_record_memory(&record_id);
8002 if let (Some(materialization_id), Some(previous)) =
8003 (materialization_id.as_ref(), previous_materialization)
8004 {
8005 self.materializations
8006 .lock()
8007 .unwrap_or_else(|poisoned| poisoned.into_inner())
8008 .insert(materialization_id.clone(), previous);
8009 }
8010 return Err(persistence_failure(error));
8011 }
8012 drop(transaction);
8013 self.publish_record(record);
8014 Ok(Some(record_id))
8015 }
8016
8017 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8018 for _ in 0..8 {
8019 let bytes = random_nonce().map_err(|error| {
8020 failure(
8021 NodeFailureCode::BackendOperationFailed,
8022 &format!("session record identity generation failed: {error}"),
8023 )
8024 })?;
8025 let mut value = String::with_capacity(27);
8026 value.push_str("sr-");
8027 for byte in &bytes[..12] {
8028 use std::fmt::Write as _;
8029 let _ = write!(&mut value, "{byte:02x}");
8030 }
8031 let record_id = SessionRecordId::new(value).map_err(|error| {
8032 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8033 })?;
8034 let records = self
8035 .session_records
8036 .lock()
8037 .unwrap_or_else(|poisoned| poisoned.into_inner());
8038 if !records.records.contains_key(&record_id) {
8039 return Ok(record_id);
8040 }
8041 }
8042 Err(failure(
8043 NodeFailureCode::BackendBusy,
8044 "could not allocate a unique session record ID",
8045 ))
8046 }
8047
8048 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8049 for _ in 0..8 {
8050 let bytes = random_nonce().map_err(|error| {
8051 failure(
8052 NodeFailureCode::BackendOperationFailed,
8053 &format!("task identity generation failed: {error}"),
8054 )
8055 })?;
8056 let mut nonce = [0_u8; 12];
8057 nonce.copy_from_slice(&bytes[..12]);
8058 let task_id = TaskId::from_nonce(nonce);
8059 let records = self
8060 .session_records
8061 .lock()
8062 .unwrap_or_else(|poisoned| poisoned.into_inner());
8063 if !records.records.values().any(|record| {
8064 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8065 == Some(&task_id)
8066 }) {
8067 return Ok(task_id);
8068 }
8069 }
8070 Err(failure(
8071 NodeFailureCode::BackendBusy,
8072 "could not allocate a unique task ID",
8073 ))
8074 }
8075
8076 fn default_record_name(&self, provider: &AgentId) -> String {
8077 let records = self
8078 .session_records
8079 .lock()
8080 .unwrap_or_else(|poisoned| poisoned.into_inner());
8081 let ordinal = records
8082 .records
8083 .values()
8084 .filter(|record| &record.provider == provider)
8085 .count()
8086 .saturating_add(1);
8087 format!("{} #{ordinal}", provider.as_str())
8088 }
8089
8090 fn new_record(
8091 &self,
8092 address: &SessionAddress,
8093 provider: AgentId,
8094 mode: SessionMode,
8095 state: ManagedSessionState,
8096 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8097 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8098 bundle: Option<ResolvedBundleReceipt>,
8099 context: Option<ResolvedContextPackReceipt>,
8100 ) -> Result<ManagedSessionRecord, NodeFailure> {
8101 let canonical_root = self.workspace_root(&address.workspace_id)?;
8102 let now = unix_time_ms();
8103 Ok(ManagedSessionRecord {
8104 record_id: self.allocate_record_id()?,
8105 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8106 provider,
8107 mode,
8108 state,
8109 workspace_id: address.workspace_id.clone(),
8110 canonical_root: opaque_windows_path(canonical_root),
8111 provider_session,
8112 active_session: Some(address.clone()),
8113 environment_profile,
8114 bundle,
8115 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8116 context,
8117 exported_context: None,
8118 task_binding: None,
8119 created_at_unix_ms: now,
8120 updated_at_unix_ms: now,
8121 last_error: None,
8122 })
8123 }
8124
8125 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8126 let mut records = self
8127 .session_records
8128 .lock()
8129 .unwrap_or_else(|poisoned| poisoned.into_inner());
8130 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8131 return Err(failure(
8132 NodeFailureCode::BackendBusy,
8133 "managed session record capacity is full; forget an inactive record first",
8134 ));
8135 }
8136 if let Some(identity) = record.provider_session.as_ref() {
8137 if records.records.values().any(|candidate| {
8138 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8139 session_registry::same_provider_session(
8140 &candidate.provider,
8141 candidate_identity,
8142 &record.provider,
8143 identity,
8144 )
8145 })
8146 }) {
8147 return Err(failure(
8148 NodeFailureCode::SessionRecordConflict,
8149 "provider session is already represented by another managed record",
8150 ));
8151 }
8152 }
8153 match records.records.entry(record.record_id.clone()) {
8154 std::collections::btree_map::Entry::Vacant(entry) => {
8155 entry.insert(record);
8156 }
8157 std::collections::btree_map::Entry::Occupied(_) => {
8158 return Err(failure(
8159 NodeFailureCode::SessionRecordConflict,
8160 "session record ID collision",
8161 ));
8162 }
8163 }
8164 Ok(())
8165 }
8166
8167 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8168 self.session_records
8169 .lock()
8170 .unwrap_or_else(|poisoned| poisoned.into_inner())
8171 .records
8172 .get(record_id)
8173 .cloned()
8174 .ok_or_else(|| {
8175 failure(
8176 NodeFailureCode::UnknownSessionRecord,
8177 "managed session record does not exist",
8178 )
8179 })
8180 }
8181
8182 fn revalidate_session_record_preview(
8183 &self,
8184 expected: &ManagedSessionRecord,
8185 identity: &ProviderSessionIdentity,
8186 ) -> Result<(), NodeFailure> {
8187 let current = self.record(&expected.record_id)?;
8188 let current_root = self.workspace_root(¤t.workspace_id)?;
8189 if current.provider != expected.provider
8190 || current.workspace_id != expected.workspace_id
8191 || current.canonical_root != expected.canonical_root
8192 || current.provider_session.as_ref() != Some(identity)
8193 || !platform::roots_equal(
8194 ¤t_root,
8195 windows_path_text(¤t.canonical_root),
8196 )
8197 {
8198 return Err(failure(
8199 NodeFailureCode::SessionRecordConflict,
8200 "managed session changed while its preview was loading",
8201 ));
8202 }
8203 Ok(())
8204 }
8205
8206 fn revalidate_session_record_context_export(
8207 &self,
8208 expected: &ManagedSessionRecord,
8209 identity: Option<&ProviderSessionIdentity>,
8210 session: &SessionAddress,
8211 allow_clean_detachment: bool,
8212 ) -> Result<(), NodeFailure> {
8213 let current = self.record(&expected.record_id)?;
8214 let current_root = self.workspace_root(¤t.workspace_id)?;
8215 let snapshot = self.internal_session_snapshot(session)?;
8216 let binding = self
8217 .session_bindings
8218 .lock()
8219 .unwrap_or_else(|poisoned| poisoned.into_inner())
8220 .get(&session.session.instance_id)
8221 .cloned()
8222 .ok_or_else(|| {
8223 failure(
8224 NodeFailureCode::SessionRecordConflict,
8225 "managed session runtime binding disappeared during context export",
8226 )
8227 })?;
8228 if !session_record_context_export_binding_is_exact(
8229 expected,
8230 ¤t,
8231 identity,
8232 session,
8233 &binding,
8234 &snapshot.agent_id,
8235 ¤t_root,
8236 allow_clean_detachment,
8237 ) || !session_record_context_export_source_is_usable(
8238 ¤t,
8239 &snapshot.status,
8240 allow_clean_detachment,
8241 ) {
8242 return Err(failure(
8243 NodeFailureCode::SessionRecordConflict,
8244 "managed session binding changed during context export",
8245 ));
8246 }
8247 Ok(())
8248 }
8249
8250 fn revalidate_loaded_history(
8251 &self,
8252 session: &SessionAddress,
8253 candidate_id: &str,
8254 loaded: &HistorySessionRecord,
8255 ) -> Result<(), NodeFailure> {
8256 let snapshot = self.internal_session_snapshot(session)?;
8257 if snapshot.history.pending.is_some()
8258 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8259 || snapshot.history.loaded.as_ref() != Some(loaded)
8260 {
8261 return Err(failure(
8262 NodeFailureCode::SessionRecordConflict,
8263 "managed session history changed during context export",
8264 ));
8265 }
8266 Ok(())
8267 }
8268
8269 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8270 self.session_records
8271 .lock()
8272 .unwrap_or_else(|poisoned| poisoned.into_inner())
8273 .records
8274 .remove(record_id)
8275 }
8276
8277 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8278 if self.destroy_record_materialization(record_id)? {
8279 self.publish(NodeEvent::SessionRecordRemoved {
8280 record_id: record_id.clone(),
8281 });
8282 return Ok(());
8283 }
8284 let transaction = self
8285 .state_transaction
8286 .lock()
8287 .unwrap_or_else(|poisoned| poisoned.into_inner());
8288 let Some(removed) = self.remove_record_memory(record_id) else {
8289 return Ok(());
8290 };
8291 if let Err(error) = self.persist_state_locked() {
8292 let _ = self.insert_record(removed);
8293 return Err(persistence_failure(error));
8294 }
8295 drop(transaction);
8296 self.publish(NodeEvent::SessionRecordRemoved {
8297 record_id: record_id.clone(),
8298 });
8299 Ok(())
8300 }
8301
8302 fn publish_record(&self, record: ManagedSessionRecord) {
8303 self.publish(NodeEvent::SessionRecordUpserted { record });
8304 }
8305
8306 fn mark_record_error(
8307 &self,
8308 record_id: &SessionRecordId,
8309 message: &str,
8310 ) -> Result<(), NodeFailure> {
8311 let transaction = self
8312 .state_transaction
8313 .lock()
8314 .unwrap_or_else(|poisoned| poisoned.into_inner());
8315 let previous = {
8316 let mut records = self
8317 .session_records
8318 .lock()
8319 .unwrap_or_else(|poisoned| poisoned.into_inner());
8320 let Some(record) = records.records.get_mut(record_id) else {
8321 return Ok(());
8322 };
8323 let previous = record.clone();
8324 record.state = ManagedSessionState::Unavailable;
8325 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8326 record.updated_at_unix_ms = unix_time_ms();
8327 previous
8328 };
8329 if let Err(error) = self.persist_state_locked() {
8330 self.session_records
8331 .lock()
8332 .unwrap_or_else(|poisoned| poisoned.into_inner())
8333 .records
8334 .insert(record_id.clone(), previous);
8335 return Err(persistence_failure(error));
8336 }
8337 let record = self.record(record_id)?;
8338 drop(transaction);
8339 self.publish_record(record);
8340 Ok(())
8341 }
8342
8343 fn rename_session_record(
8344 &self,
8345 record_id: &SessionRecordId,
8346 display_name: String,
8347 ) -> Result<ManagedSessionRecord, NodeFailure> {
8348 validate_display_name(&display_name)
8349 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8350 let _transaction = self
8351 .state_transaction
8352 .lock()
8353 .unwrap_or_else(|poisoned| poisoned.into_inner());
8354 let previous = {
8355 let mut records = self
8356 .session_records
8357 .lock()
8358 .unwrap_or_else(|poisoned| poisoned.into_inner());
8359 let record = records.records.get_mut(record_id).ok_or_else(|| {
8360 failure(
8361 NodeFailureCode::UnknownSessionRecord,
8362 "managed session record does not exist",
8363 )
8364 })?;
8365 let previous = record.clone();
8366 record.display_name = display_name;
8367 record.updated_at_unix_ms = unix_time_ms();
8368 previous
8369 };
8370 if let Err(error) = self.persist_state_locked() {
8371 self.session_records
8372 .lock()
8373 .unwrap_or_else(|poisoned| poisoned.into_inner())
8374 .records
8375 .insert(record_id.clone(), previous);
8376 return Err(persistence_failure(error));
8377 }
8378 let updated = self.record(record_id)?;
8379 self.publish_record(updated.clone());
8380 Ok(updated)
8381 }
8382
8383 fn set_session_task(
8384 &self,
8385 record_id: &SessionRecordId,
8386 expected_revision: u64,
8387 target: SessionTaskTargetV1,
8388 ) -> Result<ManagedSessionRecord, NodeFailure> {
8389 let transaction = self
8390 .state_transaction
8391 .lock()
8392 .unwrap_or_else(|poisoned| poisoned.into_inner());
8393 let current = self.record(record_id)?;
8394 let current_task_id = current
8395 .task_binding
8396 .as_ref()
8397 .and_then(|binding| binding.task_id.as_ref());
8398 let current_revision = current
8399 .task_binding
8400 .as_ref()
8401 .map_or(0, |binding| binding.revision);
8402 if expected_revision != current_revision {
8403 return Err(failure(
8404 NodeFailureCode::SessionRecordConflict,
8405 "managed session task binding revision changed",
8406 ));
8407 }
8408 let exact = match &target {
8409 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8410 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8411 SessionTaskTargetV1::New => false,
8412 };
8413 if exact {
8414 return Ok(current);
8415 }
8416 let revision = current_revision.checked_add(1).ok_or_else(|| {
8417 failure(
8418 NodeFailureCode::SessionRecordConflict,
8419 "managed session task binding revision is exhausted",
8420 )
8421 })?;
8422 let task_id = match target {
8423 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8424 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8425 SessionTaskTargetV1::Clear => None,
8426 };
8427 let changed_at_unix_ms = unix_time_ms()
8428 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8429 failure(
8430 NodeFailureCode::SessionRecordConflict,
8431 "managed session record timestamp is exhausted",
8432 )
8433 })?);
8434 let updated = {
8435 let mut records = self
8436 .session_records
8437 .lock()
8438 .unwrap_or_else(|poisoned| poisoned.into_inner());
8439 let record = records.records.get_mut(record_id).ok_or_else(|| {
8440 failure(
8441 NodeFailureCode::UnknownSessionRecord,
8442 "managed session record does not exist",
8443 )
8444 })?;
8445 record.task_binding = Some(SessionTaskBindingV1 {
8446 revision,
8447 task_id,
8448 changed_at_unix_ms,
8449 });
8450 record.updated_at_unix_ms = changed_at_unix_ms;
8451 record.clone()
8452 };
8453 if let Err(error) = self.persist_state_locked() {
8454 self.session_records
8455 .lock()
8456 .unwrap_or_else(|poisoned| poisoned.into_inner())
8457 .records
8458 .insert(record_id.clone(), current);
8459 return Err(persistence_failure(error));
8460 }
8461 drop(transaction);
8462 self.publish_record(updated.clone());
8463 Ok(updated)
8464 }
8465
8466 fn index_provider_session(
8467 &self,
8468 workspace_id: WorkspaceId,
8469 provider: AgentId,
8470 identity: ProviderSessionIdentity,
8471 display_name: String,
8472 ) -> Result<ManagedSessionRecord, NodeFailure> {
8473 self.index_provider_session_with_policy(
8474 workspace_id,
8475 provider,
8476 identity,
8477 display_name,
8478 false,
8479 )
8480 }
8481
8482 fn index_provider_session_with_policy(
8483 &self,
8484 workspace_id: WorkspaceId,
8485 provider: AgentId,
8486 identity: ProviderSessionIdentity,
8487 display_name: String,
8488 allow_validated_transcript_path: bool,
8489 ) -> Result<ManagedSessionRecord, NodeFailure> {
8490 validate_display_name(&display_name)
8491 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8492 identity
8493 .validate()
8494 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8495 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8496 return Err(failure(
8497 NodeFailureCode::InvalidRequest,
8498 "provider session reference index v1 does not accept transcript paths",
8499 ));
8500 }
8501 let canonical_root = self.workspace_root(&workspace_id)?;
8502 if !self.enabled_providers.contains(&provider) {
8503 return Err(failure(
8504 NodeFailureCode::InvalidRequest,
8505 "provider is not enabled on this node",
8506 ));
8507 }
8508
8509 let transaction = self
8510 .state_transaction
8511 .lock()
8512 .unwrap_or_else(|poisoned| poisoned.into_inner());
8513 {
8514 let records = self
8515 .session_records
8516 .lock()
8517 .unwrap_or_else(|poisoned| poisoned.into_inner());
8518 if let Some(existing) = records.records.values().find(|candidate| {
8519 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8520 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8521 })
8522 }) {
8523 if existing.provider == provider && existing.workspace_id == workspace_id {
8524 return Ok(existing.clone());
8525 }
8526 return Err(failure(
8527 NodeFailureCode::SessionRecordConflict,
8528 "provider session reference conflicts with another provider or workspace",
8529 ));
8530 }
8531 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8532 return Err(failure(
8533 NodeFailureCode::BackendBusy,
8534 "managed session record capacity is full; forget an inactive record first",
8535 ));
8536 }
8537 }
8538
8539 let now = unix_time_ms();
8540 let record = ManagedSessionRecord {
8541 record_id: self.allocate_record_id()?,
8542 display_name,
8543 provider,
8544 mode: SessionMode::Pty,
8545 state: ManagedSessionState::Dormant,
8546 workspace_id,
8547 canonical_root: opaque_windows_path(canonical_root),
8548 provider_session: Some(identity),
8549 active_session: None,
8550 environment_profile: None,
8551 bundle: None,
8552 context_id: None,
8553 context: None,
8554 exported_context: None,
8555 task_binding: None,
8556 created_at_unix_ms: now,
8557 updated_at_unix_ms: now,
8558 last_error: None,
8559 };
8560 self.insert_record(record.clone())?;
8561 if let Err(error) = self.persist_state_locked() {
8562 self.remove_record_memory(&record.record_id);
8563 return Err(persistence_failure(error));
8564 }
8565 drop(transaction);
8566 self.publish_record(record.clone());
8567 Ok(record)
8568 }
8569
8570 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8571 self.session_bindings
8572 .lock()
8573 .unwrap_or_else(|poisoned| poisoned.into_inner())
8574 .iter()
8575 .find_map(|(instance_id, binding)| {
8576 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8577 workspace_id: binding.workspace_id.clone(),
8578 session: SessionKey {
8579 instance_id: *instance_id,
8580 generation: binding.generation,
8581 },
8582 })
8583 })
8584 }
8585
8586 async fn forget_session_record(
8587 &self,
8588 record_id: &SessionRecordId,
8589 ) -> Result<(), NodeFailure> {
8590 let record = self.record(record_id)?;
8591 if record.active_session.is_some()
8592 || matches!(
8593 record.state,
8594 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8595 )
8596 {
8597 return Err(failure(
8598 NodeFailureCode::SessionRecordBusy,
8599 "stop the managed session before forgetting it",
8600 ));
8601 }
8602 if let Some(address) = self.bound_address_for_record(record_id) {
8603 self.remove_session(&address).await?;
8604 }
8605 self.remove_managed_session_record(record_id, &record.workspace_id).await
8606 }
8607
8608 async fn remove_managed_session_record(
8623 &self,
8624 record_id: &SessionRecordId,
8625 workspace_id: &WorkspaceId,
8626 ) -> Result<(), NodeFailure> {
8627 let managed_lease_id = self.managed_worktrees.lock()
8628 .unwrap_or_else(|poisoned| poisoned.into_inner())
8629 .lease_for_workspace(workspace_id);
8630 if !self.destroy_record_materialization(record_id)? {
8631 let transaction = self
8632 .state_transaction
8633 .lock()
8634 .unwrap_or_else(|poisoned| poisoned.into_inner());
8635 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8636 failure(
8637 NodeFailureCode::UnknownSessionRecord,
8638 "managed session record does not exist",
8639 )
8640 })?;
8641 if let Err(error) = self.persist_state_locked() {
8642 let _ = self.insert_record(removed);
8643 return Err(persistence_failure(error));
8644 }
8645 drop(transaction);
8646 }
8647 self.publish(NodeEvent::SessionRecordRemoved {
8648 record_id: record_id.clone(),
8649 });
8650 if let Some(lease_id) = managed_lease_id {
8651 if let Some(snapshot) = self.managed_worktrees.lock()
8652 .unwrap_or_else(|poisoned| poisoned.into_inner())
8653 .get(&lease_id)
8654 .map(ManagedWorktreeLeaseRecord::snapshot)
8655 {
8656 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8657 }
8658 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8659 }
8660 Ok(())
8661 }
8662
8663 async fn retire_unavailable_session_records(&self) {
8676 let policy = self.session_record_retention;
8677 let age_enabled = policy.age_ms > 0;
8678 let keep_enabled = policy.keep_per_workspace > 0;
8679 if !age_enabled && !keep_enabled {
8680 return;
8681 }
8682 let now = unix_time_ms();
8683 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8684 self.session_records
8685 .lock()
8686 .unwrap_or_else(|poisoned| poisoned.into_inner())
8687 .records
8688 .values()
8689 .filter(|record| record.state == ManagedSessionState::Unavailable)
8690 .map(|record| {
8691 (
8692 record.record_id.clone(),
8693 record.workspace_id.clone(),
8694 record.provider.clone(),
8695 record.updated_at_unix_ms,
8696 )
8697 })
8698 .collect()
8699 };
8700 if candidates.is_empty() {
8701 return;
8702 }
8703 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8704 BTreeMap::new();
8705 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8706 by_workspace
8707 .entry(workspace_id)
8708 .or_default()
8709 .push((record_id, provider, updated_at_unix_ms));
8710 }
8711 let reason = if age_enabled && keep_enabled {
8712 "age+keep-n"
8713 } else if age_enabled {
8714 "age"
8715 } else {
8716 "keep-n"
8717 };
8718 let mut retired: u64 = 0;
8719 for (workspace_id, mut records) in by_workspace {
8720 records.sort_by(|left, right| right.2.cmp(&left.2));
8723 for (index, (record_id, provider, updated_at_unix_ms)) in
8724 records.into_iter().enumerate()
8725 {
8726 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8727 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8728 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8729 let should_retire = if age_enabled && keep_enabled {
8730 age_hit && keep_hit
8731 } else {
8732 age_hit || keep_hit
8733 };
8734 if !should_retire {
8735 continue;
8736 }
8737 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8738 Ok(()) => {
8739 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8740 retired += 1;
8741 tracing::info!(
8742 node_id = %self.node_id,
8743 record_id = %record_id,
8744 workspace_id = %workspace_id,
8745 provider = %provider,
8746 age_ms = record_age_ms,
8747 reason,
8748 "managed session record retired"
8749 );
8750 }
8751 Err(error) => tracing::warn!(
8752 node_id = %self.node_id,
8753 record_id = %record_id,
8754 workspace_id = %workspace_id,
8755 code = ?error.code,
8756 cause = %error.message,
8757 "session record retention sweep failed to retire a record"
8758 ),
8759 }
8760 }
8761 }
8762 if retired > 0 {
8763 let remaining_unavailable = self
8764 .session_records
8765 .lock()
8766 .unwrap_or_else(|poisoned| poisoned.into_inner())
8767 .records
8768 .values()
8769 .filter(|record| record.state == ManagedSessionState::Unavailable)
8770 .count();
8771 tracing::info!(
8772 node_id = %self.node_id,
8773 retired,
8774 remaining_unavailable,
8775 "session record retention sweep completed"
8776 );
8777 }
8778 }
8779
8780 async fn resume_session_record(
8781 &self,
8782 record_id: &SessionRecordId,
8783 terminal_size: gate4agent_types::TerminalSize,
8784 initial_prompt: Option<String>,
8785 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8786 let mut record = self.record(record_id)?;
8787 if record.mode == SessionMode::Acp
8790 && record.provider.as_str() == "claude"
8791 && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8792 {
8793 record.mode = SessionMode::Pty;
8794 if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8795 r.mode = SessionMode::Pty;
8796 }
8797 tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8798 }
8799 if record.active_session.is_some()
8800 || matches!(
8801 record.state,
8802 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8803 )
8804 {
8805 return Err(failure(
8806 NodeFailureCode::SessionRecordBusy,
8807 "managed session already has a live runtime binding",
8808 ));
8809 }
8810 let identity = record.provider_session.clone().ok_or_else(|| {
8811 failure(
8812 NodeFailureCode::SessionRecordNotResumable,
8813 "managed session has no verified provider session identity",
8814 )
8815 })?;
8816 let working_directory = self.workspace_root(&record.workspace_id)?;
8817 if !platform::roots_equal(
8818 &working_directory,
8819 windows_path_text(&record.canonical_root),
8820 ) {
8821 return Err(failure(
8822 NodeFailureCode::SessionRecordConflict,
8823 "managed session workspace root changed; refusing to resume in another directory",
8824 ));
8825 }
8826 {
8827 let records = self
8828 .session_records
8829 .lock()
8830 .unwrap_or_else(|poisoned| poisoned.into_inner());
8831 if records.records.values().any(|candidate| {
8832 candidate.record_id != *record_id
8833 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8834 session_registry::same_provider_session(
8835 &candidate.provider,
8836 candidate_identity,
8837 &record.provider,
8838 &identity,
8839 )
8840 })
8841 && (candidate.active_session.is_some()
8842 || matches!(
8843 candidate.state,
8844 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8845 ))
8846 }) {
8847 return Err(failure(
8848 NodeFailureCode::SessionRecordConflict,
8849 "provider session is already active through another managed record",
8850 ));
8851 }
8852 }
8853 let runtime_requirement = if initial_prompt.is_some() {
8854 ProviderRuntimeRequirement::ResumeWithPrompt
8855 } else {
8856 ProviderRuntimeRequirement::Resume
8857 };
8858 let runtime_policy = self.admit_provider_runtime(
8859 &record.provider,
8860 runtime_requirement,
8861 )
8862 .await?;
8863 let working_directory = match self.m4a_accounts.as_ref() {
8865 Some(_) => {
8866 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8867 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8868 }
8869 None => working_directory,
8870 };
8871 let request = ResumeLaunchRequest {
8872 working_directory,
8873 terminal_size,
8874 initial_prompt,
8875 };
8876 request
8877 .validate()
8878 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8879 self.ensure_binding_capacity()?;
8880 let resolved_materialization = match self.resolve_record_materialization(
8881 record_id,
8882 &record.provider,
8883 record.mode,
8884 record.environment_profile.as_ref(),
8885 record.bundle.as_ref(),
8886 record.context.as_ref(),
8887 ) {
8888 Ok(materialization) => materialization,
8889 Err(error) => {
8890 let label = match error.code {
8891 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8892 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8893 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8894 _ => "environment-profile-unavailable",
8895 };
8896 self.mark_record_error(record_id, label)?;
8897 return Err(error);
8898 }
8899 };
8900 let (materialization_id, environment_overlay) = match resolved_materialization {
8901 Some((id, overlay)) => (Some(id), overlay),
8902 None => (None, None),
8903 };
8904 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8905 let environment_overlay = {
8907 let mut args: Vec<OsString> = Vec::new();
8908 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8909 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8910 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8911 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8912 }
8913 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8914 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8915 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8916 if let Some(sel) = options.as_ref() {
8917 args.push("--model".into()); args.push(sel.model.clone().into());
8918 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8919 args.push("--effort".into()); args.push(effort.to_owned().into());
8920 }
8921 }
8922 }
8923 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8924 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8925 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8926 instance_id: instance_id.0,
8927 provider: record.provider.as_str().to_owned(),
8928 workspace_id: record.workspace_id.as_str().to_owned(),
8929 provider_session_id: identity.id.clone(),
8930 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8931 cwd: String::new(),
8932 acp: record.mode == SessionMode::Acp,
8933 options,
8934 cols: 0,
8935 rows: 0,
8936 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8937 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8938 });
8939 }
8940 }
8941 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8942 };
8943 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8945 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8946 if let Some(old) = self.bound_address_for_record(record_id) {
8947 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8948 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8949 }
8950 }
8951 let session_key = self.m4a_session_key(instance_id);
8952 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8953 }
8954 _ => environment_overlay,
8955 };
8956 let mut environment_selection = match SpawnPreparer::select_environment(self,
8957 instance_id,
8958 &record.provider,
8959 record.mode,
8960 record.environment_profile.as_ref(),
8961 ) {
8962 Ok(selection) => selection,
8963 Err(error) => {
8964 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8965 return Err(error);
8966 }
8967 };
8968 let mut instance_overlay = environment_overlay
8969 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8970 .transpose()?
8971 .flatten();
8972 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8973 self.remove_session(&stale_address).await?;
8974 }
8975 let address = SessionAddress {
8976 workspace_id: record.workspace_id.clone(),
8977 session: SessionKey {
8978 instance_id,
8979 generation: SessionGeneration(1),
8980 },
8981 };
8982 let transaction = self
8983 .state_transaction
8984 .lock()
8985 .unwrap_or_else(|poisoned| poisoned.into_inner());
8986 let current = self.record(record_id)?;
8987 if current.active_session.is_some()
8988 || matches!(
8989 current.state,
8990 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8991 )
8992 {
8993 return Err(failure(
8994 NodeFailureCode::SessionRecordBusy,
8995 "managed session became active while resume was being prepared",
8996 ));
8997 }
8998 self.bind_managed_session_with_materialization(
8999 &address,
9000 record_id.clone(),
9001 runtime_policy,
9002 record.environment_profile.clone(),
9003 record.bundle.clone(),
9004 record.context.clone(),
9005 materialization_id,
9006 );
9007 if let Some(selection) = environment_selection.take() {
9008 selection.retain();
9009 }
9010 if let Some(overlay) = instance_overlay.take() {
9011 overlay.retain();
9012 }
9013 drop(transaction);
9014
9015 let transport = match record.mode {
9016 SessionMode::Pty => TransportKind::Pty,
9017 SessionMode::Inline => TransportKind::Pipe,
9018 SessionMode::Acp => TransportKind::Acp,
9019 };
9020 let agent_id = record.provider.clone();
9021 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9022 if let Err(error) = self
9023 .dispatch_bounded(
9024 ControlCommand::Register {
9025 instance_id,
9026 agent_id,
9027 transport,
9028 },
9029 dispatch_timeout,
9030 )
9031 .await
9032 {
9033 if let Some(binding) = self.remove_binding(&address) {
9034 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9035 }
9036 return Err(error);
9037 }
9038 let command = self.prepare_command(ControlCommand::Resume {
9039 instance_id,
9040 target: ResumeTarget::ProviderSession { identity },
9041 runtime_policy,
9042 request,
9043 });
9044 let started_after = self.current_sequence();
9045 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9046 self.rollback_spawn(&address, dispatch_timeout).await?;
9047 return Err(error);
9048 }
9049 if let Err(error) = self.dispatch_envelope(command) {
9050 self.clear_armed_resume(&address);
9051 self.rollback_spawn(&address, dispatch_timeout).await?;
9052 return Err(error);
9053 }
9054 match self
9055 .wait_for_record_resume(&address, started_after)
9056 .await
9057 {
9058 Ok(settled_address) => {
9059 let managed_lease_id = {
9060 self.managed_worktrees.lock()
9061 .unwrap_or_else(|poisoned| poisoned.into_inner())
9062 .lease_for_workspace(&record.workspace_id)
9063 };
9064 if let Some(lease_id) = managed_lease_id {
9065 {
9066 let mut bindings = self.session_bindings.lock()
9067 .unwrap_or_else(|poisoned| poisoned.into_inner());
9068 let binding = bindings.get_mut(&settled_address.session.instance_id)
9069 .ok_or_else(|| failure(
9070 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071 "resumed managed session lost its exact binding",
9072 ))?;
9073 if binding.generation != settled_address.session.generation {
9074 return Err(failure(
9075 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9076 "resumed managed session generation diverged",
9077 ));
9078 }
9079 binding.managed_worktree_lease_id = Some(lease_id.clone());
9080 }
9081 let snapshot = self.managed_worktrees.lock()
9082 .unwrap_or_else(|poisoned| poisoned.into_inner())
9083 .bind_session(
9084 &lease_id,
9085 ManagedWorktreeSessionHolder {
9086 incarnation_id: self.incarnation_id,
9087 instance_id: settled_address.session.instance_id,
9088 generation: settled_address.session.generation,
9089 },
9090 Some(record_id.clone()),
9091 unix_time_ms(),
9092 ).map_err(|message| failure(
9093 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9094 &message,
9095 ))?;
9096 if let Err(error) = self.persist_state() {
9097 let _ = self.remove_session(&settled_address).await;
9098 return Err(persistence_failure(error));
9099 }
9100 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9101 }
9102 Ok((self.record(record_id)?, settled_address))
9103 }
9104 Err(error) => {
9105 if matches!(
9106 error.code,
9107 NodeFailureCode::SessionRecordNotResumable
9108 | NodeFailureCode::BackendOperationFailed
9109 ) {
9110 if let Some(bound) = self.bound_address_for_record(record_id) {
9111 let _ = self.remove_session(&bound).await;
9112 }
9113 }
9114 Err(error)
9115 }
9116 }
9117 }
9118
9119 async fn wait_for_record_resume(
9120 &self,
9121 initial_address: &SessionAddress,
9122 after_sequence: u64,
9123 ) -> Result<SessionAddress, NodeFailure> {
9124 let deadline = Instant::now()
9125 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9126 let mut scan_after = after_sequence;
9127 loop {
9128 let events = self
9129 .history
9130 .lock()
9131 .unwrap_or_else(|poisoned| poisoned.into_inner())
9132 .events
9133 .iter()
9134 .filter(|event| event.sequence > scan_after)
9135 .cloned()
9136 .collect::<Vec<_>>();
9137 for envelope in events {
9138 scan_after = scan_after.max(envelope.sequence);
9139 let NodeEvent::Control { address, event } = envelope.event else {
9140 continue;
9141 };
9142 if address.session.instance_id != initial_address.session.instance_id {
9143 continue;
9144 }
9145 match event.event {
9146 ControlEventKind::Resumed { .. } => return Ok(address),
9147 ControlEventKind::ResumeDenied { reason } => {
9148 return Err(failure(
9149 NodeFailureCode::SessionRecordNotResumable,
9150 &reason,
9151 ));
9152 }
9153 ControlEventKind::ResumeFailed { message }
9154 | ControlEventKind::CommandRejected { message } => {
9155 return Err(failure(
9156 NodeFailureCode::BackendOperationFailed,
9157 &message,
9158 ));
9159 }
9160 _ => {}
9161 }
9162 }
9163 if Instant::now() >= deadline {
9164 return Err(failure(
9165 NodeFailureCode::BackendBusy,
9166 "managed resume did not settle before the bounded deadline",
9167 ));
9168 }
9169 sleep(Duration::from_millis(5)).await;
9170 }
9171 }
9172
9173 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9174 self.workspaces
9175 .read()
9176 .unwrap_or_else(|poisoned| poisoned.into_inner())
9177 .get(workspace_id)
9178 .cloned()
9179 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9180 }
9181
9182 fn workspace_roots(&self) -> Vec<PathBuf> {
9183 self.workspaces
9184 .read()
9185 .unwrap_or_else(|poisoned| poisoned.into_inner())
9186 .values()
9187 .map(PathBuf::from)
9188 .collect()
9189 }
9190
9191 async fn inspect_workspace(
9192 &self,
9193 workspace_id: WorkspaceId,
9194 ) -> Result<WorkspaceInspection, NodeFailure> {
9195 let _permit = self
9196 .inspection_slots
9197 .clone()
9198 .try_acquire_owned()
9199 .map_err(|_| {
9200 tracing::warn!(
9201 workspace_id = %workspace_id,
9202 "workspace inspection rejected: inspection capacity is busy",
9203 );
9204 failure(
9205 NodeFailureCode::BackendBusy,
9206 "workspace inspection capacity is busy",
9207 )
9208 })?;
9209 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9210 tracing::warn!(
9211 workspace_id = %workspace_id,
9212 code = ?error.code,
9213 "workspace inspection rejected: workspace is not registered",
9214 );
9215 error
9216 })?;
9217 let tree_root = canonical_root.clone();
9218 let started_at = Instant::now();
9219 let time_budget_ms = workspace_inspection_time_budget_ms();
9220 let entry_cap = workspace_inspection_entry_cap();
9221 let deadline = started_at + Duration::from_millis(time_budget_ms);
9222 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9223 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9224 })
9225 .await
9226 .map_err(|error| {
9227 tracing::warn!(
9228 workspace_id = %workspace_id,
9229 error = %error,
9230 "workspace inspection rejected: walk task failed",
9231 );
9232 failure(
9233 NodeFailureCode::BackendOperationFailed,
9234 &format!("workspace inspection task failed: {error}"),
9235 )
9236 })?;
9237 let (mut git, git_time_budget_exceeded) =
9238 inspect_git_workspace(&canonical_root, deadline).await;
9239 if !git.worktrees.is_empty() {
9240 let registered = self
9241 .workspaces
9242 .read()
9243 .unwrap_or_else(|poisoned| poisoned.into_inner())
9244 .iter()
9245 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9246 .collect::<Vec<_>>();
9247 for worktree in &mut git.worktrees {
9248 worktree.workspace_id = registered
9249 .iter()
9250 .find(|(_, root)| {
9251 worktree_paths_equal(root, windows_path_text(&worktree.path))
9252 })
9253 .map(|(workspace_id, _)| workspace_id.clone());
9254 }
9255 }
9256 if git.is_repository {
9257 git.managed_worktree = self
9258 .managed_worktrees
9259 .lock()
9260 .unwrap_or_else(|poisoned| poisoned.into_inner())
9261 .git_scope(&workspace_id, git.branch.as_deref());
9262 }
9263 let truncation = workspace_inspection_truncation(
9264 &walk_budget,
9265 git_time_budget_exceeded,
9266 started_at.elapsed(),
9267 );
9268 if let Some(truncation) = &truncation {
9269 tracing::info!(
9270 workspace_id = %workspace_id,
9271 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9272 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9273 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9274 entries_visited = truncation.entries_visited,
9275 elapsed_ms = truncation.elapsed_ms,
9276 "workspace inspection truncated by its inner budget",
9277 );
9278 }
9279 Ok(WorkspaceInspection {
9280 workspace_id,
9281 entries,
9282 tree_truncated,
9283 git,
9284 truncation,
9285 })
9286 }
9287
9288 async fn browse_host_directories(
9289 &self,
9290 directory: Option<OpaqueHostPath>,
9291 after: Option<OpaqueHostPath>,
9292 ) -> Result<HostDirectoryListing, NodeFailure> {
9293 let permit = self
9294 .inspection_slots
9295 .clone()
9296 .try_acquire_owned()
9297 .map_err(|_| {
9298 failure(
9299 NodeFailureCode::BackendBusy,
9300 "host directory browse capacity is busy",
9301 )
9302 })?;
9303 let task = tokio::task::spawn_blocking(move || {
9304 let _permit = permit;
9305 browse_host_directories(directory, after)
9306 });
9307 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9308 .await
9309 .map_err(|_| {
9310 failure(
9311 NodeFailureCode::HostDirectoryReadTimedOut,
9312 "host directory browse exceeded its bounded deadline",
9313 )
9314 })?
9315 .map_err(|_| {
9316 failure(
9317 NodeFailureCode::HostDirectoryReadFailed,
9318 "host directory browse task failed",
9319 )
9320 })?
9321 .map_err(host_directory_failure)
9322 }
9323
9324 async fn read_workspace_file(
9325 &self,
9326 workspace_id: WorkspaceId,
9327 path: RepositoryPath,
9328 ) -> Result<WorkspaceFileRead, NodeFailure> {
9329 let canonical_root = self.workspace_root(&workspace_id)?;
9330 let permit = self
9331 .inspection_slots
9332 .clone()
9333 .try_acquire_owned()
9334 .map_err(|_| {
9335 failure(
9336 NodeFailureCode::BackendBusy,
9337 "workspace file read capacity is busy",
9338 )
9339 })?;
9340 let path_to_read = path.clone();
9341 let task = tokio::task::spawn_blocking(move || {
9342 let _permit = permit;
9343 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9344 });
9345 let content = timeout(
9346 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9347 task,
9348 )
9349 .await
9350 .map_err(|_| {
9351 failure(
9352 NodeFailureCode::RepositoryFileReadTimedOut,
9353 "repository file read exceeded its bounded deadline",
9354 )
9355 })?
9356 .map_err(|_| {
9357 failure(
9358 NodeFailureCode::RepositoryFileReadFailed,
9359 "repository file read task failed",
9360 )
9361 })?
9362 .map_err(workspace_file_failure)?;
9363 let content = match content {
9364 WorkspaceFileBytes::Utf8(text) => {
9365 let revision = Some(workspace_file_revision(text.as_bytes()));
9366 return Ok(WorkspaceFileRead {
9367 workspace_id,
9368 path,
9369 content: WorkspaceFileContent::Utf8 {
9370 byte_len: u32::try_from(text.len())
9371 .expect("bounded workspace text length must fit u32"),
9372 text,
9373 },
9374 revision,
9375 });
9376 }
9377 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9378 WorkspaceFileContent::NonUtf8 {
9379 byte_len: u32::try_from(byte_length)
9380 .expect("bounded workspace file length must fit u32"),
9381 }
9382 }
9383 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9384 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9385 },
9386 };
9387 Ok(WorkspaceFileRead {
9388 workspace_id,
9389 path,
9390 content,
9391 revision: None,
9392 })
9393 }
9394
9395 async fn write_workspace_file(
9396 &self,
9397 workspace_id: WorkspaceId,
9398 path: RepositoryPath,
9399 expected_revision: WorkspaceFileRevision,
9400 text: String,
9401 ) -> Result<WorkspaceFileRead, NodeFailure> {
9402 let canonical_root = self.workspace_root(&workspace_id)?;
9403 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9404 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9405 })?;
9406 let path_to_write = path.clone();
9407 let expected = expected_revision.as_str().to_owned();
9408 let text_to_write = text.clone();
9409 let task = tokio::task::spawn_blocking(move || {
9410 let _permit = permit;
9411 write_workspace_file_to_disk(
9412 Path::new(&canonical_root),
9413 &path_to_write,
9414 &expected,
9415 &text_to_write,
9416 )
9417 });
9418 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9419 .await
9420 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9421 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9422 .map_err(workspace_file_write_failure)?;
9423 Ok(WorkspaceFileRead {
9424 workspace_id,
9425 path,
9426 content: WorkspaceFileContent::Utf8 {
9427 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9428 text,
9429 },
9430 revision: Some(WorkspaceFileRevision::new(revision)
9431 .expect("workspace writer returns a valid SHA-256 revision")),
9432 })
9433 }
9434
9435 async fn create_workspace_file(
9436 &self,
9437 workspace_id: WorkspaceId,
9438 path: RepositoryPath,
9439 ) -> Result<WorkspaceFileRead, NodeFailure> {
9440 let canonical_root = self.workspace_root(&workspace_id)?;
9441 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9442 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9443 })?;
9444 let path_to_create = path.clone();
9445 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9446 let task_commit_state = Arc::clone(&commit_state);
9447 let task = tokio::task::spawn_blocking(move || {
9448 let _permit = permit;
9449 create_workspace_file_on_disk(
9450 Path::new(&canonical_root),
9451 &path_to_create,
9452 &task_commit_state,
9453 )
9454 });
9455 let revision = settle_workspace_entry_create(
9456 task,
9457 commit_state,
9458 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9459 "repository file create exceeded its bounded deadline",
9460 "repository file create task failed",
9461 )
9462 .await?;
9463 Ok(WorkspaceFileRead {
9464 workspace_id,
9465 path,
9466 content: WorkspaceFileContent::Utf8 {
9467 text: String::new(),
9468 byte_len: 0,
9469 },
9470 revision: Some(WorkspaceFileRevision::new(revision)
9471 .expect("workspace creator returns a valid SHA-256 revision")),
9472 })
9473 }
9474
9475 async fn create_workspace_directory(
9476 &self,
9477 workspace_id: WorkspaceId,
9478 path: RepositoryPath,
9479 ) -> Result<WorkspaceEntry, NodeFailure> {
9480 let canonical_root = self.workspace_root(&workspace_id)?;
9481 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9482 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9483 })?;
9484 let path_to_create = path.clone();
9485 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9486 let task_commit_state = Arc::clone(&commit_state);
9487 let task = tokio::task::spawn_blocking(move || {
9488 let _permit = permit;
9489 create_workspace_directory_on_disk(
9490 Path::new(&canonical_root),
9491 &path_to_create,
9492 &task_commit_state,
9493 )
9494 });
9495 settle_workspace_entry_create(
9496 task,
9497 commit_state,
9498 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9499 "repository directory create exceeded its bounded deadline",
9500 "repository directory create task failed",
9501 )
9502 .await?;
9503 Ok(WorkspaceEntry {
9504 relative_path: path,
9505 kind: WorkspaceEntryKind::Directory,
9506 })
9507 }
9508
9509 async fn read_git_history(
9510 &self,
9511 workspace_id: WorkspaceId,
9512 path: Option<RepositoryPath>,
9513 before: Option<GitObjectId>,
9514 limit: u16,
9515 ) -> Result<GitHistoryPage, NodeFailure> {
9516 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9517 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9518 })?;
9519 let root = self.workspace_root(&workspace_id)?;
9520 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9521 }
9522
9523 async fn read_git_diff(
9524 &self,
9525 workspace_id: WorkspaceId,
9526 request: GitDiffRequest,
9527 ) -> Result<GitDiff, NodeFailure> {
9528 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9529 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9530 })?;
9531 let root = self.workspace_root(&workspace_id)?;
9532 read_git_diff_bounded(&root, request).await
9533 }
9534
9535 fn native_session_route_root(
9536 &self,
9537 route: &NativeSessionCatalogRoute,
9538 ) -> Result<Option<String>, NodeFailure> {
9539 route
9540 .validate()
9541 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9542 match route.scope {
9543 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9544 .workspace_id
9545 .as_ref()
9546 .map(|workspace_id| self.workspace_root(workspace_id))
9547 .transpose(),
9548 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9549 }
9550 }
9551
9552 fn project_native_session_entries(
9553 &self,
9554 route: &NativeSessionCatalogRoute,
9555 entries: Vec<ScopedNativeSessionCatalogEntry>,
9556 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9557 let current_root = route
9558 .workspace_id
9559 .as_ref()
9560 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9561 let records = self
9562 .session_records
9563 .lock()
9564 .unwrap_or_else(|poisoned| poisoned.into_inner());
9565 let mut seen_records = std::collections::HashSet::new();
9566 entries
9567 .into_iter()
9568 .map(|entry| {
9569 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9570 let matches = records
9571 .records
9572 .values()
9573 .filter(|record| {
9574 record.provider == route.provider
9575 && record.workspace_id == *workspace_id
9576 && current_root.as_deref().is_some_and(|root| {
9577 platform::roots_equal(
9578 root,
9579 windows_path_text(&record.canonical_root),
9580 )
9581 })
9582 && record.provider_session.as_ref().is_some_and(|identity| {
9583 session_registry::same_provider_session(
9584 &record.provider,
9585 identity,
9586 &route.provider,
9587 &entry.provider_session,
9588 )
9589 })
9590 })
9591 .map(|record| record.record_id.clone())
9592 .collect::<Vec<_>>();
9593 (matches.len() == 1
9594 && seen_records.insert(matches[0].clone()))
9595 .then(|| matches[0].clone())
9596 });
9597 let metadata = entry.metadata;
9598 crate::protocol::NativeSessionCatalogEntry {
9599 selection_id: metadata.selection_id,
9600 title: metadata.title,
9601 modified_at_unix_ms: metadata.modified_at_unix_ms,
9602 model: metadata.model,
9603 message_count: metadata.message_count,
9604 completed_turn_count: metadata.completed_turn_count,
9605 external_group: entry.external_group,
9606 record_id,
9607 }
9608 })
9609 .collect()
9610 }
9611
9612 async fn catalog_native_sessions(
9613 &self,
9614 route: NativeSessionCatalogRoute,
9615 limit: u16,
9616 ) -> Result<(
9617 Vec<crate::protocol::NativeSessionCatalogEntry>,
9618 crate::protocol::NativeSessionCatalogSummary,
9619 ), NodeFailure> {
9620 let canonical_root = self.native_session_route_root(&route)?;
9621 let registered_workspace_roots = self.workspace_roots();
9622 let provider = route.provider.clone();
9623 let scope = route.scope;
9624 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9625 failure(
9626 NodeFailureCode::InvalidRequest,
9627 "native session catalog is not configured",
9628 )
9629 })?;
9630 let _permit = Arc::clone(&self.inspection_slots)
9631 .try_acquire_owned()
9632 .map_err(|_| {
9633 failure(
9634 NodeFailureCode::BackendBusy,
9635 "native session catalog capacity is busy",
9636 )
9637 })?;
9638 let page = run_native_session_catalog_operation(
9639 catalog,
9640 "native session catalog",
9641 move |catalog| {
9642 catalog.catalog_initial_for_scope(
9643 &provider,
9644 scope,
9645 canonical_root.as_deref().map(Path::new),
9646 ®istered_workspace_roots,
9647 limit,
9648 )
9649 },
9650 )
9651 .await?;
9652 let summary = crate::protocol::NativeSessionCatalogSummary {
9653 catalog_revision: page.revision,
9654 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9655 recent_total_count: page.recent_total_count,
9656 older_total_count: page.older_total_count,
9657 recent_next_after_selection_id: page.next_after_selection_id,
9658 recent_has_more: page.remaining_count > 0,
9659 };
9660 Ok((self.project_native_session_entries(&route, page.entries), summary))
9661 }
9662
9663 async fn page_native_sessions(
9664 &self,
9665 route: NativeSessionCatalogRoute,
9666 window: crate::protocol::NativeSessionCatalogWindow,
9667 catalog_revision: u64,
9668 recent_cutoff_unix_ms: u64,
9669 after_selection_id: Option<String>,
9670 limit: u16,
9671 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9672 let canonical_root = self.native_session_route_root(&route)?;
9673 let registered_workspace_roots = self.workspace_roots();
9674 let provider = route.provider.clone();
9675 let scope = route.scope;
9676 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9677 failure(
9678 NodeFailureCode::InvalidRequest,
9679 "native session catalog is not configured",
9680 )
9681 })?;
9682 let _permit = Arc::clone(&self.inspection_slots)
9683 .try_acquire_owned()
9684 .map_err(|_| {
9685 failure(
9686 NodeFailureCode::BackendBusy,
9687 "native session catalog capacity is busy",
9688 )
9689 })?;
9690 let page = run_native_session_catalog_operation(
9691 catalog,
9692 "native session catalog page",
9693 move |catalog| {
9694 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9695 &provider,
9696 scope,
9697 canonical_root.as_deref().map(Path::new),
9698 ®istered_workspace_roots,
9699 window,
9700 catalog_revision,
9701 recent_cutoff_unix_ms,
9702 after_selection_id.as_deref(),
9703 limit,
9704 ))
9705 },
9706 )
9707 .await?;
9708 let page = page.map_err(|error| match error {
9709 NativeSessionCatalogError::StaleCatalog => failure(
9710 NodeFailureCode::StaleNativeSessionCatalog,
9711 "native session catalog revision or cursor is stale",
9712 ),
9713 _ => failure(
9714 NodeFailureCode::BackendOperationFailed,
9715 "native session catalog page failed",
9716 ),
9717 })?;
9718 let entries = self.project_native_session_entries(&route, page.entries);
9719 Ok(crate::protocol::NativeSessionCatalogPage {
9720 window: page.window,
9721 revision: page.revision,
9722 entries,
9723 next_after_selection_id: page.next_after_selection_id,
9724 remaining_count: page.remaining_count,
9725 has_more: page.remaining_count > 0,
9726 })
9727 }
9728
9729 async fn preview_native_session(
9730 &self,
9731 selection: NativeSessionSelection,
9732 message_limit: u16,
9733 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9734 selection
9735 .validate()
9736 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9737 let canonical_root = self.native_session_route_root(&selection.route)?;
9738 let registered_workspace_roots = self.workspace_roots();
9739 let provider = selection.route.provider.clone();
9740 let scope = selection.route.scope;
9741 let catalog_revision = selection.catalog_revision;
9742 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9743 let selection_id = selection.selection_id.clone();
9744 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9745 failure(
9746 NodeFailureCode::InvalidRequest,
9747 "native session preview is not configured",
9748 )
9749 })?;
9750 let _permit = Arc::clone(&self.inspection_slots)
9751 .try_acquire_owned()
9752 .map_err(|_| {
9753 failure(
9754 NodeFailureCode::BackendBusy,
9755 "native session preview capacity is busy",
9756 )
9757 })?;
9758 let preview = run_native_session_catalog_operation(
9759 catalog,
9760 "native session preview",
9761 move |catalog| {
9762 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9763 &provider,
9764 scope,
9765 canonical_root.as_deref().map(Path::new),
9766 ®istered_workspace_roots,
9767 catalog_revision,
9768 recent_cutoff_unix_ms,
9769 &selection_id,
9770 message_limit,
9771 ))
9772 },
9773 )
9774 .await?;
9775 preview
9776 .map(Into::into)
9777 .map_err(native_session_preview_failure)
9778 }
9779
9780 async fn index_native_session(
9781 &self,
9782 selection: NativeSessionSelection,
9783 display_name: String,
9784 ) -> Result<ManagedSessionRecord, NodeFailure> {
9785 selection
9786 .validate()
9787 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9788 if selection.route.scope
9789 != gate4agent_types::NativeSessionCatalogScope::Workspace
9790 {
9791 return Err(failure(
9792 NodeFailureCode::WorkspaceRegistrationRequired,
9793 "register the external project as a workspace before indexing or resuming it",
9794 ));
9795 }
9796 let workspace_id = selection
9797 .route
9798 .workspace_id
9799 .clone()
9800 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9801 let canonical_root = self
9802 .native_session_route_root(&selection.route)?
9803 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9804 let registered_workspace_roots = self.workspace_roots();
9805 let provider = selection.route.provider.clone();
9806 let catalog_revision = selection.catalog_revision;
9807 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9808 let selection_id = selection.selection_id.clone();
9809 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9810 failure(
9811 NodeFailureCode::InvalidRequest,
9812 "native session index is not configured",
9813 )
9814 })?;
9815 let _permit = Arc::clone(&self.inspection_slots)
9816 .try_acquire_owned()
9817 .map_err(|_| {
9818 failure(
9819 NodeFailureCode::BackendBusy,
9820 "native session index capacity is busy",
9821 )
9822 })?;
9823 let resolved = run_native_session_catalog_operation(
9824 catalog,
9825 "native session index resolution",
9826 move |catalog| {
9827 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9828 &provider,
9829 gate4agent_types::NativeSessionCatalogScope::Workspace,
9830 Some(Path::new(&canonical_root)),
9831 ®istered_workspace_roots,
9832 catalog_revision,
9833 recent_cutoff_unix_ms,
9834 &selection_id,
9835 ))
9836 },
9837 )
9838 .await?
9839 .map_err(native_session_preview_failure)?;
9840 self.index_provider_session_with_policy(
9841 workspace_id,
9842 selection.route.provider,
9843 resolved.identity,
9844 display_name,
9845 true,
9846 )
9847 }
9848
9849 async fn preview_native_session_by_session_id(
9850 &self,
9851 workspace_id: WorkspaceId,
9852 provider: AgentId,
9853 session_id: String,
9854 message_limit: u16,
9855 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9856 let canonical_root = self.workspace_root(&workspace_id)?;
9857 let registered_workspace_roots = self.workspace_roots();
9858 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9859 failure(
9860 NodeFailureCode::InvalidRequest,
9861 "native session preview is not configured",
9862 )
9863 })?;
9864 let _permit = Arc::clone(&self.inspection_slots)
9865 .try_acquire_owned()
9866 .map_err(|_| {
9867 failure(
9868 NodeFailureCode::BackendBusy,
9869 "native session preview capacity is busy",
9870 )
9871 })?;
9872 run_native_session_catalog_operation(
9873 catalog,
9874 "native session preview",
9875 move |catalog| {
9876 catalog.preview_session_id_for_workspace(
9877 &provider,
9878 Path::new(&canonical_root),
9879 ®istered_workspace_roots,
9880 &session_id,
9881 message_limit,
9882 )
9883 },
9884 )
9885 .await
9886 }
9887
9888 async fn register_workspace(
9889 &self,
9890 workspace_id: WorkspaceId,
9891 root: String,
9892 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9893 validate_workspace_request_root(&root)?;
9894 let canonicalize_workspace_id = workspace_id.clone();
9895 let workspace = tokio::task::spawn_blocking(move || {
9896 WorkspaceConfig::new(canonicalize_workspace_id, root)
9897 })
9898 .await
9899 .map_err(|error| {
9900 failure(
9901 NodeFailureCode::BackendOperationFailed,
9902 &format!("workspace canonicalization task failed: {error}"),
9903 )
9904 })?
9905 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9906 let transaction = self
9907 .state_transaction
9908 .lock()
9909 .unwrap_or_else(|poisoned| poisoned.into_inner());
9910 let mut workspaces = self
9911 .workspaces
9912 .write()
9913 .unwrap_or_else(|poisoned| poisoned.into_inner());
9914 if workspaces.contains_key(&workspace_id) {
9915 return Err(failure(
9916 NodeFailureCode::DuplicateWorkspaceId,
9917 "workspace ID is already registered",
9918 ));
9919 }
9920 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9921 platform::roots_equal(existing_root, workspace.canonical_root())
9922 }) {
9923 return Err(failure(
9924 NodeFailureCode::DuplicateWorkspaceRoot,
9925 &format!("workspace root is already registered as '{existing_id}'"),
9926 ));
9927 }
9928 {
9929 let records = self
9930 .session_records
9931 .lock()
9932 .unwrap_or_else(|poisoned| poisoned.into_inner());
9933 if let Some(record) = records.records.values().find(|record| {
9934 record.workspace_id == workspace_id
9935 && !record
9936 .canonical_root
9937 .as_utf8()
9938 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9939 }) {
9940 return Err(failure(
9941 NodeFailureCode::SessionRecordConflict,
9942 &format!(
9943 "workspace ID is retained by session record '{}' at another root",
9944 record.record_id,
9945 ),
9946 ));
9947 }
9948 }
9949 let snapshot = WorkspaceSnapshot {
9950 workspace_id: workspace_id.clone(),
9951 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9952 sessions: Vec::new(),
9953 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9954 managed_worktree_profiles: Some(WorktreeProfileInventory {
9955 profiles: Vec::new(),
9956 }),
9957 };
9958 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9959 drop(workspaces);
9960 let previous_records = {
9961 let mut records = self
9962 .session_records
9963 .lock()
9964 .unwrap_or_else(|poisoned| poisoned.into_inner());
9965 let previous = records.records.clone();
9966 for record in records.records.values_mut().filter(|record| {
9967 record.workspace_id == workspace_id
9968 && record
9969 .canonical_root
9970 .as_utf8()
9971 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9972 }) {
9973 if record.active_session.is_none() {
9974 record.state = detached_record_state(record);
9975 }
9976 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9977 record.last_error = None;
9978 }
9979 record.updated_at_unix_ms = unix_time_ms();
9980 }
9981 previous
9982 };
9983 if let Err(error) = self.persist_state_locked() {
9984 self.workspaces
9985 .write()
9986 .unwrap_or_else(|poisoned| poisoned.into_inner())
9987 .remove(&workspace_id);
9988 self.session_records
9989 .lock()
9990 .unwrap_or_else(|poisoned| poisoned.into_inner())
9991 .records = previous_records;
9992 return Err(persistence_failure(error));
9993 }
9994 let restored = self
9995 .session_records
9996 .lock()
9997 .unwrap_or_else(|poisoned| poisoned.into_inner())
9998 .records
9999 .values()
10000 .filter(|record| record.workspace_id == workspace_id)
10001 .cloned()
10002 .collect::<Vec<_>>();
10003 drop(transaction);
10004 self.publish(NodeEvent::WorkspaceAdded {
10005 workspace: snapshot.clone(),
10006 });
10007 for record in restored {
10008 self.publish_record(record);
10009 }
10010 Ok(snapshot)
10011 }
10012
10013 async fn create_standalone_workspace(
10014 &self,
10015 workspace_id: WorkspaceId,
10016 root: String,
10017 initial_branch: Option<String>,
10018 ) -> Result<WorkspaceSnapshot, NodeFailure> {
10019 validate_workspace_request_root(&root)?;
10020 validate_git_revision("initial branch", initial_branch.as_deref())?;
10021 let preflight_workspace_id = workspace_id.clone();
10022 let preflight_root = root.clone();
10023 let canonical_candidate = tokio::task::spawn_blocking(move || {
10024 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10025 })
10026 .await
10027 .map_err(|_| failure(
10028 NodeFailureCode::BackendOperationFailed,
10029 "standalone workspace root preflight task failed",
10030 ))?
10031 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10032 {
10033 let workspaces = self
10034 .workspaces
10035 .read()
10036 .unwrap_or_else(|poisoned| poisoned.into_inner());
10037 if workspaces.contains_key(&workspace_id) {
10038 return Err(failure(
10039 NodeFailureCode::DuplicateWorkspaceId,
10040 "workspace ID is already registered",
10041 ));
10042 }
10043 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10044 platform::roots_equal(existing_root, &canonical_candidate)
10045 }) {
10046 return Err(failure(
10047 NodeFailureCode::DuplicateWorkspaceRoot,
10048 &format!("workspace root is already registered as '{existing_id}'"),
10049 ));
10050 }
10051 }
10052 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10053 .await
10054 .map_err(standalone_workspace_failure)?;
10055 prepared
10056 .verify_for_registration()
10057 .await
10058 .map_err(standalone_workspace_failure)?;
10059 match self
10060 .register_workspace(workspace_id, prepared.root().to_owned())
10061 .await
10062 {
10063 Ok(workspace) => Ok(workspace),
10064 Err(registration_error) => {
10065 if prepared.compensate_registration_failure().await {
10066 Err(registration_error)
10067 } else {
10068 Err(failure(
10069 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10070 "standalone workspace initialization succeeded but registration failed; recovery is required",
10071 ))
10072 }
10073 }
10074 }
10075 }
10076
10077 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10078 let bindings = self
10079 .session_bindings
10080 .lock()
10081 .unwrap_or_else(|poisoned| poisoned.into_inner());
10082 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10083 return Err(failure(
10084 NodeFailureCode::WorkspaceBusy,
10085 "workspace retains a bound session; remove every session before unregistering it",
10086 ));
10087 }
10088 drop(bindings);
10089 let transaction = self
10090 .state_transaction
10091 .lock()
10092 .unwrap_or_else(|poisoned| poisoned.into_inner());
10093 let mut workspaces = self
10094 .workspaces
10095 .write()
10096 .unwrap_or_else(|poisoned| poisoned.into_inner());
10097 if !workspaces.contains_key(workspace_id) {
10098 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10099 }
10100 if workspaces.len() == 1 {
10101 return Err(failure(
10102 NodeFailureCode::LastWorkspace,
10103 "node must retain at least one workspace",
10104 ));
10105 }
10106 let removed_root = workspaces
10107 .remove(workspace_id)
10108 .expect("workspace presence was validated");
10109 drop(workspaces);
10110 let previous_records = {
10111 let mut records = self
10112 .session_records
10113 .lock()
10114 .unwrap_or_else(|poisoned| poisoned.into_inner());
10115 let previous = records.records.clone();
10116 for record in records
10117 .records
10118 .values_mut()
10119 .filter(|record| &record.workspace_id == workspace_id)
10120 {
10121 record.active_session = None;
10122 record.state = ManagedSessionState::Unavailable;
10123 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10124 record.updated_at_unix_ms = unix_time_ms();
10125 }
10126 previous
10127 };
10128 if let Err(error) = self.persist_state_locked() {
10129 self.workspaces
10130 .write()
10131 .unwrap_or_else(|poisoned| poisoned.into_inner())
10132 .insert(workspace_id.clone(), removed_root);
10133 self.session_records
10134 .lock()
10135 .unwrap_or_else(|poisoned| poisoned.into_inner())
10136 .records = previous_records;
10137 return Err(persistence_failure(error));
10138 }
10139 let unavailable = self
10140 .session_records
10141 .lock()
10142 .unwrap_or_else(|poisoned| poisoned.into_inner())
10143 .records
10144 .values()
10145 .filter(|record| &record.workspace_id == workspace_id)
10146 .cloned()
10147 .collect::<Vec<_>>();
10148 drop(transaction);
10149 self.publish(NodeEvent::WorkspaceRemoved {
10150 workspace_id: workspace_id.clone(),
10151 });
10152 for record in unavailable {
10153 self.publish_record(record);
10154 }
10155 Ok(())
10156 }
10157
10158 async fn create_worktree(
10159 &self,
10160 source_workspace_id: WorkspaceId,
10161 workspace_id: WorkspaceId,
10162 target_root: String,
10163 branch: String,
10164 base: Option<String>,
10165 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10166 self.require_worktree_service(&source_workspace_id)?;
10167 validate_workspace_request_root(&target_root)?;
10168 validate_git_revision("worktree base", base.as_deref())?;
10169 let source_root = self.workspace_root(&source_workspace_id)?;
10170 {
10171 let workspaces = self
10172 .workspaces
10173 .read()
10174 .unwrap_or_else(|poisoned| poisoned.into_inner());
10175 if workspaces.contains_key(&workspace_id) {
10176 return Err(failure(
10177 NodeFailureCode::DuplicateWorkspaceId,
10178 "workspace ID is already registered",
10179 ));
10180 }
10181 if let Some((existing_id, _)) = workspaces
10182 .iter()
10183 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10184 {
10185 return Err(failure(
10186 NodeFailureCode::DuplicateWorkspaceRoot,
10187 &format!("workspace root is already registered as '{existing_id}'"),
10188 ));
10189 }
10190 }
10191 let mut worktree = create_git_worktree(
10192 &source_root,
10193 &target_root,
10194 &branch,
10195 base.as_deref(),
10196 )
10197 .await
10198 .map_err(git_worktree_failure)?;
10199 let workspace = match self
10200 .register_workspace(workspace_id, worktree.path.clone())
10201 .await
10202 {
10203 Ok(workspace) => workspace,
10204 Err(registration_error) => {
10205 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10206 return Err(failure(
10207 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10208 "worktree registration failed and compensation requires recovery",
10209 ));
10210 }
10211 return Err(failure(
10212 registration_error.code,
10213 ®istration_error.message,
10214 ));
10215 }
10216 };
10217 worktree.workspace_id = Some(workspace.workspace_id.clone());
10218 Ok((protocol_worktree(worktree), workspace))
10219 }
10220
10221 async fn remove_worktree(
10222 &self,
10223 source_workspace_id: WorkspaceId,
10224 target_root: String,
10225 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10226 self.require_worktree_service(&source_workspace_id)?;
10227 validate_workspace_request_root(&target_root)?;
10228 let source_root = self.workspace_root(&source_workspace_id)?;
10229 let target_root = normalize_worktree_removal_target(&target_root)
10230 .map_err(git_worktree_failure)?;
10231 let target = list_git_worktrees(&source_root)
10232 .await
10233 .map_err(git_worktree_failure)?
10234 .into_iter()
10235 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10236 .ok_or_else(|| {
10237 failure(
10238 NodeFailureCode::WorktreeProtected,
10239 "refusing to remove a path that is not in Git's worktree listing",
10240 )
10241 })?;
10242 let registered_workspace_id = {
10243 let workspaces = self
10244 .workspaces
10245 .read()
10246 .unwrap_or_else(|poisoned| poisoned.into_inner());
10247 let matched = workspaces
10248 .iter()
10249 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10250 .map(|(workspace_id, _)| workspace_id.clone());
10251 if matched.is_some() && workspaces.len() == 1 {
10252 return Err(failure(
10253 NodeFailureCode::LastWorkspace,
10254 "node must retain at least one workspace",
10255 ));
10256 }
10257 matched
10258 };
10259 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10260 let bindings = self
10261 .session_bindings
10262 .lock()
10263 .unwrap_or_else(|poisoned| poisoned.into_inner());
10264 if bindings
10265 .values()
10266 .any(|binding| &binding.workspace_id == workspace_id)
10267 {
10268 return Err(failure(
10269 NodeFailureCode::WorkspaceBusy,
10270 "worktree workspace retains a bound session; remove every session first",
10271 ));
10272 }
10273 }
10274 remove_git_worktree(&source_root, &target.path)
10275 .await
10276 .map_err(git_worktree_failure)?;
10277 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10278 self.unregister_workspace(workspace_id)
10279 .map_err(|error| failure(error.code, &error.message))?;
10280 }
10281 Ok(registered_workspace_id)
10282 }
10283
10284 async fn cleanup_managed_worktree(
10285 &self,
10286 lease_id: &ManagedWorktreeLeaseId,
10287 explicit: bool,
10288 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10289 let lease = self.managed_worktrees.lock()
10290 .unwrap_or_else(|poisoned| poisoned.into_inner())
10291 .get(lease_id)
10292 .cloned()
10293 .ok_or_else(|| failure(
10294 NodeFailureCode::UnknownManagedWorktreeLease,
10295 "managed worktree lease does not exist",
10296 ))?;
10297 if lease.state == ManagedWorktreeLeaseState::Removed {
10298 return Ok(lease.snapshot());
10299 }
10300 if lease.has_holders() {
10301 return Err(failure(
10302 NodeFailureCode::ManagedWorktreeBusy,
10303 "managed worktree lease still has a session or durable-record holder",
10304 ));
10305 }
10306 if self
10307 .materializations
10308 .lock()
10309 .unwrap_or_else(|poisoned| poisoned.into_inner())
10310 .values()
10311 .any(|record| record.managed_lease_id() == Some(lease_id))
10312 {
10313 return Err(failure(
10314 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10315 "managed worktree cleanup is blocked by session environment ownership",
10316 ));
10317 }
10318 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10319 let snapshot = {
10320 let mut registry = self.managed_worktrees.lock()
10321 .unwrap_or_else(|poisoned| poisoned.into_inner());
10322 let current = registry.get_mut(lease_id)
10323 .expect("managed worktree lease remains present");
10324 current.state = ManagedWorktreeLeaseState::Retained;
10325 current.cleanup_failure = None;
10326 current.updated_at_unix_ms = unix_time_ms();
10327 current.snapshot()
10328 };
10329 if self.persist_state().is_err() {
10330 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10331 }
10332 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10333 return Ok(snapshot);
10334 }
10335 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10336 if profile.revision() != &lease.profile_revision {
10337 self.mark_managed_recovery_required(
10338 lease_id,
10339 ManagedWorktreeCleanupFailure::OwnershipConflict,
10340 );
10341 return Err(failure(
10342 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10343 "managed worktree profile revision changed",
10344 ));
10345 }
10346 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10347 self.mark_managed_recovery_required(
10348 lease_id,
10349 ManagedWorktreeCleanupFailure::OwnershipConflict,
10350 );
10351 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10352 })?;
10353 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10354 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10355 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10356 managed_git_worktree_failure(error)
10357 })?;
10358 let target = listed.iter()
10359 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10360 if let Some(target) = target {
10361 if target.is_main || target.is_bare || !exact_owned_worktree(
10362 &lease,
10363 &target.path,
10364 target.branch.as_deref(),
10365 &target.head,
10366 ) {
10367 self.mark_managed_recovery_required(
10368 lease_id,
10369 ManagedWorktreeCleanupFailure::OwnershipConflict,
10370 );
10371 return Err(failure(
10372 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10373 "Git worktree identity no longer matches the managed lease",
10374 ));
10375 }
10376 if target.prunable {
10377 self.mark_managed_cleanup_blocked(
10378 lease_id,
10379 ManagedWorktreeCleanupFailure::Prunable,
10380 );
10381 return Err(failure(
10382 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10383 "managed worktree is prunable and requires recovery",
10384 ));
10385 }
10386 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10387 let failure_kind = match error.kind {
10388 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10389 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10390 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10391 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10392 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10393 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10394 };
10395 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10396 self.mark_managed_recovery_required(lease_id, failure_kind);
10397 } else {
10398 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10399 }
10400 return Err(managed_git_worktree_failure(error));
10401 }
10402 } else {
10403 match std::fs::symlink_metadata(&lease.target_root) {
10404 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10405 Ok(_) => {
10406 self.mark_managed_recovery_required(
10407 lease_id,
10408 ManagedWorktreeCleanupFailure::OwnershipConflict,
10409 );
10410 return Err(failure(
10411 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10412 "managed target exists but is not owned by Git",
10413 ));
10414 }
10415 Err(_) => {
10416 self.mark_managed_cleanup_blocked(
10417 lease_id,
10418 ManagedWorktreeCleanupFailure::Backend,
10419 );
10420 return Err(failure(
10421 NodeFailureCode::BackendOperationFailed,
10422 "managed target identity could not be inspected",
10423 ));
10424 }
10425 }
10426 }
10427 let registered = self.workspaces.read()
10428 .unwrap_or_else(|poisoned| poisoned.into_inner())
10429 .contains_key(&lease.workspace_id);
10430 if registered {
10431 self.unregister_workspace(&lease.workspace_id)?;
10432 }
10433 let removed = {
10434 self.managed_worktrees.lock()
10435 .unwrap_or_else(|poisoned| poisoned.into_inner())
10436 .tombstone(lease_id, unix_time_ms())
10437 .expect("managed worktree lease remains present during cleanup")
10438 };
10439 self.persist_state().map_err(persistence_failure)?;
10440 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10441 Ok(removed.snapshot())
10442 }
10443
10444 fn mark_managed_cleanup_blocked(
10445 &self,
10446 lease_id: &ManagedWorktreeLeaseId,
10447 failure_kind: ManagedWorktreeCleanupFailure,
10448 ) {
10449 let snapshot = {
10450 let mut registry = self.managed_worktrees.lock()
10451 .unwrap_or_else(|poisoned| poisoned.into_inner());
10452 let Some(lease) = registry.get_mut(lease_id) else { return };
10453 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10454 lease.cleanup_failure = Some(failure_kind);
10455 lease.updated_at_unix_ms = unix_time_ms();
10456 lease.snapshot()
10457 };
10458 let _ = self.persist_state();
10459 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10460 }
10461
10462 fn require_worktree_service(
10463 &self,
10464 source_workspace_id: &WorkspaceId,
10465 ) -> Result<(), NodeFailure> {
10466 if self.managed_worktrees.lock()
10467 .unwrap_or_else(|poisoned| poisoned.into_inner())
10468 .lease_for_workspace(source_workspace_id)
10469 .is_some()
10470 {
10471 return Err(failure(
10472 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10473 "managed worktree targets are internal-only workspaces",
10474 ));
10475 }
10476 match self
10477 .worktree_service_modes
10478 .get(source_workspace_id)
10479 .copied()
10480 .unwrap_or(WorktreeServiceMode::Manual)
10481 {
10482 WorktreeServiceMode::Manual => Ok(()),
10483 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10484 NodeFailureCode::UnsupportedSpawnCapability,
10485 "Git worktree service mode is unavailable in this node build",
10486 )),
10487 }
10488 }
10489
10490 fn managed_reservation_conflict(
10491 &self,
10492 workspace_id: Option<&WorkspaceId>,
10493 root: Option<&str>,
10494 ) -> bool {
10495 self.managed_worktrees.lock()
10496 .unwrap_or_else(|poisoned| poisoned.into_inner())
10497 .active_records()
10498 .any(|lease| {
10499 workspace_id.is_some_and(|workspace_id| {
10500 workspace_id == &lease.source_workspace_id
10501 || workspace_id == &lease.workspace_id
10502 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10503 })
10504 }
10505
10506 fn reject_managed_reservation(
10507 &self,
10508 workspace_id: Option<&WorkspaceId>,
10509 root: Option<&str>,
10510 ) -> Result<(), NodeFailure> {
10511 if self.managed_reservation_conflict(workspace_id, root) {
10512 Err(failure(
10513 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10514 "managed worktree ownership may only be mutated through its lease cleanup",
10515 ))
10516 } else {
10517 Ok(())
10518 }
10519 }
10520
10521 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10522 self.validate_address(address)?;
10523 self.workspace_root(&address.workspace_id)
10524 }
10525
10526 fn require_session_runtime_policy(
10560 &self,
10561 address: &SessionAddress,
10562 requirement: ProviderRuntimeRequirement,
10563 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10564 let runtime_policy = {
10565 let bindings = self
10566 .session_bindings
10567 .lock()
10568 .unwrap_or_else(|poisoned| poisoned.into_inner());
10569 let binding = bindings
10570 .get(&address.session.instance_id)
10571 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10572 if binding.workspace_id != address.workspace_id {
10573 return Err(failure(
10574 NodeFailureCode::SessionWorkspaceMismatch,
10575 "session belongs to another workspace",
10576 ));
10577 }
10578 if binding.generation != address.session.generation {
10579 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10580 }
10581 binding.runtime_policy
10582 };
10583 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10584 let snapshot = self.internal_session_snapshot(address)?;
10585 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10586 && snapshot.provider.lead_activity != ProviderActivity::Idle
10587 {
10588 return Err(failure(
10589 NodeFailureCode::TurnInFlight,
10590 "session already has a turn in flight; wait for it to complete before sending another prompt",
10591 ));
10592 }
10593 }
10594 require_policy(runtime_policy, false, false, requirement)
10595 .map(|()| runtime_policy)
10596 .map_err(|_| failure(
10597 NodeFailureCode::UnsupportedCapability,
10598 "session runtime policy does not admit this semantic operation",
10599 ))
10600 }
10601
10602 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10603 let mut bindings = self
10604 .session_bindings
10605 .lock()
10606 .unwrap_or_else(|poisoned| poisoned.into_inner());
10607 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10608 binding.workspace_id == address.workspace_id
10609 && binding.generation == address.session.generation
10610 }) {
10611 let removed = bindings.remove(&address.session.instance_id);
10612 self.clear_terminal_frame_watermark(address);
10613 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10614 return removed;
10615 }
10616 None
10617 }
10618
10619 fn cleanup_session_owned_materialization(
10620 &self,
10621 address: &SessionAddress,
10622 binding: &SessionBinding,
10623 ) -> Result<(), NodeFailure> {
10624 let Some(id) = binding.materialization_id.as_ref() else {
10625 return Ok(());
10626 };
10627 let owner = self
10628 .materializations
10629 .lock()
10630 .unwrap_or_else(|poisoned| poisoned.into_inner())
10631 .get(id)
10632 .map(|record| record.owner().clone());
10633 match owner {
10634 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10635 Some(MaterializationOwner::Session {
10636 incarnation_id,
10637 instance_id,
10638 generation,
10639 }) if incarnation_id == self.incarnation_id
10640 && instance_id == address.session.instance_id
10641 && generation == address.session.generation =>
10642 {
10643 self.cleanup_materialization(id)
10644 }
10645 Some(MaterializationOwner::Session { .. }) => Err(failure(
10646 NodeFailureCode::BackendOperationFailed,
10647 "session environment ownership requires recovery",
10648 )),
10649 }
10650 }
10651
10652 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10653 let mut watermarks = self
10654 .terminal_frame_watermarks
10655 .lock()
10656 .unwrap_or_else(|poisoned| poisoned.into_inner());
10657 if watermarks
10658 .get(&address.session.instance_id)
10659 .is_some_and(|(current, _)| current == address)
10660 {
10661 watermarks.remove(&address.session.instance_id);
10662 }
10663 }
10664
10665 #[cfg(test)]
10666 fn address_for(
10667 &self,
10668 instance_id: AgentInstanceId,
10669 generation: SessionGeneration,
10670 ) -> Option<SessionAddress> {
10671 let binding = self.session_bindings
10672 .lock()
10673 .unwrap_or_else(|poisoned| poisoned.into_inner())
10674 .get(&instance_id)
10675 .cloned()?;
10676 (binding.generation == generation).then_some(SessionAddress {
10677 workspace_id: binding.workspace_id,
10678 session: SessionKey { instance_id, generation },
10679 })
10680 }
10681
10682 fn arm_resume(
10683 &self,
10684 address: &SessionAddress,
10685 command_id: CommandId,
10686 runtime_policy: ProviderRuntimePolicy,
10687 ) -> Result<(), NodeFailure> {
10688 let mut bindings = self
10689 .session_bindings
10690 .lock()
10691 .unwrap_or_else(|poisoned| poisoned.into_inner());
10692 let binding = bindings
10693 .get_mut(&address.session.instance_id)
10694 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10695 if binding.workspace_id != address.workspace_id {
10696 return Err(failure(
10697 NodeFailureCode::SessionWorkspaceMismatch,
10698 "session belongs to another workspace",
10699 ));
10700 }
10701 if binding.generation != address.session.generation {
10702 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10703 }
10704 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10705 Ok(())
10706 }
10707
10708 fn clear_armed_resume(&self, address: &SessionAddress) {
10709 let mut bindings = self
10710 .session_bindings
10711 .lock()
10712 .unwrap_or_else(|poisoned| poisoned.into_inner());
10713 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10714 if binding.workspace_id == address.workspace_id
10715 && binding.generation == address.session.generation
10716 {
10717 binding.pending_resume = None;
10718 }
10719 }
10720 }
10721
10722 fn publish_control(&self, event: ControlEvent) {
10723 let revokes_harness_mcp = matches!(
10724 &event.event,
10725 ControlEventKind::Exited { .. }
10726 | ControlEventKind::Failed { .. }
10727 | ControlEventKind::Removed
10728 );
10729 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10730 let mut bindings = self
10731 .session_bindings
10732 .lock()
10733 .unwrap_or_else(|poisoned| poisoned.into_inner());
10734 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10735 return;
10736 };
10737 let previous_address = SessionAddress {
10738 workspace_id: binding.workspace_id.clone(),
10739 session: SessionKey {
10740 instance_id: event.instance_id,
10741 generation: binding.generation,
10742 },
10743 };
10744 let mut resume_command_rejected = false;
10745 if binding.generation == event.generation {
10746 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10747 let rejected_resume = matches!(
10748 &event.event,
10749 ControlEventKind::CommandRejected { .. }
10750 ) && event.command_id == Some(pending_command_id);
10751 let resume_failed = matches!(
10752 &event.event,
10753 ControlEventKind::ResumeDenied { .. }
10754 | ControlEventKind::ResumeFailed { .. }
10755 );
10756 if pending_generation == binding.generation
10757 && (rejected_resume || resume_failed)
10758 {
10759 resume_command_rejected = rejected_resume;
10760 binding.pending_resume = None;
10761 }
10762 }
10763 } else {
10764 let expected = binding.generation.0.checked_add(1);
10765 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10766 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10767 generation == binding.generation
10768 })
10769 || expected != Some(event.generation.0)
10770 || !authorized
10771 {
10772 return;
10773 }
10774 let (_, _, runtime_policy) = binding
10775 .pending_resume
10776 .expect("authorized resume retains its pending runtime policy");
10777 binding.generation = event.generation;
10778 binding.runtime_policy = runtime_policy;
10779 binding.pending_resume = None;
10780 }
10781 (
10782 SessionAddress {
10783 workspace_id: binding.workspace_id.clone(),
10784 session: SessionKey {
10785 instance_id: event.instance_id,
10786 generation: event.generation,
10787 },
10788 },
10789 (previous_address.session.generation != event.generation)
10790 .then_some(previous_address),
10791 binding.record_id.clone(),
10792 binding.runtime_policy.provider_session_identity,
10793 resume_command_rejected,
10794 )
10795 };
10796 if let Some(previous_address) = previous_address.as_ref() {
10797 self.clear_terminal_frame_watermark(previous_address);
10798 }
10799 if let Some(record_id) = record_id {
10800 self.reconcile_managed_record(
10801 &record_id,
10802 &address,
10803 &event,
10804 identity_admitted,
10805 resume_command_rejected,
10806 );
10807 }
10808 match &event.event {
10816 ControlEventKind::Running { process_id } => {
10817 if let Some(pid) = process_id {
10818 crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10819 }
10820 #[cfg(unix)]
10821 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10822 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10823 }
10824 tracing::info!(
10825 node_id = %self.node_id,
10826 workspace_id = %address.workspace_id,
10827 session = ?address.session,
10828 pid = ?process_id,
10829 "session process is running",
10830 );
10831 }
10832 ControlEventKind::Exited { exit_code, forced } => {
10833 crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10834 #[cfg(unix)]
10835 if let Some(proxy) = self.signer_proxy.get().cloned() {
10836 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10837 if proxy.has_session(&key) {
10838 std::thread::spawn(move || proxy.release(&key));
10840 }
10841 }
10842 tracing::info!(
10843 node_id = %self.node_id,
10844 workspace_id = %address.workspace_id,
10845 session = ?address.session,
10846 exit_code = ?exit_code,
10847 forced = forced,
10848 "session process exited",
10849 );
10850 }
10851 ControlEventKind::Failed { message } => {
10852 tracing::warn!(
10853 node_id = %self.node_id,
10854 workspace_id = %address.workspace_id,
10855 session = ?address.session,
10856 cause = %message,
10857 "session failed",
10858 );
10859 }
10860 ControlEventKind::CommandRejected { message } => {
10861 tracing::warn!(
10862 node_id = %self.node_id,
10863 workspace_id = %address.workspace_id,
10864 session = ?address.session,
10865 cause = %message,
10866 "session command rejected",
10867 );
10868 }
10869 ControlEventKind::Removed => {
10870 tracing::info!(
10871 node_id = %self.node_id,
10872 workspace_id = %address.workspace_id,
10873 session = ?address.session,
10874 "session removed",
10875 );
10876 }
10877 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10878 tracing::info!(
10879 node_id = %self.node_id,
10880 workspace_id = %address.workspace_id,
10881 session = ?address.session,
10882 provider_session = ?provider_session,
10883 "session resume authorized",
10884 );
10885 }
10886 ControlEventKind::Resumed { session: provider_session, process_id } => {
10887 tracing::info!(
10888 node_id = %self.node_id,
10889 workspace_id = %address.workspace_id,
10890 session = ?address.session,
10891 provider_session = ?provider_session,
10892 pid = ?process_id,
10893 "session resumed",
10894 );
10895 }
10896 ControlEventKind::ResumeDenied { reason } => {
10897 tracing::warn!(
10898 node_id = %self.node_id,
10899 workspace_id = %address.workspace_id,
10900 session = ?address.session,
10901 cause = %reason,
10902 "session resume denied",
10903 );
10904 }
10905 ControlEventKind::ResumeFailed { message } => {
10906 tracing::warn!(
10907 node_id = %self.node_id,
10908 workspace_id = %address.workspace_id,
10909 session = ?address.session,
10910 cause = %message,
10911 "session resume failed",
10912 );
10913 }
10914 _ => {}
10915 }
10916 self.apply_pending_acp_options(&address, &event);
10917 self.apply_acp_auto_permit(&address, &event);
10918 let stream_chunk = agent_stream_chunk(&event);
10919 self.publish(NodeEvent::Control {
10920 address: address.clone(),
10921 event,
10922 });
10923 if let Some(chunk) = stream_chunk {
10924 self.publish_agent_stream_chunk(address.clone(), chunk);
10925 }
10926 if revokes_harness_mcp {
10927 SessionLifecycleHook::on_session_ended(self, &address);
10928 }
10929 }
10930
10931 fn reconcile_managed_record(
10932 &self,
10933 record_id: &SessionRecordId,
10934 address: &SessionAddress,
10935 event: &ControlEvent,
10936 identity_admitted: bool,
10937 resume_command_rejected: bool,
10938 ) {
10939 let _transaction = self
10940 .state_transaction
10941 .lock()
10942 .unwrap_or_else(|poisoned| poisoned.into_inner());
10943 let observed_identity = match &event.event {
10944 ControlEventKind::ProviderEvent {
10945 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10946 ..
10947 } if identity_admitted => Some(identity.clone()),
10948 _ => None,
10949 };
10950 let identity_was_observed = observed_identity.is_some();
10951 let replacement_id = observed_identity
10952 .as_ref()
10953 .and_then(|_| self.allocate_record_id().ok());
10954 let mut upserts = Vec::new();
10955 let mut removals = Vec::new();
10956 let mut rebind = None;
10957 let original_records;
10958 let original_materializations = self
10959 .materializations
10960 .lock()
10961 .unwrap_or_else(|poisoned| poisoned.into_inner())
10962 .clone();
10963 {
10964 let mut records = self
10965 .session_records
10966 .lock()
10967 .unwrap_or_else(|poisoned| poisoned.into_inner());
10968 original_records = records.records.clone();
10969 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10970 return;
10971 };
10972 if let Some(identity) = observed_identity {
10973 let existing_id = records
10974 .records
10975 .values()
10976 .find(|record| {
10977 record.record_id != *record_id
10978 && record.provider_session.as_ref().is_some_and(|record_identity| {
10979 session_registry::same_provider_session(
10980 &record.provider,
10981 record_identity,
10982 ¤t_snapshot.provider,
10983 &identity,
10984 )
10985 })
10986 })
10987 .map(|record| record.record_id.clone());
10988 if let Some(existing_id) = existing_id {
10989 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10990 record.mode == current_snapshot.mode
10991 && record.workspace_id == current_snapshot.workspace_id
10992 && record.environment_profile == current_snapshot.environment_profile
10993 && record.bundle == current_snapshot.bundle
10994 && record.context_id == current_snapshot.context_id
10995 && record.context == current_snapshot.context
10996 && record
10997 .canonical_root
10998 .as_utf8()
10999 .zip(current_snapshot.canonical_root.as_utf8())
11000 .is_some_and(|(left, right)| platform::roots_equal(left, right))
11001 });
11002 if !scope_matches {
11003 if let Some(current) = records.records.get_mut(record_id) {
11004 current.state = ManagedSessionState::Unavailable;
11005 current.last_error =
11006 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11007 current.updated_at_unix_ms = unix_time_ms();
11008 upserts.push(current.clone());
11009 }
11010 } else {
11011 let existing_is_active = records
11012 .records
11013 .get(&existing_id)
11014 .is_some_and(|record| record.active_session.is_some());
11015 if existing_is_active {
11016 if let Some(current) = records.records.get_mut(record_id) {
11017 current.state = ManagedSessionState::Unavailable;
11018 current.last_error =
11019 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11020 current.updated_at_unix_ms = unix_time_ms();
11021 upserts.push(current.clone());
11022 }
11023 } else {
11024 if current_snapshot.provider_session.is_some() {
11025 if let Some(current) = records.records.get_mut(record_id) {
11026 current.active_session = None;
11027 current.state = ManagedSessionState::Dormant;
11028 current.updated_at_unix_ms = unix_time_ms();
11029 upserts.push(current.clone());
11030 }
11031 } else {
11032 records.records.remove(record_id);
11033 removals.push(record_id.clone());
11034 }
11035 if let Some(existing) = records.records.get_mut(&existing_id) {
11036 existing.provider_session = Some(identity.clone());
11037 existing.active_session = Some(address.clone());
11038 existing.state = ManagedSessionState::Live;
11039 existing.updated_at_unix_ms = unix_time_ms();
11040 existing.last_error = None;
11041 upserts.push(existing.clone());
11042 }
11043 rebind = Some(existing_id);
11044 }
11045 }
11046 } else if current_snapshot
11047 .provider_session
11048 .as_ref()
11049 .is_some_and(|current| {
11050 !session_registry::same_provider_session(
11051 ¤t_snapshot.provider,
11052 current,
11053 ¤t_snapshot.provider,
11054 &identity,
11055 )
11056 })
11057 {
11058 if let Some(current) = records.records.get_mut(record_id) {
11059 current.active_session = None;
11060 if current.bundle.is_some() || current.context.is_some() {
11061 let had_bundle = current.bundle.is_some();
11062 current.provider_session = None;
11063 current.bundle = None;
11064 current.context_id = None;
11065 current.context = None;
11066 current.state = ManagedSessionState::Unavailable;
11067 current.last_error = Some(if had_bundle {
11068 "bundle-unavailable".to_owned()
11069 } else {
11070 "context-unavailable".to_owned()
11071 });
11072 } else {
11073 current.state = ManagedSessionState::Dormant;
11074 }
11075 current.updated_at_unix_ms = unix_time_ms();
11076 upserts.push(current.clone());
11077 }
11078 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11079 if let Some(current) = records.records.get_mut(record_id) {
11080 current.state = ManagedSessionState::Unavailable;
11081 current.last_error =
11082 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11083 current.updated_at_unix_ms = unix_time_ms();
11084 upserts.push(current.clone());
11085 }
11086 } else if let Some(new_record_id) = replacement_id {
11087 let now = unix_time_ms();
11088 let replacement = ManagedSessionRecord {
11089 record_id: new_record_id.clone(),
11090 display_name: format!(
11091 "{} #{}",
11092 current_snapshot.provider.as_str(),
11093 records
11094 .records
11095 .values()
11096 .filter(|record| {
11097 record.provider == current_snapshot.provider
11098 })
11099 .count()
11100 .saturating_add(1),
11101 ),
11102 provider: current_snapshot.provider.clone(),
11103 mode: current_snapshot.mode,
11104 state: ManagedSessionState::Live,
11105 workspace_id: current_snapshot.workspace_id,
11106 canonical_root: current_snapshot.canonical_root,
11107 provider_session: Some(identity),
11108 active_session: Some(address.clone()),
11109 environment_profile: current_snapshot.environment_profile,
11110 bundle: current_snapshot.bundle,
11111 context_id: current_snapshot.context_id,
11112 context: current_snapshot.context,
11113 exported_context: None,
11119 task_binding: current_snapshot.task_binding,
11120 created_at_unix_ms: now,
11121 updated_at_unix_ms: now,
11122 last_error: None,
11123 };
11124 records
11125 .records
11126 .insert(new_record_id.clone(), replacement.clone());
11127 upserts.push(replacement);
11128 rebind = Some(new_record_id);
11129 } else if let Some(current) = records.records.get_mut(record_id) {
11130 current.state = ManagedSessionState::Unavailable;
11131 current.last_error =
11132 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11133 upserts.push(current.clone());
11134 }
11135 } else if let Some(current) = records.records.get_mut(record_id) {
11136 current.provider_session = Some(identity);
11137 current.active_session = Some(address.clone());
11138 current.state = ManagedSessionState::Live;
11139 current.updated_at_unix_ms = unix_time_ms();
11140 current.last_error = None;
11141 upserts.push(current.clone());
11142 }
11143 } else if let Some(current) = records.records.get_mut(record_id) {
11144 if resume_command_rejected {
11145 current.active_session = None;
11146 current.state = detached_record_state(current);
11147 current.updated_at_unix_ms = unix_time_ms();
11148 current.last_error =
11149 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11150 upserts.push(current.clone());
11151 } else {
11152 match &event.event {
11153 ControlEventKind::Running { .. }
11154 | ControlEventKind::ResumeAuthorized { .. }
11155 | ControlEventKind::Resumed { .. } => {
11156 current.active_session = Some(address.clone());
11157 current.state = active_record_state(
11158 identity_admitted,
11159 current.provider_session.is_some(),
11160 );
11161 current.updated_at_unix_ms = unix_time_ms();
11162 current.last_error = None;
11163 upserts.push(current.clone());
11164 }
11165 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11166 current.active_session = None;
11167 current.state = detached_record_state(current);
11168 current.updated_at_unix_ms = unix_time_ms();
11169 upserts.push(current.clone());
11170 }
11171 ControlEventKind::Failed { message }
11172 | ControlEventKind::ResumeFailed { message } => {
11173 current.active_session = None;
11174 current.state = detached_record_state(current);
11175 current.updated_at_unix_ms = unix_time_ms();
11176 current.last_error = Some(
11177 session_registry::sanitized_record_error_summary(message),
11178 );
11179 upserts.push(current.clone());
11180 }
11181 ControlEventKind::ResumeDenied { reason } => {
11182 current.active_session = None;
11183 current.state = detached_record_state(current);
11184 current.updated_at_unix_ms = unix_time_ms();
11185 current.last_error = Some(
11186 session_registry::sanitized_record_error_summary(reason),
11187 );
11188 upserts.push(current.clone());
11189 }
11190 _ => {}
11191 }
11192 }
11193 }
11194 }
11195 let materialization_owner_record = identity_was_observed.then(|| {
11196 rebind.clone().unwrap_or_else(|| record_id.clone())
11197 }).and_then(|candidate| {
11198 self.session_records
11199 .lock()
11200 .unwrap_or_else(|poisoned| poisoned.into_inner())
11201 .records
11202 .get(&candidate)
11203 .filter(|record| {
11204 record.provider_session.is_some()
11205 && record.active_session.as_ref() == Some(address)
11206 })
11207 .map(|_| candidate)
11208 });
11209 if let Some(owner_record_id) = materialization_owner_record {
11210 let materialization_id = self
11211 .session_bindings
11212 .lock()
11213 .unwrap_or_else(|poisoned| poisoned.into_inner())
11214 .get(&address.session.instance_id)
11215 .and_then(|binding| binding.materialization_id.clone());
11216 if let Some(materialization_id) = materialization_id {
11217 let mut transfer_failed = false;
11218 {
11219 let mut materializations = self
11220 .materializations
11221 .lock()
11222 .unwrap_or_else(|poisoned| poisoned.into_inner());
11223 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11224 match ownership.owner() {
11225 MaterializationOwner::Session {
11226 incarnation_id,
11227 instance_id,
11228 generation,
11229 } if *incarnation_id == self.incarnation_id
11230 && *instance_id == address.session.instance_id
11231 && *generation == address.session.generation =>
11232 {
11233 transfer_failed = ownership
11234 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11235 .is_err();
11236 }
11237 MaterializationOwner::Record { record_id }
11238 if record_id == &owner_record_id => {}
11239 MaterializationOwner::Record { record_id } => {
11240 let expected = record_id.clone();
11241 transfer_failed = ownership
11242 .transfer_record_owner(
11243 &expected,
11244 owner_record_id.clone(),
11245 unix_time_ms(),
11246 )
11247 .is_err();
11248 }
11249 _ => transfer_failed = true,
11250 }
11251 if transfer_failed {
11252 let _ = ownership.mark_recovery_required(unix_time_ms());
11253 }
11254 } else {
11255 transfer_failed = true;
11256 }
11257 }
11258 if transfer_failed {
11259 if let Some(record) = self
11260 .session_records
11261 .lock()
11262 .unwrap_or_else(|poisoned| poisoned.into_inner())
11263 .records
11264 .get_mut(&owner_record_id)
11265 {
11266 record.state = ManagedSessionState::Unavailable;
11267 record.last_error = Some("environment-profile-unavailable".to_owned());
11268 record.updated_at_unix_ms = unix_time_ms();
11269 upserts.push(record.clone());
11270 }
11271 }
11272 }
11273 }
11274 if let Some(new_record_id) = rebind.clone() {
11275 if let Some(binding) = self
11276 .session_bindings
11277 .lock()
11278 .unwrap_or_else(|poisoned| poisoned.into_inner())
11279 .get_mut(&address.session.instance_id)
11280 {
11281 binding.record_id = Some(new_record_id);
11282 }
11283 }
11284 if upserts.is_empty() && removals.is_empty() {
11285 return;
11286 }
11287 if self.persist_state_locked().is_err() {
11288 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11289 let mut records = self
11290 .session_records
11291 .lock()
11292 .unwrap_or_else(|poisoned| poisoned.into_inner());
11293 records.records = original_records;
11294 *self
11295 .materializations
11296 .lock()
11297 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11298 if let Some(current) = records.records.get_mut(record_id) {
11299 current.state = ManagedSessionState::Unavailable;
11300 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11301 current.updated_at_unix_ms = unix_time_ms();
11302 upserts = vec![current.clone()];
11303 } else {
11304 upserts.clear();
11305 }
11306 removals.clear();
11307 drop(records);
11308 if let Some(binding) = self
11309 .session_bindings
11310 .lock()
11311 .unwrap_or_else(|poisoned| poisoned.into_inner())
11312 .get_mut(&address.session.instance_id)
11313 {
11314 binding.record_id = Some(record_id.clone());
11315 }
11316 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11317 }
11318 for record in upserts {
11319 self.publish_record(record);
11320 }
11321 for record_id in removals {
11322 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11323 }
11324 }
11325
11326 fn snapshot(&self) -> NodeSnapshot {
11327 let control = self.handle.snapshot();
11328 let bindings = self
11329 .session_bindings
11330 .lock()
11331 .unwrap_or_else(|poisoned| poisoned.into_inner())
11332 .clone();
11333 let workspace_roots = self
11334 .workspaces
11335 .read()
11336 .unwrap_or_else(|poisoned| poisoned.into_inner())
11337 .clone();
11338 let environment_profiles = self
11339 .environment_profiles
11340 .read()
11341 .unwrap_or_else(|poisoned| poisoned.into_inner());
11342 let mut workspaces = workspace_roots
11343 .iter()
11344 .map(|(workspace_id, canonical_root)| {
11345 (
11346 workspace_id.clone(),
11347 WorkspaceSnapshot {
11348 workspace_id: workspace_id.clone(),
11349 canonical_root: opaque_windows_path(canonical_root.clone()),
11350 sessions: Vec::new(),
11351 worktree_service_mode: Some(
11352 self.worktree_service_modes
11353 .get(workspace_id)
11354 .copied()
11355 .unwrap_or(WorktreeServiceMode::Manual),
11356 ),
11357 managed_worktree_profiles: Some(WorktreeProfileInventory {
11358 profiles: self
11359 .managed_worktree_profiles
11360 .get(workspace_id)
11361 .into_iter()
11362 .flat_map(BTreeMap::values)
11363 .map(|profile| ManagedWorktreeProfileSummary {
11364 id: profile.profile_id().clone(),
11365 revision: profile.revision().clone(),
11366 retention: profile.retention(),
11367 })
11368 .collect(),
11369 }),
11370 },
11371 )
11372 })
11373 .collect::<BTreeMap<_, _>>();
11374 let mut agent_progress = Vec::new();
11375 for session in &control.sessions {
11376 let Some(binding) = bindings.get(&session.instance_id) else {
11377 continue;
11378 };
11379 if binding.generation != session.generation {
11380 continue;
11381 }
11382 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11383 let address = SessionAddress {
11384 workspace_id: binding.workspace_id.clone(),
11385 session: SessionKey {
11386 instance_id: session.instance_id,
11387 generation: session.generation,
11388 },
11389 };
11390 if let Some(progress) =
11391 agent_progress_from_provider_snapshot(address, &session.provider)
11392 {
11393 agent_progress.push(progress);
11394 }
11395 workspace.sessions.push(session.clone());
11396 }
11397 }
11398 NodeSnapshot {
11399 node_id: self.node_id.clone(),
11400 enabled_providers: self.enabled_providers.clone(),
11401 provider_runtime_statuses: {
11402 let updates = self
11403 .provider_runtime_status_updates
11404 .read()
11405 .unwrap_or_else(|poisoned| poisoned.into_inner());
11406 ProviderRuntimeStatuses::new(
11407 self.provider_runtime_statuses
11408 .iter()
11409 .filter(|status| !updates.contains_key(status.provider()))
11410 .cloned()
11411 .chain(updates.values().cloned()),
11412 )
11413 .expect("refreshed provider runtime inventory remains bounded and unique")
11414 },
11415 workspaces: workspaces.into_values().collect(),
11416 session_records: self
11417 .session_records
11418 .lock()
11419 .unwrap_or_else(|poisoned| poisoned.into_inner())
11420 .records
11421 .values()
11422 .cloned()
11423 .collect(),
11424 managed_worktrees: self.managed_worktrees
11425 .lock()
11426 .unwrap_or_else(|poisoned| poisoned.into_inner())
11427 .snapshots(),
11428 launch_inventory: Some(LaunchInventory {
11429 spawn_profiles: Some(
11430 self.spawn_profiles
11431 .iter()
11432 .filter_map(|profile| {
11433 resolved_spawn_profile_summary(profile, &environment_profiles)
11434 })
11435 .collect(),
11436 ),
11437 bundles: Some(
11438 self.bundle_catalog
11439 .read()
11440 .unwrap_or_else(|poisoned| poisoned.into_inner())
11441 .iter()
11442 .map(NodeBundle::receipt)
11443 .collect(),
11444 ),
11445 network_allowlists: {
11448 let catalog = self
11449 .network_allowlist_catalog
11450 .read()
11451 .unwrap_or_else(|poisoned| poisoned.into_inner());
11452 if catalog.is_empty() {
11453 None
11454 } else {
11455 Some(catalog.ids().cloned().collect())
11456 }
11457 },
11458 }),
11459 agent_progress,
11460 }
11461 }
11462
11463 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11464 assert!(
11465 !matches!(
11466 &event,
11467 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11468 ),
11469 "terminal frame and agent stream events must use their own dedicated live channel",
11470 );
11471 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11472 let sequence = history
11473 .last_sequence
11474 .checked_add(1)
11475 .expect("node event sequence exhausted");
11476 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11477 if let Some(evicted) = history.events.pop_front() {
11478 history.replay_floor_sequence = evicted
11479 .sequence
11480 .checked_add(1)
11481 .expect("node replay floor sequence exhausted");
11482 history.removed_record_providers.remove(&evicted.sequence);
11483 }
11484 }
11485 match &event {
11486 NodeEvent::HarnessMcpReadCall { .. } => {
11487 panic!("transient harness MCP calls must not enter durable event history")
11488 }
11489 NodeEvent::SessionRecordUpserted { record } => {
11490 history
11491 .record_providers
11492 .insert(record.record_id.clone(), record.provider.clone());
11493 }
11494 NodeEvent::SessionRecordRemoved { record_id } => {
11495 if let Some(provider) = history.record_providers.remove(record_id) {
11496 history.removed_record_providers.insert(sequence, provider);
11497 }
11498 }
11499 NodeEvent::ManagedWorktreeUpserted { .. }
11500 | NodeEvent::ManagedWorktreeRemoved { .. }
11501 | NodeEvent::Control { .. }
11502 | NodeEvent::ControllerChanged { .. }
11503 | NodeEvent::SessionRecordHistorySummarized { .. }
11504 | NodeEvent::WorkspaceAdded { .. }
11505 | NodeEvent::WorkspaceRemoved { .. }
11506 | NodeEvent::ResyncRequired { .. }
11507 | NodeEvent::AgentStream { .. }
11508 | NodeEvent::TerminalFrame { .. } => {}
11509 }
11510 let envelope = NodeEventEnvelope { sequence, event };
11511 history.events.push_back(envelope.clone());
11512 history.last_sequence = sequence;
11513 let _ = self.event_tx.send(envelope.clone());
11514 drop(history);
11515 envelope
11516 }
11517
11518 fn publish_transient(&self, event: NodeEvent) -> bool {
11529 assert!(event.requires_harness_mcp_proxy_capability());
11530 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11531 let envelope = NodeEventEnvelope { sequence: 0, event };
11532 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11533 Ok(_) => true,
11534 Err(broadcast::error::SendError(undelivered)) => {
11535 if let Some(NodeEventEnvelope {
11536 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11537 ..
11538 }) = undelivered.first() {
11539 tracing::warn!(
11545 call_id = call_id.as_str(),
11546 content_type = ?request.content_type,
11547 cause = "no-subscribers",
11548 "harness MCP proxy event could not be published",
11549 );
11550 }
11551 false
11552 }
11553 }
11554 }
11555
11556 fn publish_terminal_frames(&self) {
11557 let snapshot = self.handle.snapshot();
11558 let bindings = self
11559 .session_bindings
11560 .lock()
11561 .unwrap_or_else(|poisoned| poisoned.into_inner());
11562 let candidates = snapshot
11563 .sessions
11564 .iter()
11565 .filter_map(|session| {
11566 let binding = bindings.get(&session.instance_id)?;
11567 if binding.generation != session.generation {
11568 return None;
11569 }
11570 let frame = session.terminal_frame.clone()?;
11571 crate::session_signals::feed(
11572 binding.workspace_id.as_str(),
11573 session.instance_id.0,
11574 session.agent_id.as_str(),
11575 &frame.contents,
11576 );
11577 crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11578 crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11579 Some((
11580 SessionAddress {
11581 workspace_id: binding.workspace_id.clone(),
11582 session: SessionKey {
11583 instance_id: session.instance_id,
11584 generation: session.generation,
11585 },
11586 },
11587 frame,
11588 ))
11589 })
11590 .collect();
11591 drop(bindings);
11592 self.publish_terminal_frame_candidates(candidates);
11593 }
11594
11595 fn publish_terminal_frame_candidates(
11596 &self,
11597 candidates: Vec<(SessionAddress, TerminalFrame)>,
11598 ) {
11599 let mut watermarks = self
11600 .terminal_frame_watermarks
11601 .lock()
11602 .unwrap_or_else(|poisoned| poisoned.into_inner());
11603 let mut events = Vec::with_capacity(candidates.len());
11604 for (address, frame) in candidates {
11605 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11606 let advanced = watermarks
11607 .get(&address.session.instance_id)
11608 .map_or(true, |(current_address, sequence)| {
11609 current_address != &address || frame.sequence > *sequence
11610 });
11611 if !advanced {
11612 continue;
11613 }
11614 if is_first_frame {
11615 if self.controller_state().is_none() {
11629 tracing::warn!(
11630 node_id = %self.node_id,
11631 workspace_id = %address.workspace_id,
11632 session = ?address.session,
11633 terminal_size = ?frame.size,
11634 "session producing output with no controller attached",
11635 );
11636 } else {
11637 tracing::info!(
11638 node_id = %self.node_id,
11639 workspace_id = %address.workspace_id,
11640 session = ?address.session,
11641 terminal_size = ?frame.size,
11642 "session produced its first output",
11643 );
11644 }
11645 }
11646 watermarks.insert(
11647 address.session.instance_id,
11648 (address.clone(), frame.sequence),
11649 );
11650 if let Some((at, reported)) = self
11651 .pty_input_marks
11652 .lock()
11653 .unwrap_or_else(|poisoned| poisoned.into_inner())
11654 .get_mut(&address.session.instance_id)
11655 {
11656 if !*reported && at.elapsed() < Duration::from_secs(5) {
11658 *reported = true;
11659 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");
11660 }
11661 }
11662 events.push(NodeEvent::TerminalFrame { address, frame });
11663 }
11664 drop(watermarks);
11665 if events.is_empty() {
11666 return;
11667 }
11668 let mut history = self
11669 .history
11670 .lock()
11671 .unwrap_or_else(|poisoned| poisoned.into_inner());
11672 let mut envelopes = Vec::with_capacity(events.len());
11673 for event in events {
11674 let sequence = history
11675 .last_sequence
11676 .checked_add(1)
11677 .expect("node event sequence exhausted");
11678 history.last_sequence = sequence;
11679 envelopes.push(NodeEventEnvelope { sequence, event });
11680 }
11681 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11682 drop(history);
11683 }
11684
11685 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11694 let mut history = self
11695 .history
11696 .lock()
11697 .unwrap_or_else(|poisoned| poisoned.into_inner());
11698 let sequence = history
11699 .last_sequence
11700 .checked_add(1)
11701 .expect("node event sequence exhausted");
11702 history.last_sequence = sequence;
11703 drop(history);
11704 tracing::debug!(
11712 source_sequence = chunk.source_sequence,
11713 kind = agent_stream_chunk_kind_label(&chunk.kind),
11714 envelope_sequence = sequence,
11715 "publishing agent stream chunk",
11716 );
11717 let envelope = NodeEventEnvelope {
11718 sequence,
11719 event: NodeEvent::AgentStream { address, chunk },
11720 };
11721 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11722 }
11723
11724 fn current_sequence(&self) -> u64 {
11725 self.history
11726 .lock()
11727 .unwrap_or_else(|poisoned| poisoned.into_inner())
11728 .last_sequence
11729 }
11730
11731 fn controller_state(&self) -> Option<ControllerState> {
11732 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11733 let lease = controller.as_ref().copied()?;
11734 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11735 if remaining.is_zero() {
11736 *controller = None;
11737 return None;
11738 }
11739 Some(ControllerState {
11740 connection_id: lease.connection_id,
11741 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11742 })
11743 }
11744
11745 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11746 if role != ClientRole::Operator {
11747 tracing::warn!(
11748 node_id = %self.node_id,
11749 connection_id,
11750 "controller lease rejected: observer connections cannot acquire control",
11751 );
11752 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11753 }
11754 let lease_ms = if lease_ms == 0 {
11755 DEFAULT_CONTROLLER_LEASE_MS
11756 } else {
11757 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11758 };
11759 let now = Instant::now();
11760 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11761 if let Some(active) = controller.as_ref() {
11762 if active.connection_id != connection_id && active.expires_at > now {
11763 tracing::warn!(
11764 node_id = %self.node_id,
11765 connection_id,
11766 holder_connection_id = active.connection_id,
11767 "controller lease rejected: another operator holds it",
11768 );
11769 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11770 }
11771 }
11772 let is_new_attach = match controller.as_ref() {
11779 Some(active) => active.connection_id != connection_id,
11780 None => true,
11781 };
11782 *controller = Some(ControllerLease {
11783 connection_id,
11784 expires_at: now + Duration::from_millis(lease_ms),
11785 });
11786 drop(controller);
11787 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11788 if is_new_attach {
11789 tracing::info!(
11790 node_id = %self.node_id,
11791 connection_id,
11792 lease_ms,
11793 "controller lease acquired",
11794 );
11795 } else {
11796 tracing::debug!(
11797 node_id = %self.node_id,
11798 connection_id,
11799 lease_ms,
11800 "controller lease renewed",
11801 );
11802 }
11803 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11804 Ok(state)
11805 }
11806
11807 fn release_controller(&self, connection_id: u64) -> bool {
11808 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11809 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11810 if released {
11811 *controller = None;
11812 }
11813 drop(controller);
11814 if released {
11815 tracing::info!(
11816 node_id = %self.node_id,
11817 connection_id,
11818 "controller lease released",
11819 );
11820 self.publish(NodeEvent::ControllerChanged { controller: None });
11821 }
11822 released
11823 }
11824
11825 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11834 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11835 return;
11836 };
11837 let id = address.session.instance_id;
11838 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11839 return;
11840 };
11841 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11842 .iter()
11843 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11844 .collect();
11845 let (writes, set_model, effort_gap) =
11846 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11847 if set_model {
11848 if let Some(model_id) = model.clone() {
11849 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11850 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11851 }
11852 }
11853 }
11854 for (option_id, value_json) in writes {
11855 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11856 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11857 }
11858 }
11859 if effort_gap {
11860 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11861 }
11862 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11863 }
11864
11865 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11868 let id = address.session.instance_id;
11869 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11870 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11871 crate::auto_permit::forget_acp(id.0);
11872 return;
11873 }
11874 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11875 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11876 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11877 }
11878 match provider_event {
11879 ProviderEvent::ModeChanged { mode_id, available } => {
11880 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11881 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11882 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11883 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11884 }
11885 }
11886 }
11887 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11888 match self.dispatch(ControlCommand::ResolveInteraction {
11889 instance_id: id,
11890 generation: address.session.generation,
11891 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11892 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11893 }) {
11894 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11895 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11896 }
11897 }
11898 _ => {}
11901 }
11902 }
11903
11904 fn mark_spawn_provisional(&self, address: SessionAddress) {
11905 let id = address.session.instance_id;
11906 self.provisional_spawns
11907 .lock()
11908 .unwrap_or_else(|poisoned| poisoned.into_inner())
11909 .insert(id, (address, Instant::now()));
11910 tracing::info!(
11911 node_id = %self.node_id,
11912 instance_id = id.0,
11913 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11914 "draft spawn; waiting for HQ view",
11915 );
11916 }
11917
11918 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11919 let removed = self
11920 .provisional_spawns
11921 .lock()
11922 .unwrap_or_else(|poisoned| poisoned.into_inner())
11923 .remove(&instance_id);
11924 if removed.is_some() {
11925 tracing::info!(
11926 node_id = %self.node_id,
11927 instance_id = instance_id.0,
11928 "draft spawn claimed by HQ view",
11929 );
11930 }
11931 }
11932
11933 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11934 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11935 let mut map = self
11936 .provisional_spawns
11937 .lock()
11938 .unwrap_or_else(|poisoned| poisoned.into_inner());
11939 let due: Vec<AgentInstanceId> = map
11940 .iter()
11941 .filter(|(_, (_, at))| at.elapsed() >= grace)
11942 .map(|(id, _)| *id)
11943 .collect();
11944 due.into_iter()
11945 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11946 .collect()
11947 }
11948
11949 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11950 let instance_id = address.session.instance_id;
11951 tracing::warn!(
11952 node_id = %self.node_id,
11953 instance_id = instance_id.0,
11954 workspace = %address.workspace_id,
11955 reason = "attach_never_arrived",
11956 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11957 "draft spawn never viewed; stopping PTY",
11958 );
11959 let _ = self.dispatch(ControlCommand::Stop {
11960 instance_id,
11961 force: true,
11962 });
11963 let _ = self.remove_session(&address).await;
11964 }
11965
11966 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11967 if role != ClientRole::Operator {
11968 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11969 }
11970 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11971 let now = Instant::now();
11972 match controller.as_ref().copied() {
11973 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11974 Some(active) if active.expires_at <= now => {
11975 *controller = None;
11976 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11977 }
11978 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11979 }
11980 }
11981
11982 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11983 if !self
11984 .workspaces
11985 .read()
11986 .unwrap_or_else(|poisoned| poisoned.into_inner())
11987 .contains_key(&address.workspace_id)
11988 {
11989 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11990 }
11991 let binding = self
11992 .session_bindings
11993 .lock()
11994 .unwrap_or_else(|poisoned| poisoned.into_inner())
11995 .get(&address.session.instance_id)
11996 .cloned()
11997 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11998 if binding.workspace_id != address.workspace_id {
11999 return Err(failure(
12000 NodeFailureCode::SessionWorkspaceMismatch,
12001 "session belongs to another workspace",
12002 ));
12003 }
12004 if binding.generation != address.session.generation {
12005 return Err(failure(
12006 NodeFailureCode::StaleGeneration,
12007 &format!(
12008 "stale session generation: expected {}, received {}",
12009 binding.generation.0,
12010 address.session.generation.0,
12011 ),
12012 ));
12013 }
12014 let snapshot = self.handle.snapshot();
12015 let Some(current) = snapshot
12016 .sessions
12017 .iter()
12018 .find(|item| item.instance_id == address.session.instance_id)
12019 else {
12020 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12021 };
12022 if current.generation != address.session.generation {
12023 return Err(failure(
12024 NodeFailureCode::StaleGeneration,
12025 &format!(
12026 "stale session generation: expected {}, received {}",
12027 current.generation.0,
12028 address.session.generation.0,
12029 ),
12030 ));
12031 }
12032 Ok(current.agent_id.clone())
12033 }
12034
12035 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12036 self.dispatch_envelope(self.prepare_command(command))
12037 }
12038
12039 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12040 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12041 CommandEnvelope {
12042 id: CommandId(id),
12043 command,
12044 }
12045 }
12046
12047 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12048 let command_id = envelope.id;
12049 self.handle
12050 .dispatch(envelope)
12051 .map_err(|error| match error {
12052 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12053 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12054 })?;
12055 Ok(command_id)
12056 }
12057
12058 fn record_rejected_command(
12067 &self,
12068 command_id: CommandId,
12069 message: String,
12070 unsupported_transport: bool,
12071 ) {
12072 let now = Instant::now();
12073 let mut reasons = self.rejected_command_reasons.lock()
12074 .unwrap_or_else(|poisoned| poisoned.into_inner());
12075 reasons.retain(|_, reason| reason.expires_at > now);
12076 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12077 if let Some(&oldest) = reasons.keys().next() {
12078 reasons.remove(&oldest);
12079 }
12080 }
12081 reasons.insert(
12082 command_id,
12083 RejectedCommandReason {
12084 message,
12085 unsupported_transport,
12086 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12087 },
12088 );
12089 }
12090
12091 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12098 let now = Instant::now();
12099 let mut reasons = self.rejected_command_reasons.lock()
12100 .unwrap_or_else(|poisoned| poisoned.into_inner());
12101 match reasons.remove(&command_id) {
12102 Some(reason) if reason.expires_at > now => Some(reason),
12103 _ => None,
12104 }
12105 }
12106
12107 async fn dispatch_bounded(
12108 &self,
12109 command: ControlCommand,
12110 timeout_duration: Duration,
12111 ) -> Result<CommandId, NodeFailure> {
12112 let deadline = Instant::now() + timeout_duration;
12113 loop {
12114 match self.dispatch(command.clone()) {
12115 Ok(command_id) => return Ok(command_id),
12116 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12117 sleep(Duration::from_millis(2)).await;
12118 }
12119 Err(error) => return Err(error),
12120 }
12121 }
12122 }
12123
12124 fn internal_session_snapshot(
12125 &self,
12126 address: &SessionAddress,
12127 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12128 self.validate_address(address)?;
12129 self.handle
12130 .snapshot()
12131 .sessions
12132 .clone()
12133 .into_iter()
12134 .find(|session| {
12135 session.instance_id == address.session.instance_id
12136 && session.generation == address.session.generation
12137 })
12138 .ok_or_else(|| {
12139 failure(
12140 NodeFailureCode::UnknownSession,
12141 "session snapshot is unavailable",
12142 )
12143 })
12144 }
12145
12146 fn require_acp_session(
12154 &self,
12155 address: &SessionAddress,
12156 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12157 let snapshot = self.internal_session_snapshot(address)?;
12158 if snapshot.transport != TransportKind::Acp {
12159 return Err(failure(
12160 NodeFailureCode::UnsupportedTransport,
12161 "this verb requires a session spawned over the ACP transport",
12162 ));
12163 }
12164 Ok(snapshot)
12165 }
12166
12167 async fn dispatch_history_bounded(
12168 &self,
12169 address: &SessionAddress,
12170 command: ControlCommand,
12171 operation: HistoryOperation,
12172 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12173 self.validate_address(address)?;
12174 let started_after = self.current_sequence();
12175 let command_id = self
12176 .dispatch_bounded(
12177 command,
12178 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12179 )
12180 .await?;
12181 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12182 let mut scan_after = started_after;
12183 let mut accepted_after = None;
12184 loop {
12185 let events = self
12186 .history
12187 .lock()
12188 .unwrap_or_else(|poisoned| poisoned.into_inner())
12189 .events
12190 .iter()
12191 .filter(|envelope| envelope.sequence > scan_after)
12192 .cloned()
12193 .collect::<Vec<_>>();
12194 for envelope in events {
12195 scan_after = scan_after.max(envelope.sequence);
12196 let NodeEvent::Control {
12197 address: event_address,
12198 event,
12199 } = envelope.event
12200 else {
12201 continue;
12202 };
12203 if event_address != *address {
12204 continue;
12205 }
12206 if event.command_id == Some(command_id) {
12207 match &event.event {
12208 ControlEventKind::CommandRejected { .. } => {
12209 return Err(failure(
12210 NodeFailureCode::InvalidRequest,
12211 "history request was rejected",
12212 ));
12213 }
12214 ControlEventKind::HistoryRequested {
12215 operation: requested,
12216 ..
12217 } if requested == &operation => {
12218 accepted_after = Some(envelope.sequence);
12219 continue;
12220 }
12221 _ => {}
12222 }
12223 }
12224 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12225 match (&operation, &event.event) {
12226 (
12227 HistoryOperation::Discover { .. },
12228 ControlEventKind::HistoryDiscovered { .. },
12229 )
12230 | (
12231 HistoryOperation::Load { .. },
12232 ControlEventKind::HistoryLoaded { .. },
12233 ) => return self.internal_session_snapshot(address),
12234 (_, ControlEventKind::HistoryFailed { .. }) => {
12235 return Err(failure(
12236 NodeFailureCode::BackendOperationFailed,
12237 "history operation failed",
12238 ));
12239 }
12240 _ => {}
12241 }
12242 }
12243 }
12244 if Instant::now() >= deadline {
12245 return Err(failure(
12246 NodeFailureCode::BackendBusy,
12247 if accepted_after.is_some() {
12248 "history operation did not settle before the bounded deadline"
12249 } else {
12250 "history operation was not accepted before the bounded deadline"
12251 },
12252 ));
12253 }
12254 sleep(Duration::from_millis(2)).await;
12255 }
12256 }
12257
12258 async fn discover_history(
12259 &self,
12260 address: &SessionAddress,
12261 limit: u16,
12262 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12263 let query = HistoryQuery {
12264 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12265 limit,
12266 };
12267 query
12268 .validate()
12269 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12270 let snapshot = self
12271 .dispatch_history_bounded(
12272 address,
12273 ControlCommand::DiscoverHistory {
12274 instance_id: address.session.instance_id,
12275 query: query.clone(),
12276 },
12277 HistoryOperation::Discover { query },
12278 )
12279 .await?;
12280 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12281 return Err(failure(
12282 NodeFailureCode::BackendOperationFailed,
12283 "history discovery settled into an invalid state",
12284 ));
12285 }
12286 Ok(snapshot.history.candidates)
12287 }
12288
12289 async fn load_history(
12290 &self,
12291 address: &SessionAddress,
12292 candidate_id: String,
12293 ) -> Result<HistorySessionRecord, NodeFailure> {
12294 validate_candidate_id(&candidate_id)
12295 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12296 let snapshot = self
12297 .dispatch_history_bounded(
12298 address,
12299 ControlCommand::LoadHistory {
12300 instance_id: address.session.instance_id,
12301 candidate_id: candidate_id.clone(),
12302 },
12303 HistoryOperation::Load {
12304 candidate_id: candidate_id.clone(),
12305 },
12306 )
12307 .await?;
12308 if snapshot.history.pending.is_some()
12309 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12310 {
12311 return Err(failure(
12312 NodeFailureCode::BackendOperationFailed,
12313 "history load settled into an invalid state",
12314 ));
12315 }
12316 snapshot.history.loaded.ok_or_else(|| {
12317 failure(
12318 NodeFailureCode::BackendOperationFailed,
12319 "history load produced no bounded session",
12320 )
12321 })
12322 }
12323
12324 async fn export_context_pack_for_session_record(
12331 &self,
12332 record_id: &SessionRecordId,
12333 session: &SessionAddress,
12334 allow_clean_detachment: bool,
12335 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12336 let outcome = self
12342 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12343 .await;
12344 if let Err(failure) = &outcome {
12345 tracing::warn!(
12346 record = %record_id,
12347 code = ?failure.code,
12348 reason = %failure.message,
12349 "session-record context-pack export refused",
12350 );
12351 }
12352 outcome
12353 }
12354
12355 async fn export_context_pack_for_session_record_inner(
12356 &self,
12357 record_id: &SessionRecordId,
12358 session: &SessionAddress,
12359 allow_clean_detachment: bool,
12360 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12361 let step = |name: &'static str| move |failure: NodeFailure| {
12366 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12367 failure
12368 };
12369 let record = self.record(record_id).map_err(step("record-lookup"))?;
12370 let identity = match record.provider_session.clone() {
12386 Some(identity) => {
12387 if identity.key != ProviderSessionKey::SessionId {
12388 return Err(step("provider-identity-key")(failure(
12389 NodeFailureCode::BackendOperationFailed,
12390 "managed session provider identity cannot bind history exactly",
12391 )));
12392 }
12393 Some(identity)
12394 }
12395 None if record.state == ManagedSessionState::IdentityPending => {
12396 return Err(step("provider-session-identity")(failure(
12397 NodeFailureCode::BackendOperationFailed,
12398 "managed session has not yet observed its provider session identity",
12399 )));
12400 }
12401 None => None,
12402 };
12403 self.revalidate_session_record_context_export(
12404 &record,
12405 identity.as_ref(),
12406 session,
12407 allow_clean_detachment,
12408 ).map_err(step("revalidate-before-discovery"))?;
12409 let candidates = self
12410 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12411 .await
12412 .map_err(step("discover-history"))?;
12413 let candidate_id = select_history_candidate(
12418 &candidates,
12419 identity.as_ref().map(|identity| identity.id.as_str()),
12420 ).map_err(step("unique-candidate"))?;
12421 let loaded = self.load_history(session, candidate_id.clone()).await
12422 .map_err(step("load-history"))?;
12423 if let Some(identity) = identity.as_ref() {
12424 if loaded.session_id != identity.id {
12425 return Err(failure(
12426 NodeFailureCode::SessionRecordConflict,
12427 "loaded history identity does not match the managed session record",
12428 ));
12429 }
12430 }
12431 self.revalidate_session_record_context_export(
12432 &record,
12433 identity.as_ref(),
12434 session,
12435 allow_clean_detachment,
12436 )?;
12437 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12438 .map_err(step("revalidate-loaded-history"))?;
12439 let pack = self.materialize_context_pack(session).await
12440 .map_err(step("materialize-context-pack"))?;
12441 self.commit_context_pack_for_session_record(
12442 &record,
12443 identity.as_ref(),
12444 session,
12445 &candidate_id,
12446 &loaded,
12447 pack,
12448 allow_clean_detachment,
12449 )
12450 }
12451
12452 async fn export_context_pack(
12453 &self,
12454 address: &SessionAddress,
12455 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12456 let pack = self.materialize_context_pack(address).await?;
12457 self.commit_context_pack(pack)
12458 }
12459
12460 async fn materialize_context_pack(
12461 &self,
12462 address: &SessionAddress,
12463 ) -> Result<NodeContextPack, NodeFailure> {
12464 let snapshot = self.internal_session_snapshot(address)?;
12465 if !context_pack_source_status_is_usable(&snapshot.status) {
12466 return Err(failure(
12467 NodeFailureCode::InvalidRequest,
12468 "context export requires a running or successfully exited source session",
12469 ));
12470 }
12471 if snapshot.history.pending.is_some()
12472 || snapshot.history.loaded_candidate_id.is_none()
12473 {
12474 return Err(failure(
12475 NodeFailureCode::InvalidRequest,
12476 "context export requires settled loaded history",
12477 ));
12478 }
12479 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12480 failure(
12481 NodeFailureCode::InvalidRequest,
12482 "context export requires settled loaded history",
12483 )
12484 })?;
12485 let source_provider = snapshot.agent_id.clone();
12486 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12487 let repository = self
12488 .context_pack_repository(&address.workspace_id)
12489 .await?;
12490 let current = self.internal_session_snapshot(address)?;
12491 if !context_pack_source_status_is_usable(¤t.status)
12492 || current.agent_id != source_provider
12493 || current.history.pending.is_some()
12494 || current.history.loaded_candidate_id != loaded_candidate_id
12495 || current.history.loaded.as_ref() != Some(&loaded)
12496 {
12497 return Err(failure(
12498 NodeFailureCode::ContextPackMaterializationFailed,
12499 "context source changed during bounded repository capture",
12500 ));
12501 }
12502 NodeContextPack::export_with_repository(
12503 ContextPackLineageReceipt {
12504 source_node_id: self.node_id.clone(),
12505 source_session: address.clone(),
12506 source_provider,
12507 },
12508 &loaded,
12509 Some(repository),
12510 )
12511 .map_err(|_| {
12512 failure(
12513 NodeFailureCode::ContextPackMaterializationFailed,
12514 "bounded context pack export failed",
12515 )
12516 })
12517 }
12518
12519 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12525 let mut store = self
12526 .context_pack_store
12527 .lock()
12528 .unwrap_or_else(|poisoned| poisoned.into_inner());
12529 let Some(store) = store.as_mut() else {
12530 return Ok(());
12531 };
12532 store.commit(pack).map_err(|_| {
12533 failure(
12534 NodeFailureCode::ContextPackMaterializationFailed,
12535 "durable context pack commit failed",
12536 )
12537 })
12538 }
12539
12540 fn commit_context_pack(
12541 &self,
12542 pack: NodeContextPack,
12543 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12544 self.commit_context_pack_durably(&pack)?;
12545 self.context_catalog
12546 .write()
12547 .unwrap_or_else(|poisoned| poisoned.into_inner())
12548 .insert(pack)
12549 .map_err(|_| {
12550 failure(
12551 NodeFailureCode::ContextPackMaterializationFailed,
12552 "context pack catalog is full",
12553 )
12554 })
12555 }
12556
12557 fn commit_context_pack_for_session_record(
12558 &self,
12559 expected: &ManagedSessionRecord,
12560 identity: Option<&ProviderSessionIdentity>,
12561 session: &SessionAddress,
12562 candidate_id: &str,
12563 loaded: &HistorySessionRecord,
12564 pack: NodeContextPack,
12565 allow_clean_detachment: bool,
12566 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12567 let current_root = self.workspace_root(&expected.workspace_id)?;
12568 let _transaction = self
12569 .state_transaction
12570 .lock()
12571 .unwrap_or_else(|poisoned| poisoned.into_inner());
12572 let records = self
12573 .session_records
12574 .lock()
12575 .unwrap_or_else(|poisoned| poisoned.into_inner());
12576 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12577 failure(
12578 NodeFailureCode::SessionRecordConflict,
12579 "managed session record disappeared during context export",
12580 )
12581 })?;
12582 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12583 return Err(failure(
12584 NodeFailureCode::SessionRecordConflict,
12585 "managed session record changed during context export",
12586 ));
12587 }
12588 let bindings = self
12589 .session_bindings
12590 .lock()
12591 .unwrap_or_else(|poisoned| poisoned.into_inner());
12592 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12593 failure(
12594 NodeFailureCode::SessionRecordConflict,
12595 "managed session runtime binding disappeared during context export",
12596 )
12597 })?;
12598 let runtime = self
12599 .handle
12600 .snapshot()
12601 .sessions
12602 .iter()
12603 .find(|snapshot| {
12604 snapshot.instance_id == session.session.instance_id
12605 && snapshot.generation == session.session.generation
12606 })
12607 .cloned()
12608 .ok_or_else(|| {
12609 failure(
12610 NodeFailureCode::SessionRecordConflict,
12611 "managed session runtime disappeared during context export",
12612 )
12613 })?;
12614 let receipt = pack.receipt();
12615 if !session_record_context_export_binding_is_exact(
12616 expected,
12617 current,
12618 identity,
12619 session,
12620 binding,
12621 &runtime.agent_id,
12622 ¤t_root,
12623 allow_clean_detachment,
12624 ) || !session_record_context_export_source_is_usable(
12625 current,
12626 &runtime.status,
12627 allow_clean_detachment,
12628 ) || runtime.history.pending.is_some()
12629 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12630 || runtime.history.loaded.as_ref() != Some(loaded)
12631 || receipt.lineage.source_node_id != self.node_id
12632 || receipt.lineage.source_session != *session
12633 || receipt.lineage.source_provider != current.provider
12634 || receipt.source_message_count != loaded.message_count
12635 {
12636 return Err(failure(
12637 NodeFailureCode::SessionRecordConflict,
12638 "managed session source changed before context catalog commit",
12639 ));
12640 }
12641 self.commit_context_pack_durably(&pack)?;
12642 self.context_catalog
12643 .write()
12644 .unwrap_or_else(|poisoned| poisoned.into_inner())
12645 .insert(pack)
12646 .map_err(|_| {
12647 failure(
12648 NodeFailureCode::ContextPackMaterializationFailed,
12649 "context pack catalog is full",
12650 )
12651 })
12652 }
12653
12654 async fn context_pack_repository(
12655 &self,
12656 workspace_id: &WorkspaceId,
12657 ) -> Result<ContextPackRepository, NodeFailure> {
12658 let first = self
12659 .context_pack_repository_observation(workspace_id)
12660 .await?;
12661 let second = self
12662 .context_pack_repository_observation(workspace_id)
12663 .await?;
12664 stable_context_pack_repository(first, second)
12665 }
12666
12667 async fn context_pack_repository_observation(
12668 &self,
12669 workspace_id: &WorkspaceId,
12670 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12671 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12672 let head = if git.is_repository {
12673 let root = self.workspace_root(workspace_id)?;
12674 match resolve_base_commit_with_timeout(
12675 &root,
12676 "HEAD",
12677 GIT_COMMAND_TIMEOUT_MS,
12678 )
12679 .await
12680 {
12681 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12682 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12683 ContextPackRepositoryHead::Unborn
12684 }
12685 Err(_) => {
12686 return Err(failure(
12687 NodeFailureCode::ContextPackMaterializationFailed,
12688 "context pack HEAD observation failed",
12689 ));
12690 }
12691 }
12692 } else {
12693 ContextPackRepositoryHead::NotRepository
12694 };
12695 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12696 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12697 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12698 failure(
12699 NodeFailureCode::InvalidRepositoryPath,
12700 "context pack selected file path is invalid",
12701 )
12702 })?;
12703 match self.read_workspace_file(workspace_id.clone(), path).await {
12704 Ok(WorkspaceFileRead {
12705 content: WorkspaceFileContent::Utf8 { text, byte_len },
12706 ..
12707 }) => files.push(ContextPackRepositoryFileSource::utf8(
12708 selected_path,
12709 text,
12710 byte_len,
12711 )),
12712 Ok(WorkspaceFileRead {
12713 content: WorkspaceFileContent::NonUtf8 { byte_len },
12714 ..
12715 }) => files.push(ContextPackRepositoryFileSource::skipped(
12716 selected_path,
12717 ContextPackSelectedFileSkipReason::NonUtf8,
12718 Some(byte_len),
12719 )),
12720 Ok(WorkspaceFileRead {
12721 content: WorkspaceFileContent::TooLarge { .. },
12722 ..
12723 }) => files.push(ContextPackRepositoryFileSource::skipped(
12724 selected_path,
12725 ContextPackSelectedFileSkipReason::TooLarge,
12726 None,
12727 )),
12728 Err(error) => {
12729 let reason = if matches!(
12730 error.code,
12731 NodeFailureCode::InvalidRepositoryPath
12732 | NodeFailureCode::RepositoryFileNotRegular
12733 | NodeFailureCode::RepositoryPathUnsafe
12734 ) {
12735 ContextPackSelectedFileSkipReason::Unsafe
12736 } else {
12737 ContextPackSelectedFileSkipReason::Unavailable
12738 };
12739 files.push(ContextPackRepositoryFileSource::skipped(
12740 selected_path,
12741 reason,
12742 None,
12743 ));
12744 }
12745 }
12746 }
12747 Ok(ContextPackRepositoryObservation { head, git, files })
12748 }
12749
12750 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12751 let referenced_by_binding = self
12752 .session_bindings
12753 .lock()
12754 .unwrap_or_else(|poisoned| poisoned.into_inner())
12755 .values()
12756 .any(|binding| {
12757 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12758 });
12759 let referenced_by_record = self
12760 .session_records
12761 .lock()
12762 .unwrap_or_else(|poisoned| poisoned.into_inner())
12763 .records
12764 .values()
12765 .any(|record| record.context_id.as_ref() == Some(context_id));
12766 let referenced_by_materialization = self
12767 .materializations
12768 .lock()
12769 .unwrap_or_else(|poisoned| poisoned.into_inner())
12770 .values()
12771 .any(|ownership| {
12772 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12773 });
12774 if referenced_by_binding
12775 || referenced_by_record
12776 || referenced_by_materialization
12777 {
12778 return Err(failure(
12779 NodeFailureCode::ContextPackBusy,
12780 "context pack is still referenced",
12781 ));
12782 }
12783 let mut catalog = self.context_catalog
12784 .write()
12785 .unwrap_or_else(|poisoned| poisoned.into_inner());
12786 if catalog.get(context_id).is_none() {
12787 return Err(failure(
12788 NodeFailureCode::UnknownContextPack,
12789 "context pack is unavailable",
12790 ));
12791 }
12792 self.spawn_idempotency
12793 .lock()
12794 .unwrap_or_else(|poisoned| poisoned.into_inner())
12795 .entries
12796 .retain(|_, entry| !entry.value.references_context(context_id));
12797 let removed = catalog.remove(context_id);
12798 debug_assert!(removed.is_some(), "validated context pack remains present");
12799 Ok(())
12800 }
12801
12802 async fn dispatch_input_bounded(
12803 &self,
12804 address: &SessionAddress,
12805 action: InputAction,
12806 ) -> Result<(), NodeFailure> {
12807 let started_after = self.current_sequence();
12808 self.pty_input_marks
12809 .lock()
12810 .unwrap_or_else(|poisoned| poisoned.into_inner())
12811 .insert(address.session.instance_id, (Instant::now(), false));
12812 self.pty_input_wake.notify_one();
12813 let command_id = self
12814 .dispatch_bounded(
12815 ControlCommand::SendInput {
12816 instance_id: address.session.instance_id,
12817 action,
12818 },
12819 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12820 )
12821 .await?;
12822 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12826 let mut scan_after = started_after;
12827 let mut accepted_after = None;
12828 loop {
12829 let events = self
12830 .history
12831 .lock()
12832 .unwrap_or_else(|poisoned| poisoned.into_inner())
12833 .events
12834 .iter()
12835 .filter(|envelope| envelope.sequence > scan_after)
12836 .cloned()
12837 .collect::<Vec<_>>();
12838 for envelope in events {
12839 scan_after = scan_after.max(envelope.sequence);
12840 let NodeEvent::Control {
12841 address: event_address,
12842 event,
12843 } = envelope.event
12844 else {
12845 continue;
12846 };
12847 if event_address != *address {
12848 continue;
12849 }
12850 if event.command_id == Some(command_id) {
12851 match &event.event {
12852 ControlEventKind::CommandRejected { message } => {
12853 return Err(failure(NodeFailureCode::InvalidRequest, message));
12854 }
12855 ControlEventKind::InputRequested { .. } => {
12856 accepted_after = Some(envelope.sequence);
12857 continue;
12858 }
12859 _ => {}
12860 }
12861 }
12862 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12863 match &event.event {
12864 ControlEventKind::InputCompleted { .. } => return Ok(()),
12865 ControlEventKind::InputFailed { message, .. } => {
12866 return Err(failure(
12867 NodeFailureCode::BackendOperationFailed,
12868 message,
12869 ));
12870 }
12871 _ => {}
12872 }
12873 }
12874 }
12875 if Instant::now() >= deadline {
12876 return Err(failure(
12877 NodeFailureCode::BackendBusy,
12878 if accepted_after.is_some() {
12879 "input was accepted but did not settle before the bounded deadline"
12880 } else {
12881 "input was not accepted before the bounded deadline"
12882 },
12883 ));
12884 }
12885 sleep(Duration::from_millis(2)).await;
12886 }
12887 }
12888
12889 async fn dispatch_resize_bounded(
12890 &self,
12891 address: &SessionAddress,
12892 size: gate4agent_types::TerminalSize,
12893 ) -> Result<(), NodeFailure> {
12894 let started_after = self.current_sequence();
12895 let command_id = self
12896 .dispatch_bounded(
12897 ControlCommand::Resize {
12898 instance_id: address.session.instance_id,
12899 size,
12900 },
12901 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12902 )
12903 .await?;
12904 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12905 let mut scan_after = started_after;
12906 let mut accepted_after = None;
12907 loop {
12908 let events = self
12909 .history
12910 .lock()
12911 .unwrap_or_else(|poisoned| poisoned.into_inner())
12912 .events
12913 .iter()
12914 .filter(|envelope| envelope.sequence > scan_after)
12915 .cloned()
12916 .collect::<Vec<_>>();
12917 for envelope in events {
12918 scan_after = scan_after.max(envelope.sequence);
12919 let NodeEvent::Control {
12920 address: event_address,
12921 event,
12922 } = envelope.event
12923 else {
12924 continue;
12925 };
12926 if event_address != *address {
12927 continue;
12928 }
12929 if event.command_id == Some(command_id) {
12930 match &event.event {
12931 ControlEventKind::CommandRejected { message } => {
12932 return Err(failure(NodeFailureCode::InvalidRequest, message));
12933 }
12934 ControlEventKind::ResizeRequested {
12935 size: requested_size,
12936 ..
12937 } if *requested_size == size => {
12938 accepted_after = Some(envelope.sequence);
12939 continue;
12940 }
12941 _ => {}
12942 }
12943 }
12944 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12945 match &event.event {
12946 ControlEventKind::Resized { size: settled_size }
12947 if *settled_size == size =>
12948 {
12949 return Ok(());
12950 }
12951 ControlEventKind::ResizeFailed { message } => {
12952 return Err(failure(
12953 NodeFailureCode::BackendOperationFailed,
12954 message,
12955 ));
12956 }
12957 _ => {}
12958 }
12959 }
12960 }
12961 if Instant::now() >= deadline {
12962 return Err(failure(
12963 NodeFailureCode::BackendBusy,
12964 if accepted_after.is_some() {
12965 "resize was accepted but did not settle before the bounded deadline"
12966 } else {
12967 "resize was not accepted before the bounded deadline"
12968 },
12969 ));
12970 }
12971 sleep(Duration::from_millis(2)).await;
12972 }
12973 }
12974
12975 async fn spawn_session(
12976 &self,
12977 workspace_id: WorkspaceId,
12978 provider: AgentId,
12979 mode: SessionMode,
12980 terminal_size: gate4agent_types::TerminalSize,
12981 initial_prompt: Option<String>,
12982 ) -> Result<SessionAddress, NodeFailure> {
12983 if self.managed_worktrees.lock()
12984 .unwrap_or_else(|poisoned| poisoned.into_inner())
12985 .lease_for_workspace(&workspace_id)
12986 .is_some()
12987 {
12988 return Err(failure(
12989 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12990 "managed worktree targets may only be spawned through their lease request",
12991 ));
12992 }
12993 self
12994 .spawn_session_with_deadline(
12995 workspace_id,
12996 provider.clone(),
12997 mode,
12998 terminal_size,
12999 initial_prompt,
13000 None,
13001 None,
13002 None,
13003 None,
13004 None,
13005 SpawnRecordPolicy::ProviderIdentityOnly,
13006 None,
13007 &[],
13008 None,
13009 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13016 None,
13017 None,
13018 )
13019 .await
13020 .map(|(session, _runtime_policy)| session)
13021 }
13022
13023 async fn spawn_session_with_deadline(
13024 &self,
13025 workspace_id: WorkspaceId,
13026 provider: AgentId,
13027 mode: SessionMode,
13028 terminal_size: gate4agent_types::TerminalSize,
13029 initial_prompt: Option<String>,
13030 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13031 bundle: Option<ResolvedBundleReceipt>,
13032 context: Option<ResolvedContextPackReceipt>,
13033 managed_authority: Option<ManagedWorktreeLeaseId>,
13034 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13035 record_policy: SpawnRecordPolicy,
13036 deadline: Option<Instant>,
13037 required_capabilities: &[ProviderRuntimeCapability],
13038 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13039 approval_level: ApprovalLevel,
13040 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13041 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13047 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13048 let reserved_lease = self.managed_worktrees.lock()
13049 .unwrap_or_else(|poisoned| poisoned.into_inner())
13050 .lease_for_workspace(&workspace_id);
13051 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13052 (Some(expected), Some(provided)) if expected == provided => {}
13053 (None, None) => {}
13054 _ => {
13055 return Err(failure(
13056 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13057 "spawn lacks exact managed worktree lease authority",
13058 ));
13059 }
13060 }
13061 self.ensure_workspace_environment_profile_compatible(
13065 &workspace_id,
13066 environment_profile.as_ref(),
13067 )?;
13068 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13069 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13070 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13071 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13072 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13073 };
13074 let transport = match mode {
13080 SessionMode::Pty => TransportKind::Pty,
13081 SessionMode::Inline => TransportKind::Pipe,
13082 SessionMode::Acp => TransportKind::Acp,
13083 };
13084 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13085 let acp_transport = self.provider_supports_acp_transport(&provider);
13086 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13087 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13088 NodeFailureCode::UnsupportedSpawnCapability,
13089 "pre-admitted provider runtime no longer satisfies spawn requirements",
13090 ))?;
13091 runtime_policy
13092 } else if let Some(deadline) = deadline {
13093 let remaining = spawn_deadline_remaining(deadline)?;
13094 timeout(
13095 remaining,
13096 self.admit_provider_runtime(&provider, runtime_requirement),
13097 )
13098 .await
13099 .map_err(|_| {
13100 failure(
13101 NodeFailureCode::SpawnDeadlineExceeded,
13102 "provider admission exceeded the spawn deadline",
13103 )
13104 })??
13105 } else {
13106 self.admit_provider_runtime(&provider, runtime_requirement).await?
13107 };
13108 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13109 let runtime_policy =
13121 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13122 if required_capabilities
13123 .iter()
13124 .any(|capability| !runtime_policy.admits(*capability))
13125 {
13126 return Err(failure(
13127 NodeFailureCode::UnsupportedSpawnCapability,
13128 "provider runtime does not admit a required spawn capability",
13129 ));
13130 }
13131 if let Some(deadline) = deadline {
13132 spawn_deadline_remaining(deadline)?;
13133 }
13134 self.ensure_binding_capacity()?;
13135 let mut working_directory = self.workspace_root(&workspace_id)?;
13136 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13137 let session = SessionKey {
13138 instance_id,
13139 generation: SessionGeneration(1),
13140 };
13141 let address = SessionAddress {
13142 workspace_id: workspace_id.clone(),
13143 session,
13144 };
13145 tracing::info!(
13153 node_id = %self.node_id,
13154 workspace_id = %workspace_id,
13155 provider = %provider,
13156 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13157 session = ?address.session,
13158 "session spawn request accepted",
13159 );
13160 let mut prepared_spawn = SpawnPreparer::prepare(
13161 self,
13162 SpawnPrepareRequest {
13163 instance_id,
13164 address: &address,
13165 provider: &provider,
13166 mode,
13167 environment_profile: environment_profile.as_ref(),
13168 bundle: bundle.as_ref(),
13169 context: context.as_ref(),
13170 managed_authority: managed_authority.as_ref(),
13171 harness_mcp,
13172 approval_level,
13173 network_allowlist: network_allowlist.as_ref(),
13174 browser_profile_id,
13175 },
13176 )?;
13177 let materialization_id = prepared_spawn.materialization_id.take();
13178 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13179 let record_id = self.bind_spawn_session_with_materialization(
13180 &address,
13181 provider.clone(),
13182 mode,
13183 runtime_policy,
13184 record_policy,
13185 environment_profile,
13186 bundle,
13187 context,
13188 materialization_id,
13189 )?;
13190 prepared_spawn.retain_session_guards();
13191 let session_provider = provider.clone();
13195 let agent_id = provider;
13196 let register_command_id = match self.dispatch_bounded(
13197 ControlCommand::Register {
13198 instance_id,
13199 agent_id,
13200 transport,
13201 },
13202 dispatch_timeout,
13203 )
13204 .await {
13205 Ok(command_id) => command_id,
13206 Err(error) => {
13207 if let Some(binding) = self.remove_binding(&address) {
13208 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13209 }
13210 if let Some(record_id) = record_id.as_ref() {
13211 self.discard_record(record_id)?;
13212 }
13213 return Err(spawn_dispatch_error(error, deadline));
13214 }
13215 };
13216 let start_timeout = match spawn_dispatch_timeout(deadline) {
13217 Ok(timeout) => timeout,
13218 Err(error) => {
13219 let recovery = self
13220 .rollback_spawn(
13221 &address,
13222 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13223 )
13224 .await;
13225 return match recovery {
13226 Ok(()) => {
13227 if let Some(record_id) = record_id.as_ref() {
13228 self.discard_record(record_id)?;
13229 }
13230 Err(error)
13231 }
13232 Err(recovery_error) => {
13233 if let Some(record_id) = record_id.as_ref() {
13234 self.mark_record_error(
13235 record_id,
13236 "spawn deadline elapsed and registration recovery failed",
13237 )?;
13238 }
13239 Err(recovery_error)
13240 }
13241 };
13242 }
13243 };
13244 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13245 if let Err(recovery_error) = self
13246 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13247 .await
13248 {
13249 return Err(recovery_error);
13250 }
13251 if let Some(record_id) = record_id.as_ref() {
13252 self.discard_record(record_id)?;
13253 }
13254 return Err(start_error);
13255 }
13256 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13258 working_directory = dir.to_string_lossy().into_owned();
13259 }
13260 let start_result = self
13261 .dispatch_bounded(
13262 ControlCommand::Start {
13263 instance_id,
13264 runtime_policy,
13265 request: StartRequest {
13266 working_directory,
13267 terminal_size,
13268 initial_prompt,
13269 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13270 approval_level,
13271 },
13272 },
13273 start_timeout,
13274 )
13275 .await;
13276 let start_command_id = match start_result {
13277 Ok(command_id) => command_id,
13278 Err(start_error) => {
13279 let start_error = spawn_dispatch_error(start_error, deadline);
13280 let recovery = self
13281 .rollback_spawn(
13282 &address,
13283 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13284 )
13285 .await;
13286 return match recovery {
13287 Ok(()) => {
13288 if let Some(record_id) = record_id.as_ref() {
13289 self.discard_record(record_id)?;
13290 }
13291 Err(start_error)
13292 }
13293 Err(recovery_error) => {
13294 let diagnostic = format!(
13295 "start failed and registration recovery failed: {}",
13296 recovery_error.message,
13297 );
13298 if let Some(record_id) = record_id.as_ref() {
13299 self.mark_record_error(record_id, &diagnostic)?;
13300 }
13301 Err(failure(recovery_error.code, &diagnostic))
13302 }
13303 };
13304 }
13305 };
13306
13307 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13308 loop {
13309 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13318 .or_else(|| self.take_rejected_command_reason(start_command_id))
13319 {
13320 self.rollback_spawn(
13321 &address,
13322 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13323 ).await.map_err(|error| {
13324 failure(
13325 error.code,
13326 &format!(
13327 "spawn was rejected ({}) and registration recovery failed: {}",
13328 reason.message, error.message
13329 ),
13330 )
13331 })?;
13332 if let Some(record_id) = record_id.as_ref() {
13333 self.discard_record(record_id)?;
13334 }
13335 let code = if reason.unsupported_transport {
13336 NodeFailureCode::UnsupportedTransport
13337 } else {
13338 NodeFailureCode::BackendOperationFailed
13339 };
13340 return Err(failure(code, &reason.message));
13341 }
13342 if let Some(current) = self
13343 .handle
13344 .snapshot()
13345 .sessions
13346 .iter()
13347 .find(|current| current.instance_id == instance_id)
13348 {
13349 if current.generation != session.generation {
13350 let divergence = failure(
13351 NodeFailureCode::StaleGeneration,
13352 "spawn generation diverged",
13353 );
13354 return match self
13355 .rollback_spawn(
13356 &address,
13357 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13358 )
13359 .await
13360 {
13361 Ok(()) => {
13362 if let Some(record_id) = record_id.as_ref() {
13363 self.discard_record(record_id)?;
13364 }
13365 Err(divergence)
13366 }
13367 Err(recovery_error) => {
13368 if let Some(record_id) = record_id.as_ref() {
13369 self.mark_record_error(
13370 record_id,
13371 "spawn generation diverged and recovery failed",
13372 )?;
13373 }
13374 Err(recovery_error)
13375 }
13376 };
13377 }
13378 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13379 prepared_spawn.retain_launch_guards();
13380 return Ok((address, runtime_policy));
13381 }
13382 }
13383 if Instant::now() >= commit_deadline {
13384 self.rollback_spawn(
13385 &address,
13386 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13387 ).await.map_err(|error| {
13388 failure(
13389 error.code,
13390 &format!(
13391 "start did not commit and registration recovery failed: {}",
13392 error.message
13393 ),
13394 )
13395 })?;
13396 if let Some(record_id) = record_id.as_ref() {
13397 self.discard_record(record_id)?;
13398 }
13399 return Err(failure(
13400 if deadline.is_some() {
13401 NodeFailureCode::SpawnDeadlineExceeded
13402 } else {
13403 NodeFailureCode::BackendBusy
13404 },
13405 "start did not commit before the bounded deadline; registration was removed",
13406 ));
13407 }
13408 sleep(Duration::from_millis(2)).await;
13409 }
13410 }
13411
13412 fn effective_spawn_runtime_policy(
13413 &self,
13414 runtime_policy: ProviderRuntimePolicy,
13415 ) -> ProviderRuntimePolicy {
13416 #[cfg(feature = "fixture")]
13417 let runtime_policy = if self.fixture_semantic_hook_policy {
13418 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13419 .expect("monitoring Hook fixture policy is internally valid")
13420 } else {
13421 runtime_policy
13422 };
13423 runtime_policy
13424 }
13425
13426 fn verify_harness_mcp_before_start(
13427 &self,
13428 prepared: Option<&PreparedHarnessMcpSpawn>,
13429 ) -> Result<(), NodeFailure> {
13430 prepared
13431 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13432 .transpose()
13433 .map(|_| ())
13434 .map_err(harness_mcp_failure)
13435 }
13436
13437 async fn rollback_spawn(
13438 &self,
13439 address: &SessionAddress,
13440 commit_timeout: Duration,
13441 ) -> Result<(), NodeFailure> {
13442 self.dispatch_bounded(
13443 ControlCommand::Remove {
13444 instance_id: address.session.instance_id,
13445 },
13446 commit_timeout,
13447 )
13448 .await?;
13449 self.wait_until_removed(address, commit_timeout).await?;
13450 if let Some(binding) = self.remove_binding(address) {
13451 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13452 }
13453 Ok(())
13454 }
13455
13456 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13457 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13458 self.dispatch_bounded(
13459 ControlCommand::Remove {
13460 instance_id: address.session.instance_id,
13461 },
13462 commit_timeout,
13463 )
13464 .await?;
13465 self.wait_until_removed(address, commit_timeout).await?;
13466 let removed = self.remove_binding(address);
13467 if let Some(binding) = removed.as_ref() {
13468 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13469 }
13470 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13471 let snapshot = self.managed_worktrees.lock()
13472 .unwrap_or_else(|poisoned| poisoned.into_inner())
13473 .release_session(
13474 &lease_id,
13475 self.incarnation_id,
13476 address.session.instance_id,
13477 address.session.generation,
13478 unix_time_ms(),
13479 );
13480 if self.persist_state().is_err() {
13481 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13482 }
13483 if let Some(snapshot) = snapshot {
13484 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13485 }
13486 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13487 }
13488 Ok(())
13489 }
13490
13491 async fn wait_until_removed(
13537 &self,
13538 address: &SessionAddress,
13539 commit_timeout: Duration,
13540 ) -> Result<(), NodeFailure> {
13541 let deadline = Instant::now() + commit_timeout;
13542 let mut next_dispatch = Instant::now();
13543 loop {
13544 let snapshot = self.handle.snapshot();
13545 let status = snapshot
13546 .sessions
13547 .iter()
13548 .find(|session| session.instance_id == address.session.instance_id)
13549 .map(|session| session.status.clone());
13550 let Some(status) = status else {
13551 return Ok(());
13552 };
13553 let now = Instant::now();
13554 if now >= deadline {
13555 return Err(failure(
13556 NodeFailureCode::BackendBusy,
13557 "remove did not commit before the bounded deadline; session binding was retained",
13558 ));
13559 }
13560 if status.allows_remove() && now >= next_dispatch {
13561 let _ = self.dispatch(ControlCommand::Remove {
13562 instance_id: address.session.instance_id,
13563 });
13564 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13565 }
13566 sleep(Duration::from_millis(2)).await;
13567 }
13568 }
13569
13570 fn resync(&self, after_sequence: u64) -> NodeResponse {
13571 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13572 let event_sequence = history.last_sequence;
13573 let oldest_available_sequence = history.replay_floor_sequence;
13574 let events = history
13575 .events
13576 .iter()
13577 .filter(|event| event.sequence > after_sequence)
13578 .cloned()
13579 .collect();
13580 drop(history);
13581 NodeResponse::Resync {
13582 event_sequence,
13583 oldest_available_sequence,
13584 snapshot: self.snapshot(),
13585 events,
13586 }
13587 }
13588}
13589
13590fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13595 match kind {
13596 AgentStreamChunkKindV1::Text { .. } => "text",
13597 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13598 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13599 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13600 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13601 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13602 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13603 }
13604}
13605
13606async fn accept_connections(
13620 endpoint: &str,
13621 call_home: Option<std::net::SocketAddr>,
13622 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13623 shared: Arc<NodeShared>,
13624) -> Result<(), NodeServerError> {
13625 let mut connections = JoinSet::new();
13626 let result = match call_home {
13627 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13628 Some(relay) => {
13629 let listener =
13630 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13631 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13632 tokio::pin!(listener);
13633 tokio::pin!(dialler);
13634 tokio::select! {
13635 result = &mut listener => result,
13636 result = &mut dialler => result,
13637 }
13638 }
13639 };
13640 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13641 while !connections.is_empty() {
13642 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13643 if remaining.is_zero()
13644 || timeout(remaining, connections.join_next()).await.is_err()
13645 {
13646 break;
13647 }
13648 }
13649 connections.abort_all();
13650 while connections.join_next().await.is_some() {}
13651 result
13652}
13653
13654const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13658
13659async fn call_home_loop(
13673 relay: std::net::SocketAddr,
13674 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13675 shared: Arc<NodeShared>,
13676) -> Result<(), NodeServerError> {
13677 let mut failures = 0_usize;
13678 loop {
13679 if shared.shutdown.load(Ordering::Acquire) {
13680 return Ok(());
13681 }
13682 let permit = tokio::select! {
13683 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13684 permit.map_err(|_| NodeServerError::PreauthClosed)?
13685 }
13686 _ = shared.shutdown_notify.notified() => return Ok(()),
13687 };
13688 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13689 Ok(()) => {
13690 failures = 0;
13694 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13695 }
13696 Err(error) => {
13697 failures = failures.saturating_add(1);
13698 tracing::warn!(
13699 node_id = %shared.node_id,
13700 %relay,
13701 cause = %error,
13702 attempt = failures,
13703 "call-home connection failed",
13704 );
13705 let backoff = call_home_backoff(failures);
13706 tokio::select! {
13707 _ = sleep(backoff) => {}
13708 _ = shared.shutdown_notify.notified() => return Ok(()),
13709 }
13710 }
13711 }
13712 }
13713}
13714
13715const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13717const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13719
13720fn call_home_keepalive() -> socket2::TcpKeepalive {
13721 let keepalive = socket2::TcpKeepalive::new()
13722 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13723 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13724 #[cfg(not(windows))]
13725 let keepalive = keepalive.with_retries(3);
13726 keepalive
13727}
13728
13729fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13730 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13731}
13732
13733pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13735 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13736}
13737
13738fn call_home_backoff(attempt: usize) -> Duration {
13740 Duration::from_millis(
13741 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13742 )
13743}
13744
13745
13746async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13749 #[cfg(feature = "wireguard")]
13750 {
13751 let std_stream = tokio::task::spawn_blocking(move || {
13752 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13753 })
13754 .await
13755 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13756 .map_err(NodeServerError::CallHomeTransport)?;
13757 std_stream
13758 .set_nonblocking(true)
13759 .map_err(NodeServerError::CallHomeTransport)?;
13760 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13761 }
13762 #[cfg(not(feature = "wireguard"))]
13763 {
13764 tokio::net::TcpStream::connect(relay)
13765 .await
13766 .map_err(NodeServerError::CallHomeTransport)
13767 }
13768}
13769
13770async fn dial_relay_and_serve(
13772 relay: std::net::SocketAddr,
13773 identity: Option<&hatchery_enroll::NodeIdentity>,
13774 shared: Arc<NodeShared>,
13775 preauth_permit: OwnedSemaphorePermit,
13776) -> Result<(), NodeServerError> {
13777 let mut stream = dial_call_home(relay).await?;
13778 let _ = stream.set_nodelay(true);
13782 if let Err(error) = enable_call_home_keepalive(&stream) {
13788 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13789 }
13790 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13791 .await
13792 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13793 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13794 let mut session_token = None;
13795 if let Some(identity) = identity {
13796 let mut magic = [0_u8; 4];
13800 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13801 .await
13802 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13803 .map_err(NodeServerError::CallHomeTransport)?;
13804 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13805 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13806 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13807 .await
13808 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13809 .map_err(NodeServerError::CallHomeTransport)?;
13810 let (answer, token) = identity
13811 .answer_challenge(shared.node_id.as_str(), &frame)
13812 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13813 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13814 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13815 session_token = Some(token);
13816 }
13817 }
13818 serve_connection(stream, shared, session_token, preauth_permit).await
13819}
13820
13821async fn accept_connections_inner(
13822 endpoint: &str,
13823 shared: Arc<NodeShared>,
13824 connections: &mut JoinSet<Result<(), NodeServerError>>,
13825) -> Result<(), NodeServerError> {
13826 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13827 loop {
13828 while connections.try_join_next().is_some() {}
13829 if shared.shutdown.load(Ordering::Acquire) {
13830 return Ok(());
13831 }
13832 let preauth_permit = tokio::select! {
13833 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13834 permit.map_err(|_| NodeServerError::PreauthClosed)?
13835 }
13836 _ = shared.shutdown_notify.notified() => return Ok(()),
13837 };
13838 let server = tokio::select! {
13839 result = listener.accept() => result?,
13840 _ = shared.shutdown_notify.notified() => return Ok(()),
13841 };
13842 let connection_shared = Arc::clone(&shared);
13843 connections.spawn(async move {
13844 serve_connection(server, connection_shared, None, preauth_permit).await
13845 });
13846 if shared.shutdown.load(Ordering::Acquire) {
13847 return Ok(());
13848 }
13849 }
13850}
13851
13852async fn serve_connection<S>(
13860 mut pipe: S,
13861 shared: Arc<NodeShared>,
13862 session_token: Option<String>,
13863 preauth_permit: OwnedSemaphorePermit,
13864) -> Result<(), NodeServerError>
13865where
13866 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13867{
13868 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13870 let hello_frame = timeout(
13871 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13872 read_json_frame_limited_body_timeout(
13873 &mut pipe,
13874 MAX_NODE_HELLO_FRAME_BYTES,
13875 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13876 ),
13877 )
13878 .await
13879 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13880 let ClientFrame::Hello(hello) = hello_frame else {
13881 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13882 };
13883 if hello.build_stamp != BUILD_STAMP {
13884 return Err(NodeServerError::Handshake(format!(
13885 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13886 hello.build_stamp,
13887 )));
13888 }
13889 let compatibility = match hello.compatibility.as_ref() {
13890 Some(offer) => {
13891 let mut selected = node_compatibility_support(&shared)?
13892 .negotiate(offer)
13893 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13894 project_negotiated_provider_ids(&mut selected);
13895 Some(selected)
13896 }
13897 None => None,
13898 };
13899 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13900 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13901 (Some(offer), Some(selected)) => negotiated_auth_proof(
13902 auth_token,
13903 AuthDirection::Server,
13904 hello.role,
13905 &hello.client_nonce,
13906 &server_nonce,
13907 offer,
13908 selected,
13909 ),
13910 (None, None) => auth_proof(
13911 auth_token,
13912 AuthDirection::Server,
13913 hello.role,
13914 &hello.client_nonce,
13915 &server_nonce,
13916 ),
13917 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13918 }
13919 .map_err(NodeServerError::Authentication)?;
13920 write_json_frame_limited(
13921 &mut pipe,
13922 &ServerFrame::Challenge(ServerChallenge {
13923 build_stamp: BUILD_STAMP.to_owned(),
13924 server_nonce,
13925 server_proof,
13926 compatibility: compatibility.clone(),
13927 }),
13928 MAX_NODE_HELLO_FRAME_BYTES,
13929 )
13930 .await?;
13931 let authentication = timeout(
13932 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13933 read_json_frame_limited_body_timeout(
13934 &mut pipe,
13935 MAX_NODE_HELLO_FRAME_BYTES,
13936 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13937 ),
13938 )
13939 .await
13940 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13941 let ClientFrame::Authenticate(authentication) = authentication else {
13942 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13943 };
13944 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13945 (Some(offer), Some(selected)) => negotiated_auth_proof(
13946 auth_token,
13947 AuthDirection::Client,
13948 hello.role,
13949 &hello.client_nonce,
13950 &server_nonce,
13951 offer,
13952 selected,
13953 ),
13954 (None, None) => auth_proof(
13955 auth_token,
13956 AuthDirection::Client,
13957 hello.role,
13958 &hello.client_nonce,
13959 &server_nonce,
13960 ),
13961 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13962 }
13963 .map_err(NodeServerError::Authentication)?;
13964 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13965 return Err(NodeServerError::Handshake("access denied".to_owned()));
13966 }
13967 let selected_capabilities = compatibility
13968 .as_ref()
13969 .map(|selected| selected.capabilities.clone())
13970 .unwrap_or_default();
13971 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13972 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13973 });
13974 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13975 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13976 });
13977 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13978 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13979 });
13980 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13981 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13982 });
13983 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13984 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13985 });
13986 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13987 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13988 }) && selected_capabilities.iter().any(|capability| {
13989 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13990 });
13991 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13992 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13993 });
13994 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13995 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13996 });
13997 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13998 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13999 });
14000 let include_agent_progress = selected_capabilities.iter().any(|capability| {
14001 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14002 });
14003 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14004 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14005 });
14006 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14007 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14008 });
14009 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14010 .try_acquire_owned()
14011 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14012 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14013 let _release_guard = ControllerReleaseGuard {
14014 shared: Arc::clone(&shared),
14015 connection_id,
14016 };
14017 drop(preauth_permit);
14018 let _authenticated_permit = authenticated_permit;
14019 let mut event_rx = shared.event_tx.subscribe();
14020 let mut terminal_event_rx = terminal_event_subscription(
14021 &shared,
14022 include_terminal_frame_events,
14023 );
14024 let mut agent_stream_event_rx = agent_stream_event_subscription(
14025 &shared,
14026 include_agent_stream_events,
14027 );
14028 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14029 &shared,
14030 include_harness_mcp_proxy,
14031 );
14032 write_json_frame(
14033 &mut pipe,
14034 &ServerFrame::Hello(NodeHello {
14035 build_stamp: BUILD_STAMP.to_owned(),
14036 incarnation_id: shared.incarnation_id,
14037 connection_id,
14038 role: hello.role,
14039 event_sequence: shared.current_sequence(),
14040 controller: shared.controller_state(),
14041 snapshot: snapshot_for_wire(
14042 &shared,
14043 include_provider_runtime_status,
14044 include_open_provider_ids,
14045 include_spawn_profiles,
14046 include_managed_worktrees,
14047 include_child_environment_profiles,
14048 include_session_bundles,
14049 include_history_context_packs,
14050 include_agent_progress,
14051 include_session_task_correlation,
14052 ),
14053 compatibility,
14054 }),
14055 )
14056 .await?;
14057 let (mut reader, mut writer) = tokio::io::split(pipe);
14058 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14059 let reader_task = tokio::spawn(async move {
14060 loop {
14061 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14062 &mut reader,
14063 MAX_NODE_CLIENT_FRAME_BYTES,
14064 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14065 )
14066 .await;
14067 let terminal = frame.is_err();
14068 if frame_tx.send(frame).await.is_err() || terminal {
14069 break;
14070 }
14071 }
14072 });
14073 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14074 let mut pending_events = Vec::new();
14075 let mut resync_required = false;
14076 let mut discard_events_through = 0_u64;
14077 loop {
14078 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14083 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14084 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14085 for _ in 0..durable_drain_budget {
14086 match event_rx.try_recv() {
14087 Ok(event) => queue_connection_event(
14088 &mut pending_events,
14089 event,
14090 discard_events_through,
14091 ),
14092 Err(broadcast::error::TryRecvError::Empty) => break,
14093 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14094 resync_required = true;
14095 break;
14096 }
14097 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14098 }
14099 }
14100 if let Some(receiver) = terminal_event_rx.as_mut() {
14101 match receiver.try_recv() {
14102 Ok(events) => queue_connection_event_batch(
14103 &mut pending_events,
14104 &events,
14105 discard_events_through,
14106 ),
14107 Err(broadcast::error::TryRecvError::Empty) => {}
14108 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14109 resync_required = true;
14110 }
14111 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14112 }
14113 }
14114 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14115 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14125 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14126 for _ in 0..agent_stream_drain_budget {
14127 match receiver.try_recv() {
14128 Ok(events) => queue_connection_event_batch(
14129 &mut pending_events,
14130 &events,
14131 discard_events_through,
14132 ),
14133 Err(broadcast::error::TryRecvError::Empty) => break,
14134 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14135 tracing::warn!(
14136 connection_id,
14137 dropped,
14138 "agent stream event subscription lagged; forcing a resync",
14139 );
14140 resync_required = true;
14141 break;
14142 }
14143 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14144 }
14145 }
14146 }
14147 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14148 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14154 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14155 for _ in 0..harness_mcp_proxy_drain_budget {
14156 match receiver.try_recv() {
14157 Ok(events) => queue_connection_event_batch(
14158 &mut pending_events,
14159 &events,
14160 discard_events_through,
14161 ),
14162 Err(broadcast::error::TryRecvError::Empty) => break,
14163 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14164 tracing::warn!(
14165 connection_id,
14166 dropped,
14167 "harness MCP proxy event subscription lagged; forcing a resync",
14168 );
14169 resync_required = true;
14170 break;
14171 }
14172 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14173 }
14174 }
14175 }
14176 if resync_required {
14177 let event = resync_required_event(&shared);
14178 apply_connection_resync_watermark(
14179 &mut pending_events,
14180 &mut discard_events_through,
14181 event.sequence,
14182 );
14183 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14184 resync_required = false;
14185 continue;
14186 }
14187 if !pending_events.is_empty() {
14188 pending_events.sort_unstable_by_key(|event| event.sequence);
14189 let burst_len = connection_event_burst_len(pending_events.len());
14190 shared
14191 .connection_events_sent
14192 .fetch_add(burst_len as u64, Ordering::Relaxed);
14193 for event in pending_events.drain(..burst_len) {
14194 let event = if include_managed_worktrees {
14195 Some(event)
14196 } else {
14197 project_event_without_managed_worktrees(event)
14198 };
14199 let event = event.and_then(|event| if include_open_provider_ids {
14200 Some(event)
14201 } else {
14202 project_event_legacy_provider_ids(&shared, event)
14203 });
14204 let event = event.map(|event| if include_child_environment_profiles {
14205 event
14206 } else {
14207 project_event_without_child_environment_profile(event)
14208 });
14209 let event = event.map(|event| if include_session_bundles {
14210 event
14211 } else {
14212 project_event_without_session_bundle(event)
14213 });
14214 let event = event.map(|event| {
14215 project_event_history_for_wire(event, include_history_context_packs)
14216 });
14217 let event = event.map(|event| if include_history_context_packs {
14218 event
14219 } else {
14220 project_event_without_context_pack(event)
14221 });
14222 let event = event.map(|event| if include_session_task_correlation {
14223 event
14224 } else {
14225 project_event_without_session_task_binding(event)
14226 });
14227 let exact_harness_controller = hello.role == ClientRole::Operator
14228 && shared.controller_state().is_some_and(|controller| {
14229 controller.connection_id == connection_id
14230 });
14231 let event = event.and_then(|event| {
14232 if include_harness_mcp_proxy && exact_harness_controller {
14233 Some(event)
14234 } else {
14235 project_event_without_harness_mcp_proxy(event)
14236 }
14237 });
14238 if let Some(event) = event {
14239 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14240 }
14241 }
14242 }
14243 tokio::select! {
14244 biased;
14245 frame = frame_rx.recv() => {
14246 let frame = match frame {
14247 Some(frame) => frame,
14248 None => break,
14249 };
14250 let frame = match frame {
14251 Ok(frame) => frame,
14252 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14253 Err(error) => return Err(error.into()),
14254 };
14255 let ClientFrame::Request(request) = frame else {
14256 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14257 };
14258 #[cfg(feature = "fixture")]
14259 let probe_spawn_managed_worktree_v2 = matches!(
14260 &request.request,
14261 NodeRequest::SpawnManagedWorktreeV2 { .. }
14262 );
14263 let requires_open_provider_ids =
14264 request_requires_open_provider_ids(&shared, &request.request);
14265 let requires_child_environment_profile =
14266 request_requires_child_environment_profile(&shared, &request.request);
14267 let requires_session_bundle =
14268 request_requires_session_bundle(&shared, &request.request);
14269 let requires_history_context_pack =
14270 request_requires_history_context_pack(&shared, &request.request);
14271 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14272 ResponseEnvelope {
14273 request_id: request.request_id,
14274 result: Err(failure(
14275 NodeFailureCode::UnsupportedCapability,
14276 "open provider IDs were not negotiated",
14277 )),
14278 }
14279 } else if requires_child_environment_profile
14280 && !include_child_environment_profiles
14281 {
14282 ResponseEnvelope {
14283 request_id: request.request_id,
14284 result: Err(failure(
14285 NodeFailureCode::UnsupportedCapability,
14286 "child environment profiles were not negotiated",
14287 )),
14288 }
14289 } else if requires_session_bundle && !include_session_bundles {
14290 ResponseEnvelope {
14291 request_id: request.request_id,
14292 result: Err(failure(
14293 NodeFailureCode::UnsupportedCapability,
14294 "session bundles were not negotiated",
14295 )),
14296 }
14297 } else if requires_history_context_pack && !include_history_context_packs {
14298 ResponseEnvelope {
14299 request_id: request.request_id,
14300 result: Err(failure(
14301 NodeFailureCode::UnsupportedCapability,
14302 "history context packs were not negotiated",
14303 )),
14304 }
14305 } else if request_uses_unnegotiated_capability(
14306 &request.request,
14307 &selected_capabilities,
14308 )
14309 {
14310 ResponseEnvelope {
14311 request_id: request.request_id,
14312 result: Err(failure(
14313 NodeFailureCode::UnsupportedCapability,
14314 "request capability was not negotiated",
14315 )),
14316 }
14317 } else {
14318 process_request(&shared, connection_id, hello.role, request).await
14319 };
14320 #[cfg(feature = "fixture")]
14321 if probe_spawn_managed_worktree_v2 {
14322 shared
14323 .fixture_spawn_managed_worktree_v2_failure_probe
14324 .record(reply.result.as_ref().err().map(|failure| failure.code));
14325 }
14326 if !include_provider_runtime_status {
14327 clear_response_provider_runtime_status(&mut reply);
14328 }
14329 if !include_managed_worktrees {
14330 project_response_without_managed_worktrees(&mut reply);
14331 }
14332 if !include_open_provider_ids {
14333 project_response_legacy_provider_ids(&shared, &mut reply);
14334 }
14335 if !include_child_environment_profiles {
14336 project_response_without_child_environment_profile(&mut reply);
14337 }
14338 if !include_session_bundles {
14339 project_response_without_session_bundle(&mut reply);
14340 }
14341 project_response_history_for_wire(
14342 &mut reply,
14343 include_history_context_packs,
14344 );
14345 if !include_history_context_packs {
14346 project_response_without_context_pack(&mut reply);
14347 }
14348 if !include_agent_progress {
14349 project_response_without_agent_progress(&mut reply);
14350 }
14351 if !include_session_task_correlation {
14352 project_response_without_session_task_binding(&mut reply);
14353 }
14354 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14355 }
14356 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14357 event = event_rx.recv() => {
14358 match event {
14359 Ok(event) => queue_connection_event(
14360 &mut pending_events,
14361 event,
14362 discard_events_through,
14363 ),
14364 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14365 Err(broadcast::error::RecvError::Closed) => break,
14366 }
14367 }
14368 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14369 match events {
14370 Ok(events) => queue_connection_event_batch(
14371 &mut pending_events,
14372 &events,
14373 discard_events_through,
14374 ),
14375 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14376 Err(broadcast::error::RecvError::Closed) => break,
14377 }
14378 }
14379 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14380 match events {
14381 Ok(events) => queue_connection_event_batch(
14382 &mut pending_events,
14383 &events,
14384 discard_events_through,
14385 ),
14386 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14387 Err(broadcast::error::RecvError::Closed) => break,
14388 }
14389 }
14390 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14391 match events {
14392 Ok(events) => queue_connection_event_batch(
14393 &mut pending_events,
14394 &events,
14395 discard_events_through,
14396 ),
14397 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14398 Err(broadcast::error::RecvError::Closed) => break,
14399 }
14400 }
14401 _ = shared.shutdown_notify.notified() => break,
14402 }
14403 }
14404 Ok(())
14405}
14406
14407async fn receive_terminal_event_batch(
14408 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14409) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14410 match receiver {
14411 Some(receiver) => receiver.recv().await,
14412 None => std::future::pending().await,
14413 }
14414}
14415
14416fn terminal_event_subscription(
14417 shared: &NodeShared,
14418 enabled: bool,
14419) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14420 enabled.then(|| shared.terminal_event_tx.subscribe())
14421}
14422
14423fn agent_stream_event_subscription(
14427 shared: &NodeShared,
14428 enabled: bool,
14429) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14430 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14431}
14432
14433fn harness_mcp_proxy_event_subscription(
14438 shared: &NodeShared,
14439 enabled: bool,
14440) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14441 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14442}
14443
14444fn queue_connection_event(
14445 pending: &mut Vec<NodeEventEnvelope>,
14446 event: NodeEventEnvelope,
14447 discard_through: u64,
14448) {
14449 if event.sequence <= discard_through
14450 && !event.event.requires_harness_mcp_proxy_capability()
14451 {
14452 return;
14453 }
14454 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14455 if let Some(index) = pending.iter().position(|current| {
14456 matches!(
14457 ¤t.event,
14458 NodeEvent::TerminalFrame {
14459 address: current_address,
14460 ..
14461 } if current_address == address
14462 )
14463 }) {
14464 pending.remove(index);
14465 }
14466 }
14467 pending.push(event);
14468}
14469
14470fn project_event_without_harness_mcp_proxy(
14471 envelope: NodeEventEnvelope,
14472) -> Option<NodeEventEnvelope> {
14473 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14474}
14475
14476fn queue_connection_event_batch(
14477 pending: &mut Vec<NodeEventEnvelope>,
14478 events: &[NodeEventEnvelope],
14479 discard_through: u64,
14480) {
14481 for event in events {
14482 queue_connection_event(pending, event.clone(), discard_through);
14483 }
14484}
14485
14486fn apply_connection_resync_watermark(
14487 pending: &mut Vec<NodeEventEnvelope>,
14488 discard_through: &mut u64,
14489 marker_sequence: u64,
14490) {
14491 *discard_through = (*discard_through).max(marker_sequence);
14492 pending.retain(|event| event.sequence > *discard_through);
14493}
14494
14495fn connection_event_burst_len(pending_len: usize) -> usize {
14496 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14497}
14498
14499fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14500 let history = shared
14501 .history
14502 .lock()
14503 .unwrap_or_else(|poisoned| poisoned.into_inner());
14504 let sequence = history.last_sequence;
14505 let oldest_available_sequence = history.replay_floor_sequence;
14506 NodeEventEnvelope {
14507 sequence,
14508 event: NodeEvent::ResyncRequired {
14509 oldest_available_sequence,
14510 },
14511 }
14512}
14513
14514fn node_compatibility_support(
14515 shared: &NodeShared,
14516) -> Result<NodeCompatibilitySupport, NodeServerError> {
14517 let mut support = node_compatibility_support_for_manifest(
14518 &shared.provider_contracts,
14519 &shared.provider_adapter_contracts,
14520 )?;
14521 if shared
14522 .delivery_store
14523 .lock()
14524 .unwrap_or_else(|poisoned| poisoned.into_inner())
14525 .is_some()
14526 {
14527 support.capabilities.push(
14528 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14529 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14530 );
14531 }
14532 if shared.harness_mcp_registry.is_some() {
14533 support.capabilities.push(
14534 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14535 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14536 );
14537 }
14538 Ok(support)
14539}
14540
14541fn node_compatibility_support_for_manifest(
14542 provider_contracts: &[ProviderContractSupport],
14543 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14544) -> Result<NodeCompatibilitySupport, NodeServerError> {
14545 Ok(NodeCompatibilitySupport {
14546 build_stamp: BUILD_STAMP.to_owned(),
14547 capabilities: baseline_capabilities()?,
14548 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14549 path_semantics: platform::path_semantics(),
14550 local_transport: platform::local_transport(),
14551 state_schema: StateSchemaSupport {
14552 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14553 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14554 },
14555 provider_contracts: provider_contracts.to_vec(),
14556 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14557 })
14558}
14559
14560fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14561 let capabilities = [
14562 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14563 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14564 NODE_REPOSITORY_PATH_CAPABILITY,
14565 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14566 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14567 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14568 NODE_GIT_READ_CAPABILITY,
14569 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14570 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14571 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14572 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14573 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14574 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14575 NODE_ACP_CONTROL_CAPABILITY,
14576 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14577 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14578 NODE_WORKTREE_SELECTION_CAPABILITY,
14579 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14580 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14581 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14582 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14583 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14584 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14585 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14586 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14587 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14588 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14589 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14590 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14591 ]
14592 .into_iter()
14593 .map(|capability| {
14594 CapabilityId::new(capability)
14595 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14596 })
14597 .collect::<Result<Vec<_>, _>>()?;
14598 #[cfg(windows)]
14599 let capabilities = {
14600 let mut capabilities = capabilities;
14601 capabilities.push(
14602 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14603 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14604 );
14605 capabilities
14606 };
14607 Ok(capabilities)
14608}
14609
14610fn request_uses_unnegotiated_capability(
14611 request: &NodeRequest,
14612 selected_capabilities: &[CapabilityId],
14613) -> bool {
14614 let selected = |required: &str| {
14615 selected_capabilities
14616 .iter()
14617 .any(|capability| capability.as_str() == required)
14618 };
14619 request
14620 .required_capability()
14621 .is_some_and(|required| !selected(required))
14622 || (request.requires_worktree_selection_capability()
14623 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14624 || (request.requires_spawn_spec_defaults_overrides_capability()
14625 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14626 || (request.requires_spawn_profile_revision_capability()
14627 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14628 || (request.requires_child_environment_profile_capability()
14629 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14630 || (request.requires_session_bundle_materialization_capability()
14631 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14632 || (request.requires_history_context_pack_capability()
14633 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14634}
14635
14636fn snapshot_for_wire(
14637 shared: &NodeShared,
14638 include_provider_runtime_status: bool,
14639 include_open_provider_ids: bool,
14640 include_spawn_profiles: bool,
14641 include_managed_worktrees: bool,
14642 include_child_environment_profiles: bool,
14643 include_session_bundles: bool,
14644 include_history_context_packs: bool,
14645 include_agent_progress: bool,
14646 include_session_task_correlation: bool,
14647) -> NodeSnapshot {
14648 let mut snapshot = shared.snapshot();
14649 if !include_provider_runtime_status {
14650 snapshot.provider_runtime_statuses.clear();
14651 }
14652 if !include_open_provider_ids {
14653 project_snapshot_legacy_provider_ids(&mut snapshot);
14654 }
14655 if !include_managed_worktrees {
14656 snapshot.managed_worktrees.clear();
14657 for workspace in &mut snapshot.workspaces {
14658 workspace.managed_worktree_profiles = None;
14659 }
14660 }
14661 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14662 if !include_spawn_profiles {
14663 inventory.spawn_profiles = None;
14664 }
14665 if !include_session_bundles {
14666 inventory.bundles = None;
14667 }
14668 if inventory.spawn_profiles.is_none()
14669 && inventory.bundles.is_none()
14670 && inventory.network_allowlists.is_none()
14671 {
14672 snapshot.launch_inventory = None;
14673 }
14674 }
14675 if !include_child_environment_profiles {
14676 clear_snapshot_child_environment_profiles(&mut snapshot);
14677 }
14678 if !include_session_bundles {
14679 clear_snapshot_session_bundles(&mut snapshot);
14680 }
14681 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14682 if !include_history_context_packs {
14683 clear_snapshot_context_packs(&mut snapshot);
14684 }
14685 if !include_agent_progress {
14686 snapshot.agent_progress.clear();
14687 }
14688 if !include_session_task_correlation {
14689 clear_snapshot_session_task_bindings(&mut snapshot);
14690 }
14691 snapshot
14692}
14693
14694fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14695 for record in &mut snapshot.session_records {
14696 record.task_binding = None;
14697 }
14698}
14699
14700fn project_event_without_session_task_binding(
14701 mut envelope: NodeEventEnvelope,
14702) -> NodeEventEnvelope {
14703 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14704 record.task_binding = None;
14705 }
14706 envelope
14707}
14708
14709fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14710 let Ok(response) = reply.result.as_mut() else { return };
14711 match response {
14712 NodeResponse::Snapshot { snapshot, .. } => {
14713 clear_snapshot_session_task_bindings(snapshot);
14714 }
14715 NodeResponse::Resync { snapshot, events, .. } => {
14716 clear_snapshot_session_task_bindings(snapshot);
14717 for event in events {
14718 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14719 record.task_binding = None;
14720 }
14721 }
14722 }
14723 NodeResponse::SessionRecordUpdated { record }
14724 | NodeResponse::ProviderSessionIndexed { record }
14725 | NodeResponse::NativeSessionIndexed { record, .. }
14726 | NodeResponse::SessionRecordResumed { record, .. } => {
14727 record.task_binding = None;
14728 }
14729 _ => {}
14730 }
14731}
14732
14733fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14734 let Ok(response) = reply.result.as_mut() else { return };
14735 match response {
14736 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14737 snapshot.agent_progress.clear();
14738 }
14739 _ => {}
14740 }
14741}
14742
14743fn project_event_without_managed_worktrees(
14744 mut envelope: NodeEventEnvelope,
14745) -> Option<NodeEventEnvelope> {
14746 if matches!(
14747 envelope.event,
14748 NodeEvent::ManagedWorktreeUpserted { .. }
14749 | NodeEvent::ManagedWorktreeRemoved { .. }
14750 ) {
14751 return None;
14752 }
14753 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14754 workspace.managed_worktree_profiles = None;
14755 }
14756 Some(envelope)
14757}
14758
14759fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14760 if matches!(
14761 reply.result,
14762 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14763 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14764 ) {
14765 reply.result = Err(failure(
14766 NodeFailureCode::UnsupportedCapability,
14767 "managed worktree capability was not negotiated",
14768 ));
14769 return;
14770 }
14771 let Ok(response) = reply.result.as_mut() else { return };
14772 match response {
14773 NodeResponse::Snapshot { snapshot, .. } => {
14774 snapshot.managed_worktrees.clear();
14775 for workspace in &mut snapshot.workspaces {
14776 workspace.managed_worktree_profiles = None;
14777 }
14778 }
14779 NodeResponse::Resync { snapshot, events, .. } => {
14780 snapshot.managed_worktrees.clear();
14781 for workspace in &mut snapshot.workspaces {
14782 workspace.managed_worktree_profiles = None;
14783 }
14784 *events = std::mem::take(events)
14785 .into_iter()
14786 .filter_map(project_event_without_managed_worktrees)
14787 .collect();
14788 }
14789 NodeResponse::WorkspaceRegistered { workspace }
14790 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14791 | NodeResponse::WorktreeCreated { workspace, .. } => {
14792 workspace.managed_worktree_profiles = None;
14793 }
14794 NodeResponse::WorkspaceInspected { inspection } => {
14795 inspection.git.managed_worktree = None;
14796 }
14797 _ => {}
14798 }
14799}
14800
14801fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14802 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14803 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14804 });
14805 if includes_open_provider_ids {
14806 return;
14807 }
14808 compatibility
14809 .provider_contracts
14810 .retain(|contract| provider_id_is_legacy(&contract.provider));
14811 compatibility
14812 .provider_adapter_contracts
14813 .retain(|contract| provider_id_is_legacy(&contract.provider));
14814}
14815
14816fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14817 if let Some(spec) = match request {
14818 NodeRequest::SpawnSpec { spec }
14819 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14820 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14821 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14822 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14823 _ => None,
14824 } {
14825 return shared
14826 .resolve_spawn_spec(spec)
14827 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14828 }
14829 request_requires_open_provider_ids_with(
14830 request,
14831 |session| shared.validate_address(session).ok(),
14832 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14833 )
14834}
14835
14836fn request_requires_child_environment_profile(
14837 shared: &NodeShared,
14838 request: &NodeRequest,
14839) -> bool {
14840 if let Some(spec) = match request {
14841 NodeRequest::SpawnSpec { spec }
14842 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14843 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14844 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14845 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14846 _ => None,
14847 } {
14848 return shared
14849 .resolve_spawn_spec(spec)
14850 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14851 }
14852 match request {
14853 NodeRequest::Resume { session, .. }
14854 | NodeRequest::Prompt { session, .. }
14855 | NodeRequest::Paste { session, .. }
14856 | NodeRequest::Input { session, .. }
14857 | NodeRequest::TerminalBytes { session, .. }
14858 | NodeRequest::TerminalControl { session, .. }
14859 | NodeRequest::Resize { session, .. }
14860 | NodeRequest::Interrupt { session }
14861 | NodeRequest::Stop { session, .. }
14862 | NodeRequest::Remove { session }
14863 | NodeRequest::ResolveInteraction { session, .. }
14864 | NodeRequest::SetSessionMode { session, .. }
14865 | NodeRequest::SetSessionConfigOption { session, .. }
14866 | NodeRequest::SetSessionModel { session, .. }
14867 | NodeRequest::DiscoverHistory { session, .. }
14868 | NodeRequest::LoadHistory { session, .. }
14869 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14870 | NodeRequest::ExportContextPack { session } => shared
14871 .session_bindings
14872 .lock()
14873 .unwrap_or_else(|poisoned| poisoned.into_inner())
14874 .get(&session.session.instance_id)
14875 .filter(|binding| {
14876 binding.workspace_id == session.workspace_id
14877 && binding.generation == session.session.generation
14878 })
14879 .map_or(true, |binding| binding.environment_profile.is_some()),
14880 NodeRequest::RenameSessionRecord { record_id, .. }
14881 | NodeRequest::SetSessionTask { record_id, .. }
14882 | NodeRequest::ResumeSessionRecord { record_id, .. }
14883 | NodeRequest::ForgetSessionRecord { record_id } => shared
14884 .record(record_id)
14885 .map_or(true, |record| record.environment_profile.is_some()),
14886 NodeRequest::ArmHarnessMcpReservation { .. }
14887 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14888 | NodeRequest::ActivateHarnessMcpReservation { .. }
14889 | NodeRequest::AbortHarnessMcpReservation { .. }
14890 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14891 | NodeRequest::RejectHarnessMcpCall { .. }
14892 | NodeRequest::Snapshot
14893 | NodeRequest::Resync { .. }
14894 | NodeRequest::BeginDeliveryStage { .. }
14895 | NodeRequest::PutDeliveryBlobChunk { .. }
14896 | NodeRequest::CommitDeliveryStage { .. }
14897 | NodeRequest::AbortDeliveryStage { .. }
14898 | NodeRequest::BrowseHostDirectories { .. }
14899 | NodeRequest::InspectWorkspace { .. }
14900 | NodeRequest::ReadWorkspaceFile { .. }
14901 | NodeRequest::WriteWorkspaceFile { .. }
14902 | NodeRequest::CreateWorkspaceFile { .. }
14903 | NodeRequest::CreateWorkspaceDirectory { .. }
14904 | NodeRequest::ReadGitHistory { .. }
14905 | NodeRequest::ReadGitDiff { .. }
14906 | NodeRequest::AcquireController { .. }
14907 | NodeRequest::ReleaseController
14908 | NodeRequest::Spawn { .. }
14909 | NodeRequest::SpawnWithOptions { .. }
14910 | NodeRequest::SpawnSpec { .. }
14911 | NodeRequest::RegisterWorkspace { .. }
14912 | NodeRequest::CreateStandaloneWorkspace { .. }
14913 | NodeRequest::UnregisterWorkspace { .. }
14914 | NodeRequest::CreateWorktree { .. }
14915 | NodeRequest::RemoveWorktree { .. }
14916 | NodeRequest::SpawnManagedWorktree { .. }
14917 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14918 | NodeRequest::CleanupManagedWorktree { .. }
14919 | NodeRequest::IndexProviderSession { .. }
14920 | NodeRequest::IndexNativeSession { .. }
14921 | NodeRequest::CatalogNativeSessions { .. }
14922 | NodeRequest::PageNativeSessions { .. }
14923 | NodeRequest::PreviewNativeSession { .. }
14924 | NodeRequest::PreviewSessionRecord { .. }
14925 | NodeRequest::ForgetContextPack { .. }
14926 | NodeRequest::ResolveDurableContextPack { .. }
14927 | NodeRequest::ReadContextPack { .. }
14928 | NodeRequest::Shutdown => false,
14929 }
14930}
14931
14932fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14933 if let Some(spec) = match request {
14934 NodeRequest::SpawnSpec { spec }
14935 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14936 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14937 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14938 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14939 _ => None,
14940 } {
14941 return shared
14942 .resolve_spawn_spec(spec)
14943 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14944 }
14945 match request {
14946 NodeRequest::Resume { session, .. }
14947 | NodeRequest::Prompt { session, .. }
14948 | NodeRequest::Paste { session, .. }
14949 | NodeRequest::Input { session, .. }
14950 | NodeRequest::TerminalBytes { session, .. }
14951 | NodeRequest::TerminalControl { session, .. }
14952 | NodeRequest::Resize { session, .. }
14953 | NodeRequest::Interrupt { session }
14954 | NodeRequest::Stop { session, .. }
14955 | NodeRequest::Remove { session }
14956 | NodeRequest::ResolveInteraction { session, .. }
14957 | NodeRequest::SetSessionMode { session, .. }
14958 | NodeRequest::SetSessionConfigOption { session, .. }
14959 | NodeRequest::SetSessionModel { session, .. }
14960 | NodeRequest::DiscoverHistory { session, .. }
14961 | NodeRequest::LoadHistory { session, .. }
14962 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14963 | NodeRequest::ExportContextPack { session } => shared
14964 .session_bindings
14965 .lock()
14966 .unwrap_or_else(|poisoned| poisoned.into_inner())
14967 .get(&session.session.instance_id)
14968 .filter(|binding| {
14969 binding.workspace_id == session.workspace_id
14970 && binding.generation == session.session.generation
14971 })
14972 .map_or(true, |binding| binding.bundle.is_some()),
14973 NodeRequest::RenameSessionRecord { record_id, .. }
14974 | NodeRequest::SetSessionTask { record_id, .. }
14975 | NodeRequest::ResumeSessionRecord { record_id, .. }
14976 | NodeRequest::ForgetSessionRecord { record_id } => shared
14977 .record(record_id)
14978 .map_or(true, |record| record.bundle.is_some()),
14979 NodeRequest::ArmHarnessMcpReservation { .. }
14980 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14981 | NodeRequest::ActivateHarnessMcpReservation { .. }
14982 | NodeRequest::AbortHarnessMcpReservation { .. }
14983 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14984 | NodeRequest::RejectHarnessMcpCall { .. }
14985 | NodeRequest::Snapshot
14986 | NodeRequest::Resync { .. }
14987 | NodeRequest::BeginDeliveryStage { .. }
14988 | NodeRequest::PutDeliveryBlobChunk { .. }
14989 | NodeRequest::CommitDeliveryStage { .. }
14990 | NodeRequest::AbortDeliveryStage { .. }
14991 | NodeRequest::BrowseHostDirectories { .. }
14992 | NodeRequest::InspectWorkspace { .. }
14993 | NodeRequest::ReadWorkspaceFile { .. }
14994 | NodeRequest::WriteWorkspaceFile { .. }
14995 | NodeRequest::CreateWorkspaceFile { .. }
14996 | NodeRequest::CreateWorkspaceDirectory { .. }
14997 | NodeRequest::ReadGitHistory { .. }
14998 | NodeRequest::ReadGitDiff { .. }
14999 | NodeRequest::AcquireController { .. }
15000 | NodeRequest::ReleaseController
15001 | NodeRequest::Spawn { .. }
15002 | NodeRequest::SpawnWithOptions { .. }
15003 | NodeRequest::SpawnSpec { .. }
15004 | NodeRequest::RegisterWorkspace { .. }
15005 | NodeRequest::CreateStandaloneWorkspace { .. }
15006 | NodeRequest::UnregisterWorkspace { .. }
15007 | NodeRequest::CreateWorktree { .. }
15008 | NodeRequest::RemoveWorktree { .. }
15009 | NodeRequest::SpawnManagedWorktree { .. }
15010 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15011 | NodeRequest::CleanupManagedWorktree { .. }
15012 | NodeRequest::IndexProviderSession { .. }
15013 | NodeRequest::IndexNativeSession { .. }
15014 | NodeRequest::CatalogNativeSessions { .. }
15015 | NodeRequest::PageNativeSessions { .. }
15016 | NodeRequest::PreviewNativeSession { .. }
15017 | NodeRequest::PreviewSessionRecord { .. }
15018 | NodeRequest::ForgetContextPack { .. }
15019 | NodeRequest::ResolveDurableContextPack { .. }
15020 | NodeRequest::ReadContextPack { .. }
15021 | NodeRequest::Shutdown => false,
15022 }
15023}
15024
15025fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15026 if request.requires_history_context_pack_capability() {
15027 return true;
15028 }
15029 if let Some(spec) = match request {
15030 NodeRequest::SpawnSpec { spec }
15031 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15032 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15033 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15034 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15035 _ => None,
15036 } {
15037 return shared
15038 .resolve_spawn_spec(spec)
15039 .map_or(true, |resolved| resolved.context_id.is_some());
15040 }
15041 match request {
15042 NodeRequest::Resume { session, .. }
15043 | NodeRequest::Prompt { session, .. }
15044 | NodeRequest::Paste { session, .. }
15045 | NodeRequest::Input { session, .. }
15046 | NodeRequest::TerminalBytes { session, .. }
15047 | NodeRequest::TerminalControl { session, .. }
15048 | NodeRequest::Resize { session, .. }
15049 | NodeRequest::Interrupt { session }
15050 | NodeRequest::Stop { session, .. }
15051 | NodeRequest::Remove { session }
15052 | NodeRequest::ResolveInteraction { session, .. }
15053 | NodeRequest::SetSessionMode { session, .. }
15054 | NodeRequest::SetSessionConfigOption { session, .. }
15055 | NodeRequest::SetSessionModel { session, .. } => shared
15056 .session_bindings
15057 .lock()
15058 .unwrap_or_else(|poisoned| poisoned.into_inner())
15059 .get(&session.session.instance_id)
15060 .filter(|binding| {
15061 binding.workspace_id == session.workspace_id
15062 && binding.generation == session.session.generation
15063 })
15064 .map_or(true, |binding| binding.context.is_some()),
15065 NodeRequest::RenameSessionRecord { record_id, .. }
15066 | NodeRequest::SetSessionTask { record_id, .. }
15067 | NodeRequest::ResumeSessionRecord { record_id, .. }
15068 | NodeRequest::ForgetSessionRecord { record_id } => shared
15069 .record(record_id)
15070 .map_or(true, |record| record.context.is_some()),
15071 NodeRequest::ArmHarnessMcpReservation { .. }
15072 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15073 | NodeRequest::ActivateHarnessMcpReservation { .. }
15074 | NodeRequest::AbortHarnessMcpReservation { .. }
15075 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15076 | NodeRequest::RejectHarnessMcpCall { .. }
15077 | NodeRequest::Snapshot
15078 | NodeRequest::Resync { .. }
15079 | NodeRequest::BeginDeliveryStage { .. }
15080 | NodeRequest::PutDeliveryBlobChunk { .. }
15081 | NodeRequest::CommitDeliveryStage { .. }
15082 | NodeRequest::AbortDeliveryStage { .. }
15083 | NodeRequest::BrowseHostDirectories { .. }
15084 | NodeRequest::InspectWorkspace { .. }
15085 | NodeRequest::ReadWorkspaceFile { .. }
15086 | NodeRequest::WriteWorkspaceFile { .. }
15087 | NodeRequest::CreateWorkspaceFile { .. }
15088 | NodeRequest::CreateWorkspaceDirectory { .. }
15089 | NodeRequest::ReadGitHistory { .. }
15090 | NodeRequest::ReadGitDiff { .. }
15091 | NodeRequest::AcquireController { .. }
15092 | NodeRequest::ReleaseController
15093 | NodeRequest::Spawn { .. }
15094 | NodeRequest::SpawnWithOptions { .. }
15095 | NodeRequest::SpawnSpec { .. }
15096 | NodeRequest::RegisterWorkspace { .. }
15097 | NodeRequest::CreateStandaloneWorkspace { .. }
15098 | NodeRequest::UnregisterWorkspace { .. }
15099 | NodeRequest::CreateWorktree { .. }
15100 | NodeRequest::RemoveWorktree { .. }
15101 | NodeRequest::SpawnManagedWorktree { .. }
15102 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15103 | NodeRequest::CleanupManagedWorktree { .. }
15104 | NodeRequest::IndexProviderSession { .. }
15105 | NodeRequest::IndexNativeSession { .. }
15106 | NodeRequest::CatalogNativeSessions { .. }
15107 | NodeRequest::PageNativeSessions { .. }
15108 | NodeRequest::PreviewNativeSession { .. }
15109 | NodeRequest::PreviewSessionRecord { .. }
15110 | NodeRequest::DiscoverHistory { .. }
15111 | NodeRequest::LoadHistory { .. }
15112 | NodeRequest::ExportContextPackForSessionRecord { .. }
15113 | NodeRequest::ExportContextPack { .. }
15114 | NodeRequest::ForgetContextPack { .. }
15115 | NodeRequest::ResolveDurableContextPack { .. }
15116 | NodeRequest::ReadContextPack { .. }
15117 | NodeRequest::Shutdown => false,
15118 }
15119}
15120
15121fn request_requires_open_provider_ids_with(
15122 request: &NodeRequest,
15123 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15124 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15125) -> bool {
15126 match request {
15127 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15128 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15129 NodeRequest::CatalogNativeSessions { route, .. }
15130 | NodeRequest::PageNativeSessions { route, .. } => {
15131 !provider_id_is_legacy(&route.provider)
15132 }
15133 NodeRequest::PreviewNativeSession { selection, .. }
15134 | NodeRequest::IndexNativeSession { selection, .. } => {
15135 !provider_id_is_legacy(&selection.route.provider)
15136 }
15137 NodeRequest::SpawnSpec { .. }
15138 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15139 NodeRequest::SpawnManagedWorktree { .. }
15140 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15141 NodeRequest::Resume { session, .. }
15142 | NodeRequest::Prompt { session, .. }
15143 | NodeRequest::Paste { session, .. }
15144 | NodeRequest::Input { session, .. }
15145 | NodeRequest::TerminalBytes { session, .. }
15146 | NodeRequest::TerminalControl { session, .. }
15147 | NodeRequest::Resize { session, .. }
15148 | NodeRequest::Interrupt { session }
15149 | NodeRequest::Stop { session, .. }
15150 | NodeRequest::Remove { session }
15151 | NodeRequest::ResolveInteraction { session, .. }
15152 | NodeRequest::SetSessionMode { session, .. }
15153 | NodeRequest::SetSessionConfigOption { session, .. }
15154 | NodeRequest::SetSessionModel { session, .. }
15155 | NodeRequest::DiscoverHistory { session, .. }
15156 | NodeRequest::LoadHistory { session, .. }
15157 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15158 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15159 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15160 NodeRequest::RenameSessionRecord { record_id, .. }
15161 | NodeRequest::SetSessionTask { record_id, .. }
15162 | NodeRequest::ResumeSessionRecord { record_id, .. }
15163 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15164 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15165 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15166 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15167 NodeRequest::ArmHarnessMcpReservation { .. }
15168 | NodeRequest::ActivateHarnessMcpReservation { .. }
15169 | NodeRequest::AbortHarnessMcpReservation { .. }
15170 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15171 | NodeRequest::RejectHarnessMcpCall { .. }
15172 | NodeRequest::Snapshot
15173 | NodeRequest::Resync { .. }
15174 | NodeRequest::BeginDeliveryStage { .. }
15175 | NodeRequest::PutDeliveryBlobChunk { .. }
15176 | NodeRequest::CommitDeliveryStage { .. }
15177 | NodeRequest::AbortDeliveryStage { .. }
15178 | NodeRequest::BrowseHostDirectories { .. }
15179 | NodeRequest::InspectWorkspace { .. }
15180 | NodeRequest::ReadWorkspaceFile { .. }
15181 | NodeRequest::WriteWorkspaceFile { .. }
15182 | NodeRequest::CreateWorkspaceFile { .. }
15183 | NodeRequest::CreateWorkspaceDirectory { .. }
15184 | NodeRequest::ReadGitHistory { .. }
15185 | NodeRequest::ReadGitDiff { .. }
15186 | NodeRequest::AcquireController { .. }
15187 | NodeRequest::ReleaseController
15188 | NodeRequest::RegisterWorkspace { .. }
15189 | NodeRequest::CreateStandaloneWorkspace { .. }
15190 | NodeRequest::UnregisterWorkspace { .. }
15191 | NodeRequest::CreateWorktree { .. }
15192 | NodeRequest::RemoveWorktree { .. }
15193 | NodeRequest::CleanupManagedWorktree { .. }
15194 | NodeRequest::Shutdown => false,
15195 NodeRequest::ForgetContextPack { .. } => true,
15196 NodeRequest::ResolveDurableContextPack { .. }
15202 | NodeRequest::ReadContextPack { .. } => true,
15203 }
15204}
15205
15206fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15207 snapshot.enabled_providers.retain(provider_id_is_legacy);
15208 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15209 snapshot
15210 .provider_runtime_statuses
15211 .as_slice()
15212 .iter()
15213 .filter(|status| provider_id_is_legacy(status.provider()))
15214 .cloned(),
15215 )
15216 .expect("a filtered provider runtime projection remains valid");
15217 snapshot
15218 .session_records
15219 .retain(|record| provider_id_is_legacy(&record.provider));
15220 for workspace in &mut snapshot.workspaces {
15221 workspace
15222 .sessions
15223 .retain(|session| provider_id_is_legacy(&session.agent_id));
15224 }
15225}
15226
15227fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15228 for record in &mut snapshot.session_records {
15229 record.environment_profile = None;
15230 }
15231 if let Some(profiles) = snapshot
15232 .launch_inventory
15233 .as_mut()
15234 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15235 {
15236 for profile in profiles {
15237 profile.environment_profile = None;
15238 }
15239 }
15240}
15241
15242fn project_event_without_child_environment_profile(
15243 mut envelope: NodeEventEnvelope,
15244) -> NodeEventEnvelope {
15245 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15246 record.environment_profile = None;
15247 }
15248 envelope
15249}
15250
15251fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15252 let contains_environment_profile = match reply.result.as_ref() {
15253 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15254 receipt.environment_profile.is_some()
15255 }
15256 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15257 receipt.spawn.environment_profile.is_some()
15258 }
15259 _ => false,
15260 };
15261 if contains_environment_profile {
15262 reply.result = Err(failure(
15263 NodeFailureCode::UnsupportedCapability,
15264 "child environment profiles were not negotiated",
15265 ));
15266 return;
15267 }
15268 let Ok(response) = reply.result.as_mut() else {
15269 return;
15270 };
15271 match response {
15272 NodeResponse::Snapshot { snapshot, .. } => {
15273 clear_snapshot_child_environment_profiles(snapshot);
15274 }
15275 NodeResponse::Resync { snapshot, events, .. } => {
15276 clear_snapshot_child_environment_profiles(snapshot);
15277 for event in events {
15278 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15279 record.environment_profile = None;
15280 }
15281 }
15282 }
15283 NodeResponse::SessionRecordUpdated { record }
15284 | NodeResponse::ProviderSessionIndexed { record }
15285 | NodeResponse::NativeSessionIndexed { record, .. }
15286 | NodeResponse::SessionRecordResumed { record, .. } => {
15287 record.environment_profile = None;
15288 }
15289 NodeResponse::Armed { .. }
15290 | NodeResponse::Spawned { .. }
15291 | NodeResponse::Activated { .. }
15292 | NodeResponse::Aborted { .. }
15293 | NodeResponse::ReplyChunkAccepted { .. }
15294 | NodeResponse::CallRejected { .. }
15295 | NodeResponse::DeliveryStageBegun { .. }
15296 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15297 | NodeResponse::DeliveryCommitted { .. }
15298 | NodeResponse::DeliveryStageAborted { .. }
15299 | NodeResponse::WorkspaceInspected { .. }
15300 | NodeResponse::HostDirectoriesBrowsed { .. }
15301 | NodeResponse::WorkspaceFileRead { .. }
15302 | NodeResponse::WorkspaceFileWritten { .. }
15303 | NodeResponse::WorkspaceFileCreated { .. }
15304 | NodeResponse::WorkspaceDirectoryCreated { .. }
15305 | NodeResponse::GitHistoryRead { .. }
15306 | NodeResponse::GitDiffRead { .. }
15307 | NodeResponse::Controller { .. }
15308 | NodeResponse::SpawnAccepted { .. }
15309 | NodeResponse::SpawnSpecAccepted { .. }
15310 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15311 | NodeResponse::ManagedWorktreeCleanup { .. }
15312 | NodeResponse::NativeSessionsCataloged { .. }
15313 | NodeResponse::NativeSessionsPaged { .. }
15314 | NodeResponse::NativeSessionPreviewed { .. }
15315 | NodeResponse::SessionRecordPreviewed { .. }
15316 | NodeResponse::HistoryDiscovered { .. }
15317 | NodeResponse::HistoryLoaded { .. }
15318 | NodeResponse::ContextPackForSessionRecordExported { .. }
15319 | NodeResponse::ContextPackExported { .. }
15320 | NodeResponse::ContextPackForgotten { .. }
15321 | NodeResponse::DurableContextPackResolved { .. }
15322 | NodeResponse::ContextPackBytesRead { .. }
15323 | NodeResponse::SessionRecordForgotten { .. }
15324 | NodeResponse::WorkspaceRegistered { .. }
15325 | NodeResponse::StandaloneWorkspaceCreated { .. }
15326 | NodeResponse::WorkspaceUnregistered { .. }
15327 | NodeResponse::WorktreeCreated { .. }
15328 | NodeResponse::WorktreeRemoved { .. }
15329 | NodeResponse::Accepted
15330 | NodeResponse::ShuttingDown => {}
15331 }
15332}
15333
15334fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15335 for record in &mut snapshot.session_records {
15336 record.bundle = None;
15337 }
15338}
15339
15340fn project_event_without_session_bundle(
15341 mut envelope: NodeEventEnvelope,
15342) -> NodeEventEnvelope {
15343 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15344 record.bundle = None;
15345 }
15346 envelope
15347}
15348
15349fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15350 let contains_bundle = match reply.result.as_ref() {
15351 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15352 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15353 receipt.spawn.bundle.is_some()
15354 }
15355 _ => false,
15356 };
15357 if contains_bundle {
15358 reply.result = Err(failure(
15359 NodeFailureCode::UnsupportedCapability,
15360 "session bundles were not negotiated",
15361 ));
15362 return;
15363 }
15364 let Ok(response) = reply.result.as_mut() else {
15365 return;
15366 };
15367 match response {
15368 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15369 NodeResponse::Resync { snapshot, events, .. } => {
15370 clear_snapshot_session_bundles(snapshot);
15371 for event in events {
15372 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15373 record.bundle = None;
15374 }
15375 }
15376 }
15377 NodeResponse::SessionRecordUpdated { record }
15378 | NodeResponse::ProviderSessionIndexed { record }
15379 | NodeResponse::NativeSessionIndexed { record, .. }
15380 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15381 NodeResponse::Armed { .. }
15382 | NodeResponse::Spawned { .. }
15383 | NodeResponse::Activated { .. }
15384 | NodeResponse::Aborted { .. }
15385 | NodeResponse::ReplyChunkAccepted { .. }
15386 | NodeResponse::CallRejected { .. }
15387 | NodeResponse::DeliveryStageBegun { .. }
15388 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15389 | NodeResponse::DeliveryCommitted { .. }
15390 | NodeResponse::DeliveryStageAborted { .. }
15391 | NodeResponse::WorkspaceInspected { .. }
15392 | NodeResponse::HostDirectoriesBrowsed { .. }
15393 | NodeResponse::WorkspaceFileRead { .. }
15394 | NodeResponse::WorkspaceFileWritten { .. }
15395 | NodeResponse::WorkspaceFileCreated { .. }
15396 | NodeResponse::WorkspaceDirectoryCreated { .. }
15397 | NodeResponse::GitHistoryRead { .. }
15398 | NodeResponse::GitDiffRead { .. }
15399 | NodeResponse::Controller { .. }
15400 | NodeResponse::SpawnAccepted { .. }
15401 | NodeResponse::SpawnSpecAccepted { .. }
15402 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15403 | NodeResponse::ManagedWorktreeCleanup { .. }
15404 | NodeResponse::NativeSessionsCataloged { .. }
15405 | NodeResponse::NativeSessionsPaged { .. }
15406 | NodeResponse::NativeSessionPreviewed { .. }
15407 | NodeResponse::SessionRecordPreviewed { .. }
15408 | NodeResponse::HistoryDiscovered { .. }
15409 | NodeResponse::HistoryLoaded { .. }
15410 | NodeResponse::ContextPackForSessionRecordExported { .. }
15411 | NodeResponse::ContextPackExported { .. }
15412 | NodeResponse::ContextPackForgotten { .. }
15413 | NodeResponse::DurableContextPackResolved { .. }
15414 | NodeResponse::ContextPackBytesRead { .. }
15415 | NodeResponse::SessionRecordForgotten { .. }
15416 | NodeResponse::WorkspaceRegistered { .. }
15417 | NodeResponse::StandaloneWorkspaceCreated { .. }
15418 | NodeResponse::WorkspaceUnregistered { .. }
15419 | NodeResponse::WorktreeCreated { .. }
15420 | NodeResponse::WorktreeRemoved { .. }
15421 | NodeResponse::Accepted
15422 | NodeResponse::ShuttingDown => {}
15423 }
15424}
15425
15426fn project_snapshot_history_for_wire(
15427 snapshot: &mut NodeSnapshot,
15428 include_history_metadata: bool,
15429) {
15430 for workspace in &mut snapshot.workspaces {
15431 for session in &mut workspace.sessions {
15432 if include_history_metadata {
15433 session.history.loaded = None;
15434 } else {
15435 session.history = Default::default();
15436 }
15437 }
15438 }
15439}
15440
15441fn project_event_history_for_wire(
15442 mut envelope: NodeEventEnvelope,
15443 include_history_metadata: bool,
15444) -> NodeEventEnvelope {
15445 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15446 for session in &mut workspace.sessions {
15447 if include_history_metadata {
15448 session.history.loaded = None;
15449 } else {
15450 session.history = Default::default();
15451 }
15452 }
15453 }
15454 envelope
15455}
15456
15457fn project_response_history_for_wire(
15458 reply: &mut ResponseEnvelope,
15459 include_history_metadata: bool,
15460) {
15461 let Ok(response) = reply.result.as_mut() else {
15462 return;
15463 };
15464 match response {
15465 NodeResponse::Snapshot { snapshot, .. } => {
15466 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15467 }
15468 NodeResponse::Resync {
15469 snapshot, events, ..
15470 } => {
15471 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15472 for event in events {
15473 *event = project_event_history_for_wire(
15474 event.clone(),
15475 include_history_metadata,
15476 );
15477 }
15478 }
15479 NodeResponse::WorkspaceRegistered { workspace }
15480 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15481 | NodeResponse::WorktreeCreated { workspace, .. } => {
15482 for session in &mut workspace.sessions {
15483 if include_history_metadata {
15484 session.history.loaded = None;
15485 } else {
15486 session.history = Default::default();
15487 }
15488 }
15489 }
15490 _ => {}
15491 }
15492}
15493
15494fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15495 for record in &mut snapshot.session_records {
15496 record.context_id = None;
15497 record.context = None;
15498 record.exported_context = None;
15499 }
15500}
15501
15502fn project_event_without_context_pack(
15503 mut envelope: NodeEventEnvelope,
15504) -> NodeEventEnvelope {
15505 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15506 record.context_id = None;
15507 record.context = None;
15508 record.exported_context = None;
15509 }
15510 envelope
15511}
15512
15513fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15514 let contains_context = match reply.result.as_ref() {
15515 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15516 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15517 receipt.spawn.context.is_some()
15518 }
15519 Ok(NodeResponse::HistoryDiscovered { .. })
15520 | Ok(NodeResponse::HistoryLoaded { .. })
15521 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15522 | Ok(NodeResponse::ContextPackExported { .. })
15523 | Ok(NodeResponse::ContextPackForgotten { .. })
15524 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15525 _ => false,
15526 };
15527 if contains_context {
15528 reply.result = Err(failure(
15529 NodeFailureCode::UnsupportedCapability,
15530 "history context packs were not negotiated",
15531 ));
15532 return;
15533 }
15534 let Ok(response) = reply.result.as_mut() else {
15535 return;
15536 };
15537 match response {
15538 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15539 NodeResponse::Resync {
15540 snapshot, events, ..
15541 } => {
15542 clear_snapshot_context_packs(snapshot);
15543 for event in events {
15544 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15545 record.context_id = None;
15546 record.context = None;
15547 record.exported_context = None;
15548 }
15549 }
15550 }
15551 NodeResponse::SessionRecordUpdated { record }
15552 | NodeResponse::ProviderSessionIndexed { record }
15553 | NodeResponse::NativeSessionIndexed { record, .. }
15554 | NodeResponse::SessionRecordResumed { record, .. } => {
15555 record.context_id = None;
15556 record.context = None;
15557 record.exported_context = None;
15558 }
15559 _ => {}
15560 }
15561}
15562
15563fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15564 let contains_open_record = match reply.result.as_ref() {
15565 Ok(NodeResponse::SessionRecordUpdated { record })
15566 | Ok(NodeResponse::ProviderSessionIndexed { record })
15567 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15568 !provider_id_is_legacy(&record.provider)
15569 }
15570 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15571 !provider_id_is_legacy(&selection.route.provider)
15572 || !provider_id_is_legacy(&record.provider)
15573 }
15574 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15575 !provider_id_is_legacy(&receipt.provider)
15576 }
15577 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15578 !provider_id_is_legacy(&receipt.spawn.provider)
15579 }
15580 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15581 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15582 !provider_id_is_legacy(&route.provider)
15583 }
15584 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15585 !provider_id_is_legacy(&selection.route.provider)
15586 }
15587 _ => false,
15588 };
15589 if contains_open_record {
15590 reply.result = Err(failure(
15591 NodeFailureCode::UnsupportedCapability,
15592 "open provider IDs were not negotiated",
15593 ));
15594 return;
15595 }
15596 let Ok(response) = reply.result.as_mut() else {
15597 return;
15598 };
15599 match response {
15600 NodeResponse::Snapshot { snapshot, .. } => {
15601 project_snapshot_legacy_provider_ids(snapshot);
15602 }
15603 NodeResponse::Resync { snapshot, events, .. } => {
15604 project_snapshot_legacy_provider_ids(snapshot);
15605 *events = std::mem::take(events)
15606 .into_iter()
15607 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15608 .collect();
15609 }
15610 NodeResponse::WorkspaceRegistered { workspace }
15611 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15612 | NodeResponse::WorktreeCreated { workspace, .. } => {
15613 workspace
15614 .sessions
15615 .retain(|session| provider_id_is_legacy(&session.agent_id));
15616 }
15617 NodeResponse::Armed { .. }
15618 | NodeResponse::Spawned { .. }
15619 | NodeResponse::Activated { .. }
15620 | NodeResponse::Aborted { .. }
15621 | NodeResponse::ReplyChunkAccepted { .. }
15622 | NodeResponse::CallRejected { .. }
15623 | NodeResponse::DeliveryStageBegun { .. }
15624 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15625 | NodeResponse::DeliveryCommitted { .. }
15626 | NodeResponse::DeliveryStageAborted { .. }
15627 | NodeResponse::WorkspaceInspected { .. }
15628 | NodeResponse::HostDirectoriesBrowsed { .. }
15629 | NodeResponse::WorkspaceFileRead { .. }
15630 | NodeResponse::WorkspaceFileWritten { .. }
15631 | NodeResponse::WorkspaceFileCreated { .. }
15632 | NodeResponse::WorkspaceDirectoryCreated { .. }
15633 | NodeResponse::GitHistoryRead { .. }
15634 | NodeResponse::GitDiffRead { .. }
15635 | NodeResponse::Controller { .. }
15636 | NodeResponse::SpawnAccepted { .. }
15637 | NodeResponse::SpawnSpecAccepted { .. }
15638 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15639 | NodeResponse::ManagedWorktreeCleanup { .. }
15640 | NodeResponse::NativeSessionsCataloged { .. }
15641 | NodeResponse::NativeSessionsPaged { .. }
15642 | NodeResponse::NativeSessionPreviewed { .. }
15643 | NodeResponse::SessionRecordPreviewed { .. }
15644 | NodeResponse::HistoryDiscovered { .. }
15645 | NodeResponse::HistoryLoaded { .. }
15646 | NodeResponse::ContextPackForSessionRecordExported { .. }
15647 | NodeResponse::ContextPackExported { .. }
15648 | NodeResponse::ContextPackForgotten { .. }
15649 | NodeResponse::DurableContextPackResolved { .. }
15650 | NodeResponse::ContextPackBytesRead { .. }
15651 | NodeResponse::SessionRecordUpdated { .. }
15652 | NodeResponse::ProviderSessionIndexed { .. }
15653 | NodeResponse::NativeSessionIndexed { .. }
15654 | NodeResponse::SessionRecordResumed { .. }
15655 | NodeResponse::SessionRecordForgotten { .. }
15656 | NodeResponse::WorkspaceUnregistered { .. }
15657 | NodeResponse::WorktreeRemoved { .. }
15658 | NodeResponse::Accepted
15659 | NodeResponse::ShuttingDown => {}
15660 }
15661}
15662
15663fn project_event_legacy_provider_ids(
15664 shared: &NodeShared,
15665 mut envelope: NodeEventEnvelope,
15666) -> Option<NodeEventEnvelope> {
15667 let include = match &mut envelope.event {
15668 NodeEvent::HarnessMcpReadCall { .. } => true,
15669 NodeEvent::Control { address, .. }
15670 | NodeEvent::AgentStream { address, .. }
15671 | NodeEvent::TerminalFrame { address, .. } => shared
15672 .validate_address(address)
15673 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15674 NodeEvent::WorkspaceAdded { workspace } => {
15675 workspace
15676 .sessions
15677 .retain(|session| provider_id_is_legacy(&session.agent_id));
15678 true
15679 }
15680 NodeEvent::SessionRecordUpserted { record } => {
15681 provider_id_is_legacy(&record.provider)
15682 }
15683 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15684 NodeEvent::SessionRecordRemoved { .. } => shared
15685 .history
15686 .lock()
15687 .unwrap_or_else(|poisoned| poisoned.into_inner())
15688 .removed_record_providers
15689 .get(&envelope.sequence)
15690 .is_some_and(provider_id_is_legacy),
15691 NodeEvent::ControllerChanged { .. }
15692 | NodeEvent::ManagedWorktreeUpserted { .. }
15693 | NodeEvent::ManagedWorktreeRemoved { .. }
15694 | NodeEvent::WorkspaceRemoved { .. }
15695 | NodeEvent::ResyncRequired { .. } => true,
15696 };
15697 include.then_some(envelope)
15698}
15699
15700fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15701 let Ok(response) = reply.result.as_mut() else {
15702 return;
15703 };
15704 match response {
15705 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15706 snapshot.provider_runtime_statuses.clear();
15707 }
15708 NodeResponse::Armed { .. }
15709 | NodeResponse::Spawned { .. }
15710 | NodeResponse::Activated { .. }
15711 | NodeResponse::Aborted { .. }
15712 | NodeResponse::ReplyChunkAccepted { .. }
15713 | NodeResponse::CallRejected { .. }
15714 | NodeResponse::DeliveryStageBegun { .. }
15715 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15716 | NodeResponse::DeliveryCommitted { .. }
15717 | NodeResponse::DeliveryStageAborted { .. }
15718 | NodeResponse::WorkspaceInspected { .. }
15719 | NodeResponse::HostDirectoriesBrowsed { .. }
15720 | NodeResponse::WorkspaceFileRead { .. }
15721 | NodeResponse::WorkspaceFileWritten { .. }
15722 | NodeResponse::WorkspaceFileCreated { .. }
15723 | NodeResponse::WorkspaceDirectoryCreated { .. }
15724 | NodeResponse::GitHistoryRead { .. }
15725 | NodeResponse::GitDiffRead { .. }
15726 | NodeResponse::Controller { .. }
15727 | NodeResponse::SpawnAccepted { .. }
15728 | NodeResponse::SpawnSpecAccepted { .. }
15729 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15730 | NodeResponse::ManagedWorktreeCleanup { .. }
15731 | NodeResponse::NativeSessionsCataloged { .. }
15732 | NodeResponse::NativeSessionsPaged { .. }
15733 | NodeResponse::NativeSessionPreviewed { .. }
15734 | NodeResponse::SessionRecordPreviewed { .. }
15735 | NodeResponse::HistoryDiscovered { .. }
15736 | NodeResponse::HistoryLoaded { .. }
15737 | NodeResponse::ContextPackForSessionRecordExported { .. }
15738 | NodeResponse::ContextPackExported { .. }
15739 | NodeResponse::ContextPackForgotten { .. }
15740 | NodeResponse::DurableContextPackResolved { .. }
15741 | NodeResponse::ContextPackBytesRead { .. }
15742 | NodeResponse::SessionRecordUpdated { .. }
15743 | NodeResponse::ProviderSessionIndexed { .. }
15744 | NodeResponse::NativeSessionIndexed { .. }
15745 | NodeResponse::SessionRecordResumed { .. }
15746 | NodeResponse::SessionRecordForgotten { .. }
15747 | NodeResponse::WorkspaceRegistered { .. }
15748 | NodeResponse::StandaloneWorkspaceCreated { .. }
15749 | NodeResponse::WorkspaceUnregistered { .. }
15750 | NodeResponse::WorktreeCreated { .. }
15751 | NodeResponse::WorktreeRemoved { .. }
15752 | NodeResponse::Accepted
15753 | NodeResponse::ShuttingDown => {}
15754 }
15755}
15756
15757struct ControllerReleaseGuard {
15758 shared: Arc<NodeShared>,
15759 connection_id: u64,
15760}
15761
15762impl Drop for ControllerReleaseGuard {
15763 fn drop(&mut self) {
15764 self.shared.release_controller(self.connection_id);
15765 }
15766}
15767
15768struct AbortTaskOnDrop(AbortHandle);
15769
15770impl Drop for AbortTaskOnDrop {
15771 fn drop(&mut self) {
15772 self.0.abort();
15773 }
15774}
15775
15776fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15781 std::env::var(name)
15782 .ok()
15783 .and_then(|value| value.parse::<u64>().ok())
15784 .filter(|value| *value > 0)
15785 .unwrap_or(default)
15786 .min(max)
15787}
15788
15789fn workspace_inspection_time_budget_ms() -> u64 {
15790 env_config_bounded(
15791 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15792 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15793 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15794 )
15795}
15796
15797fn workspace_inspection_entry_cap() -> usize {
15798 env_config_bounded(
15799 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15800 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15801 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15802 ) as usize
15803}
15804
15805struct WorkspaceWalkBudget {
15811 deadline: Instant,
15812 entry_cap: usize,
15813 entries_visited: u64,
15814 time_budget_exceeded: bool,
15815 entry_cap_exceeded: bool,
15816}
15817
15818impl WorkspaceWalkBudget {
15819 fn new(deadline: Instant, entry_cap: usize) -> Self {
15820 Self {
15821 deadline,
15822 entry_cap,
15823 entries_visited: 0,
15824 time_budget_exceeded: false,
15825 entry_cap_exceeded: false,
15826 }
15827 }
15828
15829 fn is_exhausted(&self) -> bool {
15830 self.time_budget_exceeded || self.entry_cap_exceeded
15831 }
15832
15833 fn record_visit(&mut self) -> bool {
15836 self.entries_visited += 1;
15837 if self.entries_visited as usize >= self.entry_cap {
15838 self.entry_cap_exceeded = true;
15839 }
15840 if Instant::now() >= self.deadline {
15841 self.time_budget_exceeded = true;
15842 }
15843 self.is_exhausted()
15844 }
15845}
15846
15847fn collect_workspace_entries(
15848 root: &Path,
15849 deadline: Instant,
15850 entry_cap: usize,
15851) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15852 let mut entries = Vec::new();
15853 let mut truncated = false;
15854 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15855 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15856 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15860 (entries, truncated, budget)
15861}
15862
15863fn workspace_inspection_truncation(
15867 walk_budget: &WorkspaceWalkBudget,
15868 git_time_budget_exceeded: bool,
15869 elapsed: Duration,
15870) -> Option<WorkspaceInspectionTruncationV1> {
15871 if !walk_budget.time_budget_exceeded
15872 && !walk_budget.entry_cap_exceeded
15873 && !git_time_budget_exceeded
15874 {
15875 return None;
15876 }
15877 Some(WorkspaceInspectionTruncationV1 {
15878 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15879 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15880 git_time_budget_exceeded,
15881 entries_visited: walk_budget.entries_visited,
15882 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15883 })
15884}
15885
15886fn walk_workspace_directory(
15887 directory: &Path,
15888 relative_directory: Option<&RepositoryPath>,
15889 depth: usize,
15890 entries: &mut Vec<WorkspaceEntry>,
15891 truncated: &mut bool,
15892 budget: &mut WorkspaceWalkBudget,
15893) {
15894 if budget.is_exhausted() {
15895 *truncated = true;
15896 return;
15897 }
15898 let read_dir = match std::fs::read_dir(directory) {
15899 Ok(read_dir) => read_dir,
15900 Err(_) => {
15901 *truncated = true;
15902 return;
15903 }
15904 };
15905 let mut children = Vec::new();
15906 for child in read_dir {
15907 match child {
15908 Ok(child) => children.push(child),
15909 Err(_) => *truncated = true,
15910 }
15911 }
15912 children.sort_by_key(|child| child.file_name());
15913 for child in children {
15914 if budget.record_visit() {
15915 *truncated = true;
15916 return;
15917 }
15918 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15919 *truncated = true;
15920 return;
15921 }
15922 let file_type = match child.file_type() {
15923 Ok(file_type) => file_type,
15924 Err(_) => {
15925 *truncated = true;
15926 continue;
15927 }
15928 };
15929 if file_type.is_symlink() {
15930 continue;
15931 }
15932 let name = match child.file_name().into_string() {
15933 Ok(name) => name,
15934 Err(_) => {
15935 *truncated = true;
15936 continue;
15937 }
15938 };
15939 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15940 continue;
15941 }
15942 let relative_path = match relative_directory {
15943 None => windows_repository_path(name),
15944 Some(parent) => parent
15945 .as_utf8()
15946 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15947 };
15948 let Some(relative_path) = relative_path else {
15949 *truncated = true;
15950 continue;
15951 };
15952 let kind = if file_type.is_dir() {
15953 WorkspaceEntryKind::Directory
15954 } else if file_type.is_file() {
15955 WorkspaceEntryKind::File
15956 } else {
15957 continue;
15958 };
15959 entries.push(WorkspaceEntry {
15960 relative_path: relative_path.clone(),
15961 kind,
15962 });
15963 if file_type.is_dir() {
15964 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15965 walk_workspace_directory(
15966 &child.path(),
15967 Some(&relative_path),
15968 depth + 1,
15969 entries,
15970 truncated,
15971 budget,
15972 );
15973 } else {
15974 *truncated = true;
15975 }
15976 }
15977 }
15978}
15979
15980fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15981 if value.contains('\\') {
15982 return None;
15983 }
15984 RepositoryPath::utf8(value).ok()
15985}
15986
15987fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15988 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15989}
15990
15991fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16000 if name.eq_ignore_ascii_case(".git")
16001 || name.eq_ignore_ascii_case("target")
16002 || name.eq_ignore_ascii_case("node_modules")
16003 {
16004 return true;
16005 }
16006 path.join("CACHEDIR.TAG").is_file()
16007}
16008
16009fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16010 matches!(
16011 status,
16012 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16013 )
16014}
16015
16016#[derive(Clone, Debug, Eq, PartialEq)]
16017enum ContextPackRepositoryHead {
16018 NotRepository,
16019 Unborn,
16020 Commit(String),
16021}
16022
16023#[derive(Clone, Debug, Eq, PartialEq)]
16024struct ContextPackRepositoryObservation {
16025 head: ContextPackRepositoryHead,
16026 git: GitSnapshot,
16027 files: Vec<ContextPackRepositoryFileSource>,
16028}
16029
16030fn stable_context_pack_repository(
16031 first: ContextPackRepositoryObservation,
16032 second: ContextPackRepositoryObservation,
16033) -> Result<ContextPackRepository, NodeFailure> {
16034 if first != second {
16035 return Err(failure(
16036 NodeFailureCode::ContextPackMaterializationFailed,
16037 "context repository changed during bounded capture",
16038 ));
16039 }
16040 Ok(ContextPackRepository::from_sources(
16041 &second.git,
16042 second.files,
16043 ))
16044}
16045
16046fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16053 if Instant::now() < deadline {
16054 return false;
16055 }
16056 append_git_diagnostic(
16057 snapshot,
16058 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16059 );
16060 true
16061}
16062
16063async fn run_git_bounded_with_deadline(
16072 root: &str,
16073 arguments: &[&str],
16074 output_limit: usize,
16075 deadline: Instant,
16076) -> io::Result<GitCommandOutput> {
16077 let remaining = deadline.checked_duration_since(Instant::now());
16078 let timeout_ms = remaining
16079 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16080 .unwrap_or(0)
16081 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16082 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16083}
16084
16085async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16092 let mut snapshot = GitSnapshot {
16093 is_repository: false,
16094 branch: None,
16095 status: Vec::new(),
16096 recent_commits: Vec::new(),
16097 worktrees: Vec::new(),
16098 managed_worktree: None,
16099 truncated: false,
16100 diagnostic: None,
16101 };
16102 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16103 return (snapshot, true);
16104 }
16105 let repository = match run_git_bounded_with_deadline(
16106 root,
16107 &["rev-parse", "--is-inside-work-tree"],
16108 4 * 1_024,
16109 deadline,
16110 )
16111 .await
16112 {
16113 Ok(output) => output,
16114 Err(error) => {
16115 append_git_diagnostic(
16116 &mut snapshot,
16117 &format!("git inspection unavailable: {error}"),
16118 );
16119 return (snapshot, false);
16120 }
16121 };
16122 snapshot.truncated |= repository.truncated;
16123 if repository.timed_out {
16124 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16125 return (snapshot, false);
16126 }
16127 if !repository.success
16128 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16129 {
16130 let stderr = String::from_utf8_lossy(&repository.stderr);
16131 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16132 append_git_diagnostic(&mut snapshot, stderr.trim());
16133 }
16134 return (snapshot, false);
16135 }
16136 snapshot.is_repository = true;
16137
16138 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16139 return (snapshot, true);
16140 }
16141 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16142 Ok(output) => {
16143 snapshot.truncated |= output.truncated;
16144 if output.timed_out {
16145 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16146 } else if output.success {
16147 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16148 if !branch.is_empty() {
16149 snapshot.branch = Some(truncate_git_field(
16150 &branch,
16151 MAX_REPOSITORY_PATH_BYTES,
16152 &mut snapshot.truncated,
16153 ));
16154 }
16155 }
16156 }
16157 Err(error) => append_git_diagnostic(
16158 &mut snapshot,
16159 &format!("git branch query failed: {error}"),
16160 ),
16161 }
16162 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16163 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16164 snapshot.truncated |= output.truncated;
16165 if output.success && !output.timed_out {
16166 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16167 if !head.is_empty() {
16168 snapshot.branch = Some(format!("detached:{head}"));
16169 }
16170 }
16171 }
16172 }
16173
16174 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16175 return (snapshot, true);
16176 }
16177 match run_git_bounded_with_deadline(
16178 root,
16179 &[
16180 "status",
16181 "--porcelain=v1",
16182 "-z",
16183 "--renames",
16184 "--untracked-files=normal",
16185 "--",
16186 ".",
16187 ],
16188 GIT_OUTPUT_MAX_BYTES,
16189 deadline,
16190 )
16191 .await
16192 {
16193 Ok(output) => {
16194 snapshot.truncated |= output.truncated;
16195 if output.timed_out {
16196 append_git_diagnostic(&mut snapshot, "git status timed out");
16197 } else if output.success {
16198 parse_git_status(&output.stdout, &mut snapshot);
16199 } else {
16200 append_git_diagnostic(
16201 &mut snapshot,
16202 String::from_utf8_lossy(&output.stderr).trim(),
16203 );
16204 }
16205 }
16206 Err(error) => append_git_diagnostic(
16207 &mut snapshot,
16208 &format!("git status failed: {error}"),
16209 ),
16210 }
16211
16212 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16213 return (snapshot, true);
16214 }
16215 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16216 match run_git_bounded_with_deadline(
16217 root,
16218 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16219 16 * 1_024,
16220 deadline,
16221 )
16222 .await
16223 {
16224 Ok(output) => {
16225 snapshot.truncated |= output.truncated;
16226 if output.timed_out {
16227 append_git_diagnostic(&mut snapshot, "git log timed out");
16228 } else if output.success {
16229 parse_git_commits(&output.stdout, &mut snapshot);
16230 } else {
16231 let stderr = String::from_utf8_lossy(&output.stderr);
16232 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16233 append_git_diagnostic(&mut snapshot, stderr.trim());
16234 }
16235 }
16236 }
16237 Err(error) => append_git_diagnostic(
16238 &mut snapshot,
16239 &format!("git log failed: {error}"),
16240 ),
16241 }
16242
16243 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16244 return (snapshot, true);
16245 }
16246 match list_git_worktrees_with_deadline(root, deadline).await {
16247 Ok(worktrees) => {
16248 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16249 }
16250 Err(error) => append_git_diagnostic(
16251 &mut snapshot,
16252 &format!("git worktree list failed: {}", error.message),
16253 ),
16254 }
16255 (snapshot, false)
16256}
16257
16258fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16259 let mut remaining = output;
16260 while !remaining.is_empty() {
16261 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16262 snapshot.truncated = true;
16263 break;
16264 }
16265 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16266 snapshot.truncated = true;
16267 break;
16268 };
16269 let record = &remaining[..record_end];
16270 remaining = &remaining[record_end + 1..];
16271 let has_previous_path = record
16272 .get(..2)
16273 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16274 let previous_path_bytes = if has_previous_path {
16275 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16276 snapshot.truncated = true;
16277 break;
16278 };
16279 let previous = &remaining[..previous_end];
16280 remaining = &remaining[previous_end + 1..];
16281 Some(previous)
16282 } else {
16283 None
16284 };
16285 if record.len() < 4
16286 || record[2] != b' '
16287 || !is_git_status_code(record[0])
16288 || !is_git_status_code(record[1])
16289 {
16290 snapshot.truncated = true;
16291 continue;
16292 }
16293 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16294 snapshot.truncated = true;
16295 continue;
16296 };
16297 let previous_path = match previous_path_bytes {
16298 Some(previous) => {
16299 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16300 snapshot.truncated = true;
16301 continue;
16302 };
16303 Some(previous)
16304 }
16305 None => None,
16306 };
16307 snapshot.status.push(GitStatusEntry {
16308 index_status: (record[0] as char).to_string(),
16309 worktree_status: (record[1] as char).to_string(),
16310 path,
16311 previous_path,
16312 });
16313 }
16314}
16315
16316fn is_git_status_code(byte: u8) -> bool {
16317 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16318}
16319
16320fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16321 for line in String::from_utf8_lossy(output).lines() {
16322 let Some((id, summary)) = line.split_once('\u{1f}') else {
16323 continue;
16324 };
16325 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16326 snapshot.truncated = true;
16327 break;
16328 }
16329 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16330 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16331 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16332 }
16333}
16334
16335fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16336 if value.len() <= max_bytes {
16337 return value.to_owned();
16338 }
16339 *truncated = true;
16340 let mut boundary = max_bytes;
16341 while boundary > 0 && !value.is_char_boundary(boundary) {
16342 boundary -= 1;
16343 }
16344 value[..boundary].to_owned()
16345}
16346
16347fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16348 if message.is_empty() {
16349 return;
16350 }
16351 let normalized = message
16352 .chars()
16353 .map(|character| if character.is_control() { ' ' } else { character })
16354 .collect::<String>();
16355 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16356 if !diagnostic.is_empty() {
16357 diagnostic.push_str("; ");
16358 }
16359 diagnostic.push_str(normalized.trim());
16360 snapshot.diagnostic = Some(truncate_git_field(
16361 &diagnostic,
16362 GIT_DIAGNOSTIC_MAX_BYTES,
16363 &mut snapshot.truncated,
16364 ));
16365}
16366
16367async fn run_git_bounded(
16368 root: &str,
16369 arguments: &[&str],
16370 output_limit: usize,
16371) -> io::Result<GitCommandOutput> {
16372 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16373}
16374
16375async fn read_git_history_bounded(
16376 root: &str,
16377 path: Option<&RepositoryPath>,
16378 before: Option<&GitObjectId>,
16379 limit: u16,
16380) -> Result<GitHistoryPage, NodeFailure> {
16381 let path = path
16382 .map(|path| {
16383 path.as_utf8().ok_or_else(|| {
16384 failure(
16385 NodeFailureCode::InvalidRepositoryPath,
16386 "git history path must use UTF-8",
16387 )
16388 })
16389 })
16390 .transpose()?
16391 .unwrap_or(".");
16392 let count = (usize::from(limit) + 1).to_string();
16393 let start = before
16394 .map(|revision| format!("{}^", revision.as_str()))
16395 .unwrap_or_else(|| "HEAD".to_owned());
16396 let arguments = [
16397 "--no-pager",
16398 "log",
16399 "--no-decorate",
16400 "--date=iso-strict",
16401 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16402 "-n",
16403 count.as_str(),
16404 start.as_str(),
16405 "--",
16406 path,
16407 ];
16408 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16409 .await
16410 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16411 if output.timed_out {
16412 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16413 }
16414 if !output.success {
16415 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16416 }
16417 let mut commits = parse_git_history(&output.stdout);
16418 let has_more = commits.len() > usize::from(limit) || output.truncated;
16419 commits.truncate(usize::from(limit));
16420 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16421 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16422}
16423
16424fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16425 String::from_utf8_lossy(output)
16426 .split('\u{1e}')
16427 .filter_map(|record| {
16428 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16429 if fields.len() != 9 {
16430 return None;
16431 }
16432 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16433 let parents = if fields[1].is_empty() {
16434 Vec::new()
16435 } else {
16436 fields[1]
16437 .split(' ')
16438 .map(|value| GitObjectId::new(value.to_owned()))
16439 .collect::<Result<Vec<_>, _>>()
16440 .ok()?
16441 };
16442 Some(GitCommitDetails {
16443 id,
16444 parents,
16445 subject: bounded_git_text(fields[2], 512),
16446 author_name: bounded_git_text(fields[3], 256),
16447 author_email: bounded_git_text(fields[4], 320),
16448 authored_at: bounded_git_text(fields[5], 64),
16449 committer_name: bounded_git_text(fields[6], 256),
16450 committer_email: bounded_git_text(fields[7], 320),
16451 committed_at: bounded_git_text(fields[8], 64),
16452 signature_status: GitSignatureStatus::NoSignature,
16453 signer: None,
16454 })
16455 })
16456 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16457 .collect()
16458}
16459
16460fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16461 let normalized = value
16462 .chars()
16463 .map(|character| if character.is_control() { ' ' } else { character })
16464 .collect::<String>();
16465 if normalized.len() <= max_bytes {
16466 return normalized;
16467 }
16468 let mut boundary = max_bytes;
16469 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16470 boundary -= 1;
16471 }
16472 normalized[..boundary].to_owned()
16473}
16474
16475async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16476 let path = request.path.as_ref().map(|path| {
16477 path.as_utf8()
16478 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16479 }).transpose()?;
16480 let mut arguments = match &request.mode {
16481 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16482 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16483 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16484 };
16485 if let GitDiffMode::Commit { revision } = &request.mode {
16486 arguments.push(revision.as_str());
16487 }
16488 arguments.push("--");
16489 if let Some(path) = path {
16490 arguments.push(path);
16491 }
16492 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16493 .await
16494 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16495 if output.timed_out {
16496 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16497 }
16498 if !output.success {
16499 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16500 }
16501 Ok(GitDiff {
16502 mode: request.mode,
16503 path: request.path,
16504 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16505 truncated: output.truncated,
16506 })
16507}
16508
16509async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16510 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16511 ResponseEnvelope { request_id: envelope.request_id, result }
16512}
16513
16514async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16515 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16516 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16517 }
16518 let read_only = matches!(
16519 &request,
16520 NodeRequest::Snapshot
16521 | NodeRequest::Resync { .. }
16522 | NodeRequest::BrowseHostDirectories { .. }
16523 | NodeRequest::InspectWorkspace { .. }
16524 | NodeRequest::ReadWorkspaceFile { .. }
16525 | NodeRequest::ReadGitHistory { .. }
16526 | NodeRequest::ReadGitDiff { .. }
16527 | NodeRequest::CatalogNativeSessions { .. }
16528 | NodeRequest::PageNativeSessions { .. }
16529 | NodeRequest::PreviewNativeSession { .. }
16530 | NodeRequest::PreviewSessionRecord { .. }
16531 );
16532 let _mutation_guard = if read_only {
16533 None
16534 } else {
16535 let guard = shared.mutation_gate.lock().await;
16536 if shared.shutdown.load(Ordering::Acquire) {
16537 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16538 }
16539 Some(guard)
16540 };
16541 match request {
16542 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16543 event_sequence: shared.current_sequence(),
16544 controller: shared.controller_state(),
16545 snapshot: shared.snapshot(),
16546 }),
16547 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16548 NodeRequest::ArmHarnessMcpReservation {
16549 reservation_id,
16550 activation_digest,
16551 spawn_spec,
16552 launch,
16553 expires_at_unix_ms,
16554 } => {
16555 shared.require_controller(connection_id, role)?;
16556 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16557 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16558 })?;
16559 let expires_at_unix_ms = registry
16560 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16561 .await
16562 .map_err(harness_mcp_failure)?;
16563 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16564 }
16565 NodeRequest::SpawnSpecWithHarnessMcp {
16566 reservation_id,
16567 activation_digest,
16568 spec,
16569 deadline_unix_ms,
16570 } => {
16571 shared.require_controller(connection_id, role)?;
16572 let receipt = shared
16573 .spawn_from_spec_with_harness_mcp(
16574 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16575 )
16576 .await?;
16577 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16578 }
16579 NodeRequest::ActivateHarnessMcpReservation {
16580 reservation_id,
16581 activation_digest,
16582 record_id,
16583 session,
16584 } => {
16585 shared.require_controller(connection_id, role)?;
16586 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16587 current.instance_id == session.session.instance_id
16588 && current.generation == session.session.generation
16589 }).and_then(|current| current.process_id).ok_or_else(|| {
16590 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16591 })?;
16592 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16593 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16594 })?.activate(
16595 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16596 ).map_err(harness_mcp_failure)?;
16597 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16598 }
16599 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16600 shared.require_controller(connection_id, role)?;
16601 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16602 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16603 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16604 if let Some(instance_id) = instance_id {
16605 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16606 }
16607 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16608 }
16609 NodeRequest::PutHarnessMcpReplyChunk {
16610 reservation_id, activation_digest, record_id, session, call_id,
16611 offset, final_chunk, chunk_hex,
16612 } => {
16613 shared.require_controller(connection_id, role)?;
16614 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16615 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16616 })?.put_reply_chunk(
16617 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16618 offset, final_chunk, &chunk_hex,
16619 ).map_err(harness_mcp_failure)?;
16620 Ok(NodeResponse::ReplyChunkAccepted {
16621 reservation_id, activation_digest, record_id, session, call_id,
16622 next_offset, completed,
16623 })
16624 }
16625 NodeRequest::RejectHarnessMcpCall {
16626 reservation_id, activation_digest, record_id, session, call_id, reason,
16627 } => {
16628 shared.require_controller(connection_id, role)?;
16629 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16630 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16631 })?.reject_call(
16632 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16633 ).map_err(harness_mcp_failure)?;
16634 Ok(NodeResponse::CallRejected {
16635 reservation_id, activation_digest, record_id, session, call_id,
16636 })
16637 }
16638 NodeRequest::BeginDeliveryStage { manifest } => {
16639 shared.require_controller(connection_id, role)?;
16640 shared.begin_delivery_stage(manifest)
16641 }
16642 NodeRequest::PutDeliveryBlobChunk {
16643 stage_id,
16644 blob_digest,
16645 offset,
16646 chunk_hex,
16647 } => {
16648 shared.require_controller(connection_id, role)?;
16649 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16650 }
16651 NodeRequest::CommitDeliveryStage { stage_id } => {
16652 shared.require_controller(connection_id, role)?;
16653 let receipt = shared.commit_delivery_stage(stage_id)?;
16654 Ok(NodeResponse::DeliveryCommitted { receipt })
16655 }
16656 NodeRequest::AbortDeliveryStage { stage_id } => {
16657 shared.require_controller(connection_id, role)?;
16658 shared.abort_delivery_stage(&stage_id)?;
16659 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16660 }
16661 NodeRequest::BrowseHostDirectories { directory, after } => {
16662 let listing = shared.browse_host_directories(directory, after).await?;
16663 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16664 }
16665 NodeRequest::InspectWorkspace { workspace_id } => {
16666 let inspection = shared.inspect_workspace(workspace_id).await?;
16667 Ok(NodeResponse::WorkspaceInspected { inspection })
16668 }
16669 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16670 let file = shared.read_workspace_file(workspace_id, path).await?;
16671 Ok(NodeResponse::WorkspaceFileRead { file })
16672 }
16673 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16674 shared.require_controller(connection_id, role)?;
16675 let file = shared
16676 .write_workspace_file(workspace_id, path, expected_revision, text)
16677 .await?;
16678 Ok(NodeResponse::WorkspaceFileWritten { file })
16679 }
16680 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16681 shared.require_controller(connection_id, role)?;
16682 let file = shared.create_workspace_file(workspace_id, path).await?;
16683 Ok(NodeResponse::WorkspaceFileCreated { file })
16684 }
16685 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16686 shared.require_controller(connection_id, role)?;
16687 let entry = shared
16688 .create_workspace_directory(workspace_id.clone(), path)
16689 .await?;
16690 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16691 }
16692 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16693 if role != ClientRole::Operator {
16694 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16695 }
16696 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16697 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16698 }
16699 let page = shared
16700 .read_git_history(workspace_id.clone(), path, before, limit)
16701 .await?;
16702 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16703 }
16704 NodeRequest::ReadGitDiff { workspace_id, request } => {
16705 if role != ClientRole::Operator {
16706 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16707 }
16708 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16709 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16710 }
16711 NodeRequest::CatalogNativeSessions { route, limit } => {
16712 if role != ClientRole::Operator {
16713 return Err(failure(
16714 NodeFailureCode::ObserverReadOnly,
16715 "native session catalog requires an operator connection",
16716 ));
16717 }
16718 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16719 return Err(failure(
16720 NodeFailureCode::InvalidRequest,
16721 "native session catalog limit is invalid",
16722 ));
16723 }
16724 let (entries, summary) = shared
16725 .catalog_native_sessions(route.clone(), limit)
16726 .await?;
16727 Ok(NodeResponse::NativeSessionsCataloged {
16728 route,
16729 entries,
16730 summary: Some(summary),
16731 })
16732 }
16733 NodeRequest::PageNativeSessions {
16734 route,
16735 window,
16736 catalog_revision,
16737 recent_cutoff_unix_ms,
16738 after_selection_id,
16739 limit,
16740 } => {
16741 if role != ClientRole::Operator {
16742 return Err(failure(
16743 NodeFailureCode::ObserverReadOnly,
16744 "native session catalog paging requires an operator connection",
16745 ));
16746 }
16747 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16748 || after_selection_id
16749 .as_deref()
16750 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16751 {
16752 return Err(failure(
16753 NodeFailureCode::InvalidRequest,
16754 "native session catalog page request is invalid",
16755 ));
16756 }
16757 let page = shared
16758 .page_native_sessions(
16759 route.clone(),
16760 window,
16761 catalog_revision,
16762 recent_cutoff_unix_ms,
16763 after_selection_id,
16764 limit,
16765 )
16766 .await?;
16767 Ok(NodeResponse::NativeSessionsPaged {
16768 route,
16769 page,
16770 })
16771 }
16772 NodeRequest::PreviewNativeSession {
16773 selection,
16774 message_limit,
16775 } => {
16776 if role != ClientRole::Operator {
16777 return Err(failure(
16778 NodeFailureCode::ObserverReadOnly,
16779 "native session preview requires an operator connection",
16780 ));
16781 }
16782 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16783 .contains(&message_limit)
16784 || selection.validate().is_err()
16785 {
16786 return Err(failure(
16787 NodeFailureCode::InvalidRequest,
16788 "native session preview request is invalid",
16789 ));
16790 }
16791 let preview = shared
16792 .preview_native_session(selection.clone(), message_limit)
16793 .await?;
16794 Ok(NodeResponse::NativeSessionPreviewed {
16795 selection,
16796 preview,
16797 })
16798 }
16799 NodeRequest::IndexNativeSession {
16800 selection,
16801 display_name,
16802 } => {
16803 shared.require_controller(connection_id, role)?;
16804 let record = shared
16805 .index_native_session(selection.clone(), display_name)
16806 .await?;
16807 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16808 }
16809 NodeRequest::PreviewSessionRecord {
16810 record_id,
16811 message_limit,
16812 } => {
16813 if role != ClientRole::Operator {
16814 return Err(failure(
16815 NodeFailureCode::ObserverReadOnly,
16816 "native session preview requires an operator connection",
16817 ));
16818 }
16819 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16820 .contains(&message_limit)
16821 {
16822 return Err(failure(
16823 NodeFailureCode::InvalidRequest,
16824 "native session preview request is invalid",
16825 ));
16826 }
16827 let record = shared.record(&record_id)?;
16828 let working_directory = shared.workspace_root(&record.workspace_id)?;
16829 if !platform::roots_equal(
16830 &working_directory,
16831 windows_path_text(&record.canonical_root),
16832 ) {
16833 return Err(failure(
16834 NodeFailureCode::SessionRecordConflict,
16835 "managed session workspace root changed; refusing to preview another directory",
16836 ));
16837 }
16838 let identity = record.provider_session.clone().ok_or_else(|| {
16839 failure(
16840 NodeFailureCode::BackendOperationFailed,
16841 "native session preview is unavailable for this session record",
16842 )
16843 })?;
16844 if identity.key != ProviderSessionKey::SessionId {
16845 return Err(failure(
16846 NodeFailureCode::BackendOperationFailed,
16847 "native session preview is unavailable for this session identity",
16848 ));
16849 }
16850 let preview = shared
16851 .preview_native_session_by_session_id(
16852 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16853 )
16854 .await?;
16855 if preview.session_id != identity.id {
16856 return Err(failure(
16857 NodeFailureCode::SessionRecordConflict,
16858 "native session preview identity changed while loading",
16859 ));
16860 }
16861 shared.revalidate_session_record_preview(&record, &identity)?;
16862 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16863 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16864 record_id: record_id.clone(),
16865 summary: SessionHistorySummaryV1::from(&preview),
16866 });
16867 Ok(NodeResponse::SessionRecordPreviewed {
16868 record_id,
16869 preview,
16870 })
16871 }
16872 NodeRequest::AcquireController { lease_ms } => {
16873 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16874 Ok(NodeResponse::Controller { controller: Some(controller) })
16875 }
16876 NodeRequest::ReleaseController => {
16877 shared.release_controller(connection_id);
16878 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16879 }
16880 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16881 shared.require_controller(connection_id, role)?;
16882 let root = require_windows_path(root)?;
16883 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16884 let workspace = shared.register_workspace(workspace_id, root).await?;
16885 Ok(NodeResponse::WorkspaceRegistered { workspace })
16886 }
16887 NodeRequest::CreateStandaloneWorkspace {
16888 workspace_id,
16889 root,
16890 initial_branch,
16891 } => {
16892 shared.require_controller(connection_id, role)?;
16893 let root = require_windows_path(root)?;
16894 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16895 let workspace = shared
16896 .create_standalone_workspace(workspace_id, root, initial_branch)
16897 .await?;
16898 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16899 }
16900 NodeRequest::UnregisterWorkspace { workspace_id } => {
16901 shared.require_controller(connection_id, role)?;
16902 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16903 shared.unregister_workspace(&workspace_id)?;
16904 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16905 }
16906 NodeRequest::CreateWorktree {
16907 source_workspace_id,
16908 workspace_id,
16909 target_root,
16910 branch,
16911 base,
16912 } => {
16913 shared.require_controller(connection_id, role)?;
16914 let target_root = require_windows_path(target_root)?;
16915 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16916 let (worktree, workspace) = shared
16917 .create_worktree(
16918 source_workspace_id,
16919 workspace_id,
16920 target_root,
16921 branch,
16922 base,
16923 )
16924 .await?;
16925 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16926 }
16927 NodeRequest::RemoveWorktree {
16928 source_workspace_id,
16929 target_root,
16930 } => {
16931 shared.require_controller(connection_id, role)?;
16932 let native_target_root = require_windows_path(target_root.clone())?;
16933 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16934 let workspace_id = shared
16935 .remove_worktree(source_workspace_id, native_target_root)
16936 .await?;
16937 Ok(NodeResponse::WorktreeRemoved {
16938 target_root,
16939 workspace_id,
16940 })
16941 }
16942 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16943 shared.require_controller(connection_id, role)?;
16944 if !terminal_size.is_valid() {
16945 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16946 }
16947 if let Some(prompt) = initial_prompt.as_deref() {
16948 validate_node_text("spawn initial prompt", prompt)?;
16949 }
16950 match shared
16951 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16952 .await
16953 {
16954 Ok(session) => {
16955 shared.mark_spawn_provisional(session.clone());
16956 Ok(NodeResponse::SpawnAccepted { session })
16957 }
16958 Err(err) => {
16959 tracing::warn!(
16960 %provider,
16961 %workspace_id,
16962 ?mode,
16963 code = ?err.code,
16964 message = %err.message,
16965 "session spawn refused"
16966 );
16967 Err(err)
16968 }
16969 }
16970 }
16971 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16972 shared.require_controller(connection_id, role)?;
16973 if !terminal_size.is_valid() {
16974 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16975 }
16976 if let Some(prompt) = initial_prompt.as_deref() {
16977 validate_node_text("spawn initial prompt", prompt)?;
16978 }
16979 let plan = backend_paths::plan_launch_options(
16980 provider.as_str(),
16981 mode,
16982 options.model.is_some(),
16983 options.effort.is_some(),
16984 )
16985 .map_err(|r| {
16986 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16987 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16988 })?;
16989 let selection = spawn_selection(&options)?;
16990 let pty_selection = match plan {
16991 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16992 _ => None,
16993 };
16994 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16995 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16996 SPAWN_SESSION_OPTIONS
16997 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16998 .await
16999 }).await
17000 }).await;
17001 match spawned {
17002 Ok(session) => {
17003 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17004 shared
17005 .pending_acp_options
17006 .lock()
17007 .unwrap_or_else(|p| p.into_inner())
17008 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17009 }
17010 shared.mark_spawn_provisional(session.clone());
17011 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17012 Ok(NodeResponse::SpawnAccepted { session })
17013 }
17014 Err(err) => {
17015 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17016 Err(err)
17017 }
17018 }
17019 }
17020 NodeRequest::SpawnSpec { spec } => {
17021 shared.require_controller(connection_id, role)?;
17022 let receipt = shared.spawn_from_spec(spec).await?;
17023 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17024 }
17025 NodeRequest::SpawnManagedWorktree { request } => {
17026 shared.require_controller(connection_id, role)?;
17027 let receipt = shared.spawn_managed_worktree(request).await?;
17028 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17029 }
17030 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17031 shared.require_controller(connection_id, role)?;
17032 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17033 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17034 }
17035 NodeRequest::CleanupManagedWorktree { lease_id } => {
17036 shared.require_controller(connection_id, role)?;
17037 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17038 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17039 }
17040 NodeRequest::DiscoverHistory { session, limit } => {
17041 controlled_session(shared, connection_id, role, &session)?;
17042 let candidates = shared.discover_history(&session, limit).await?;
17043 Ok(NodeResponse::HistoryDiscovered {
17044 session,
17045 candidates,
17046 })
17047 }
17048 NodeRequest::LoadHistory {
17049 session,
17050 candidate_id,
17051 } => {
17052 controlled_session(shared, connection_id, role, &session)?;
17053 let loaded = shared
17054 .load_history(&session, candidate_id)
17055 .await?;
17056 Ok(NodeResponse::HistoryLoaded {
17057 session,
17058 session_id: loaded.session_id,
17059 message_count: loaded.message_count,
17060 completed_turn_count: loaded.completed_turn_count,
17061 })
17062 }
17063 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17064 shared.require_controller(connection_id, role)?;
17065 let context = shared
17066 .export_context_pack_for_session_record(&record_id, &session, false)
17067 .await?;
17068 Ok(NodeResponse::ContextPackForSessionRecordExported {
17069 record_id,
17070 session,
17071 context,
17072 })
17073 }
17074 NodeRequest::ExportContextPack { session } => {
17075 controlled_session(shared, connection_id, role, &session)?;
17076 let context = shared.export_context_pack(&session).await?;
17077 Ok(NodeResponse::ContextPackExported { context })
17078 }
17079 NodeRequest::ForgetContextPack { context_id } => {
17080 shared.require_controller(connection_id, role)?;
17081 shared.forget_context_pack(&context_id)?;
17082 Ok(NodeResponse::ContextPackForgotten { context_id })
17083 }
17084 NodeRequest::ResolveDurableContextPack { context_id } => {
17085 let context = shared
17089 .context_catalog
17090 .read()
17091 .unwrap_or_else(|poisoned| poisoned.into_inner())
17092 .get(&context_id)
17093 .map(|pack| pack.receipt().clone())
17094 .ok_or_else(|| {
17095 failure(
17096 NodeFailureCode::UnknownContextPack,
17097 "context pack is not present",
17098 )
17099 })?;
17100 Ok(NodeResponse::DurableContextPackResolved { context })
17101 }
17102 NodeRequest::ReadContextPack { digest } => {
17103 let (id, byte_len, bytes) = shared
17108 .context_catalog
17109 .read()
17110 .unwrap_or_else(|poisoned| poisoned.into_inner())
17111 .get_by_digest(&digest)
17112 .map(|pack| {
17113 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17114 })
17115 .ok_or_else(|| {
17116 failure(
17117 NodeFailureCode::UnknownContextPack,
17118 &format!("context pack {digest} not on this node"),
17119 )
17120 })?;
17121 tracing::debug!(
17122 digest = digest.as_str(),
17123 byte_len,
17124 "read context pack bytes",
17125 );
17126 Ok(NodeResponse::ContextPackBytesRead {
17127 pack: ContextPackBytesRead {
17128 digest,
17129 id,
17130 byte_len,
17131 bytes,
17132 },
17133 })
17134 }
17135 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17136 let provider = controlled_session(shared, connection_id, role, &session)?;
17137 if let Some(prompt) = initial_prompt.as_deref() {
17138 validate_node_text("resume initial prompt", prompt)?;
17139 }
17140 let runtime_requirement = if initial_prompt.is_some() {
17141 ProviderRuntimeRequirement::ResumeWithPrompt
17142 } else {
17143 ProviderRuntimeRequirement::Resume
17144 };
17145 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17146 let runtime_policy = shared
17147 .admit_provider_runtime(&provider, runtime_requirement)
17148 .await?;
17149 let working_directory = shared.bound_workspace_root(&session)?;
17150 let request = ResumeLaunchRequest {
17151 working_directory,
17152 terminal_size,
17153 initial_prompt,
17154 };
17155 request
17156 .validate()
17157 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17158 let command = shared.prepare_command(ControlCommand::Resume {
17159 instance_id: session.session.instance_id,
17160 target: ResumeTarget::CurrentProvider,
17161 runtime_policy,
17162 request,
17163 });
17164 shared.arm_resume(&session, command.id, runtime_policy)?;
17165 let dispatch = shared.dispatch_envelope(command);
17166 if let Err(error) = dispatch {
17167 shared.clear_armed_resume(&session);
17168 return Err(error);
17169 }
17170 Ok(NodeResponse::Accepted)
17171 }
17172 NodeRequest::RenameSessionRecord {
17173 record_id,
17174 display_name,
17175 } => {
17176 shared.require_controller(connection_id, role)?;
17177 let record = shared.rename_session_record(&record_id, display_name)?;
17178 Ok(NodeResponse::SessionRecordUpdated { record })
17179 }
17180 NodeRequest::SetSessionTask {
17181 record_id,
17182 expected_revision,
17183 target,
17184 } => {
17185 shared.require_controller(connection_id, role)?;
17186 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17187 Ok(NodeResponse::SessionRecordUpdated { record })
17188 }
17189 NodeRequest::IndexProviderSession {
17190 workspace_id,
17191 provider,
17192 identity,
17193 display_name,
17194 } => {
17195 shared.require_controller(connection_id, role)?;
17196 let record = shared.index_provider_session(
17197 workspace_id,
17198 provider,
17199 identity,
17200 display_name,
17201 )?;
17202 Ok(NodeResponse::ProviderSessionIndexed { record })
17203 }
17204 NodeRequest::ResumeSessionRecord {
17205 record_id,
17206 terminal_size,
17207 initial_prompt,
17208 } => {
17209 shared.require_controller(connection_id, role)?;
17210 if let Some(prompt) = initial_prompt.as_deref() {
17211 validate_node_text("managed resume initial prompt", prompt)?;
17212 }
17213 let (record, session) = shared
17214 .resume_session_record(
17215 &record_id,
17216 terminal_size,
17217 initial_prompt,
17218 )
17219 .await?;
17220 Ok(NodeResponse::SessionRecordResumed { record, session })
17221 }
17222 NodeRequest::ForgetSessionRecord { record_id } => {
17223 shared.require_controller(connection_id, role)?;
17224 shared.forget_session_record(&record_id).await?;
17225 Ok(NodeResponse::SessionRecordForgotten { record_id })
17226 }
17227 NodeRequest::Prompt { session, text } => {
17228 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17229 validate_node_text("prompt", &text)?;
17230 shared.require_session_runtime_policy(
17231 &session,
17232 ProviderRuntimeRequirement::SemanticPrompt,
17233 )?;
17234 let framing = prompt_framing(&agent_id);
17235 shared
17236 .dispatch_input_bounded(
17237 &session,
17238 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17239 )
17240 .await?;
17241 Ok(NodeResponse::Accepted)
17242 }
17243 NodeRequest::Paste { session, text } => {
17244 controlled_session(shared, connection_id, role, &session)?;
17245 validate_node_text("paste", &text)?;
17246 shared.require_session_runtime_policy(
17247 &session,
17248 ProviderRuntimeRequirement::SemanticPrompt,
17249 )?;
17250 shared
17251 .dispatch_input_bounded(
17252 &session,
17253 InputAction::InsertDraft(PromptPayload {
17254 text,
17255 framing: PromptFraming::BracketedPaste,
17256 }),
17257 )
17258 .await?;
17259 Ok(NodeResponse::Accepted)
17260 }
17261 NodeRequest::Input { session, text } => {
17262 controlled_session(shared, connection_id, role, &session)?;
17263 validate_node_text("terminal input", &text)?;
17264 shared
17265 .dispatch_input_bounded(
17266 &session,
17267 InputAction::TerminalText(TerminalText { text }),
17268 )
17269 .await?;
17270 Ok(NodeResponse::Accepted)
17271 }
17272 NodeRequest::TerminalBytes { session, bytes } => {
17273 controlled_session(shared, connection_id, role, &session)?;
17274 shared.sessionize_spawn(session.session.instance_id);
17275 let action = terminal_bytes_action(bytes)?;
17276 shared
17277 .dispatch_input_bounded(&session, action)
17278 .await?;
17279 Ok(NodeResponse::Accepted)
17280 }
17281 NodeRequest::TerminalControl { session, control } => {
17282 controlled_session(shared, connection_id, role, &session)?;
17283 shared
17284 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17285 .await?;
17286 Ok(NodeResponse::Accepted)
17287 }
17288 NodeRequest::Resize { session, size } => {
17289 controlled_session(shared, connection_id, role, &session)?;
17290 shared.sessionize_spawn(session.session.instance_id);
17291 if !size.is_valid() {
17292 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17293 }
17294 shared.dispatch_resize_bounded(&session, size).await?;
17295 Ok(NodeResponse::Accepted)
17296 }
17297 NodeRequest::Interrupt { session } => {
17298 controlled_session(shared, connection_id, role, &session)?;
17299 shared
17300 .dispatch_input_bounded(
17301 &session,
17302 InputAction::TerminalControl(TerminalControl::Interrupt),
17303 )
17304 .await?;
17305 Ok(NodeResponse::Accepted)
17306 }
17307 NodeRequest::Stop { session, force } => {
17308 controlled_session(shared, connection_id, role, &session)?;
17309 shared.sessionize_spawn(session.session.instance_id);
17310 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17311 shared.retire_session_grok_leader(session.session.instance_id);
17312 Ok(NodeResponse::Accepted)
17313 }
17314 NodeRequest::Remove { session } => {
17315 controlled_session(shared, connection_id, role, &session)?;
17316 shared.remove_session(&session).await?;
17317 Ok(NodeResponse::Accepted)
17318 }
17319 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17320 shared.require_controller(connection_id, role)?;
17321 let snapshot = shared.require_acp_session(&session)?;
17322 let interaction = snapshot
17323 .provider
17324 .interactions
17325 .iter()
17326 .find(|interaction| {
17327 interaction_correlation(
17328 session.session.instance_id,
17329 session.session.generation,
17330 interaction.id.0,
17331 ) == correlation_id
17332 })
17333 .ok_or_else(|| failure(
17334 NodeFailureCode::InvalidRequest,
17335 "unknown interaction correlation",
17336 ))?;
17337 if interaction.status != ProviderInteractionStatus::Pending {
17338 return Err(failure(
17339 NodeFailureCode::BackendOperationFailed,
17340 "interaction has already been resolved",
17341 ));
17342 }
17343 response
17344 .validate_for(interaction.interaction_kind)
17345 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17346 let interaction_id = interaction.id;
17347 let response_kind = response.kind();
17348 let response_outcome = response.outcome();
17349 shared.dispatch(ControlCommand::ResolveInteraction {
17350 instance_id: session.session.instance_id,
17351 generation: session.session.generation,
17352 interaction_id,
17353 response,
17354 })?;
17355 tracing::info!(
17361 node_id = %shared.node_id,
17362 session = ?session.session,
17363 correlation_id = %correlation_id,
17364 interaction_id = interaction_id.0,
17365 response_kind = ?response_kind,
17366 outcome = ?response_outcome,
17367 "interaction resolved",
17368 );
17369 Ok(NodeResponse::Accepted)
17370 }
17371 NodeRequest::SetSessionMode { session, mode_id } => {
17372 shared.require_controller(connection_id, role)?;
17373 shared.require_acp_session(&session)?;
17374 shared.dispatch(ControlCommand::SetSessionMode {
17375 instance_id: session.session.instance_id,
17376 mode_id,
17377 })?;
17378 Ok(NodeResponse::Accepted)
17379 }
17380 NodeRequest::SetSessionConfigOption {
17381 session,
17382 option_id,
17383 value_json,
17384 } => {
17385 shared.require_controller(connection_id, role)?;
17386 shared.require_acp_session(&session)?;
17387 shared.dispatch(ControlCommand::SetSessionConfigOption {
17388 instance_id: session.session.instance_id,
17389 option_id,
17390 value_json,
17391 })?;
17392 Ok(NodeResponse::Accepted)
17393 }
17394 NodeRequest::SetSessionModel { session, model_id } => {
17395 shared.require_controller(connection_id, role)?;
17396 shared.require_acp_session(&session)?;
17397 shared.dispatch(ControlCommand::SetSessionModel {
17398 instance_id: session.session.instance_id,
17399 model_id,
17400 })?;
17401 Ok(NodeResponse::Accepted)
17402 }
17403 NodeRequest::Shutdown => {
17404 shared.require_controller(connection_id, role)?;
17405 shared.begin_shutdown_locked().await?;
17406 Ok(NodeResponse::ShuttingDown)
17407 }
17408 }
17409}
17410
17411fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17412 shared.require_controller(connection_id, role)?;
17413 shared.validate_address(session)
17414}
17415
17416fn select_history_candidate(
17422 candidates: &[HistoryCandidateSummary],
17423 provider_session_id: Option<&str>,
17424) -> Result<String, NodeFailure> {
17425 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17426 Some(id) => candidate.session_id_hint == id,
17427 None => true,
17428 });
17429 let candidate = matches.next().ok_or_else(|| {
17430 failure(
17431 NodeFailureCode::SessionRecordConflict,
17432 if provider_session_id.is_some() {
17433 "no history candidate matches the managed provider session"
17434 } else {
17435 "no history candidate exists for the managed session"
17436 },
17437 )
17438 })?;
17439 if matches.next().is_some() {
17440 return Err(failure(
17441 NodeFailureCode::SessionRecordConflict,
17442 if provider_session_id.is_some() {
17443 "history identity is ambiguous for the managed provider session"
17444 } else {
17445 "history is ambiguous without a verified provider session identity"
17446 },
17447 ));
17448 }
17449 Ok(candidate.id.clone())
17450}
17451
17452fn resolved_spawn_profile_summary(
17453 profile: &SpawnProfileDefaults,
17454 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17455) -> Option<SpawnProfileSummary> {
17456 let environment_profile = match profile.environment_profile_id.as_ref() {
17457 None => None,
17458 Some(profile_id) => {
17459 let binding = environment_profiles.get(profile_id)?;
17460 if binding.provider != profile.provider
17461 || binding.native_profile_id(profile.mode).is_none()
17462 {
17463 return None;
17464 }
17465 Some(ResolvedEnvironmentProfileReceipt {
17466 profile_id: binding.id.clone(),
17467 profile_revision: binding.revision.clone(),
17468 network_allowlist: None,
17469 browser_profile_id: None,
17470 })
17471 }
17472 };
17473 Some(SpawnProfileSummary {
17474 id: profile.profile_id.clone(),
17475 revision: profile.revision.clone(),
17476 environment_profile,
17477 })
17478}
17479
17480fn session_record_export_target_matches(
17499 expected: &ManagedSessionRecord,
17500 current: &ManagedSessionRecord,
17501 session: &SessionAddress,
17502 allow_clean_detachment: bool,
17503) -> bool {
17504 if current == expected && current.active_session.as_ref() == Some(session) {
17505 return current.state == ManagedSessionState::Live;
17506 }
17507 allow_clean_detachment
17508 && current.state == ManagedSessionState::Dormant
17509 && current.active_session.is_none()
17510 && current.record_id == expected.record_id
17511 && current.display_name == expected.display_name
17512 && current.provider == expected.provider
17513 && current.mode == expected.mode
17514 && current.workspace_id == expected.workspace_id
17515 && current.canonical_root == expected.canonical_root
17516 && current.provider_session == expected.provider_session
17517 && current.environment_profile == expected.environment_profile
17518 && current.bundle == expected.bundle
17519 && current.context_id == expected.context_id
17520 && current.context == expected.context
17521 && current.exported_context == expected.exported_context
17522 && current.task_binding == expected.task_binding
17523 && current.created_at_unix_ms == expected.created_at_unix_ms
17524 && current.last_error == expected.last_error
17525}
17526
17527fn session_record_context_export_binding_is_exact(
17528 expected: &ManagedSessionRecord,
17529 current: &ManagedSessionRecord,
17530 identity: Option<&ProviderSessionIdentity>,
17531 session: &SessionAddress,
17532 binding: &SessionBinding,
17533 provider: &AgentId,
17534 current_root: &str,
17535 allow_clean_detachment: bool,
17536) -> bool {
17537 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17538 && current.provider_session.as_ref() == identity
17539 && current.workspace_id == session.workspace_id
17540 && provider == ¤t.provider
17541 && binding.workspace_id == session.workspace_id
17542 && binding.generation == session.session.generation
17543 && binding.record_id.as_ref() == Some(¤t.record_id)
17544 && platform::roots_equal(
17545 current_root,
17546 windows_path_text(¤t.canonical_root),
17547 )
17548}
17549
17550fn session_record_context_export_source_is_usable(
17551 record: &ManagedSessionRecord,
17552 status: &SessionStatus,
17553 allow_clean_detachment: bool,
17554) -> bool {
17555 (record.state == ManagedSessionState::Live
17556 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17557 && context_pack_source_status_is_usable(status)
17558}
17559
17560fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17561 PromptFraming::BracketedPaste
17562}
17563
17564fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17565 if text.len() > MAX_NODE_TEXT_BYTES {
17566 return Err(failure(
17567 NodeFailureCode::InvalidRequest,
17568 &format!(
17569 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17570 text.len(),
17571 ),
17572 ));
17573 }
17574 Ok(())
17575}
17576
17577fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17578 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17579 return Err(failure(
17580 NodeFailureCode::InvalidRequest,
17581 &format!(
17582 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17583 bytes.len(),
17584 ),
17585 ));
17586 }
17587 Ok(())
17588}
17589
17590fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17591 validate_terminal_bytes(&bytes)?;
17592 Ok(InputAction::TerminalBytes(bytes))
17593}
17594
17595fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17596 if root.is_empty()
17597 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17598 || root.chars().any(char::is_control)
17599 {
17600 return Err(failure(
17601 NodeFailureCode::InvalidWorkspaceRoot,
17602 &format!(
17603 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17604 ),
17605 ));
17606 }
17607 Ok(())
17608}
17609
17610fn canonical_standalone_workspace_candidate(
17611 workspace_id: &WorkspaceId,
17612 root: &str,
17613) -> Result<String, NodeServerError> {
17614 let path = Path::new(root);
17615 match std::fs::symlink_metadata(path) {
17616 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17617 .map(|workspace| workspace.canonical_root),
17618 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17619 if !path.is_absolute() {
17620 return Err(NodeServerError::InvalidWorkspaceRoot {
17621 workspace_id: workspace_id.clone(),
17622 path: root.to_owned(),
17623 message: "path is not absolute".to_owned(),
17624 });
17625 }
17626 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17627 workspace_id: workspace_id.clone(),
17628 path: root.to_owned(),
17629 message: "path has no standalone directory name".to_owned(),
17630 })?;
17631 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17632 workspace_id: workspace_id.clone(),
17633 path: root.to_owned(),
17634 message: "path has no parent directory".to_owned(),
17635 })?;
17636 let canonical = std::fs::canonicalize(parent)
17637 .map(|parent| parent.join(name))
17638 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17639 workspace_id: workspace_id.clone(),
17640 path: root.to_owned(),
17641 message: error.to_string(),
17642 })?;
17643 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17644 NodeServerError::InvalidWorkspaceRoot {
17645 workspace_id: workspace_id.clone(),
17646 path: path.to_string_lossy().into_owned(),
17647 message: "canonical path is not valid Unicode".to_owned(),
17648 }
17649 })?;
17650 let canonical_root = platform::normalize_canonical_root(canonical_root);
17651 validate_workspace_root(workspace_id, &canonical_root)?;
17652 Ok(canonical_root)
17653 }
17654 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17655 workspace_id: workspace_id.clone(),
17656 path: root.to_owned(),
17657 message: error.to_string(),
17658 }),
17659 }
17660}
17661
17662fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17663 let Some(revision) = revision else {
17664 return Ok(());
17665 };
17666 if revision.is_empty()
17667 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17668 || revision.chars().any(char::is_control)
17669 {
17670 return Err(failure(
17671 NodeFailureCode::InvalidRequest,
17672 &format!(
17673 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17674 ),
17675 ));
17676 }
17677 Ok(())
17678}
17679
17680fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17681 let code = match error.kind {
17682 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17683 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17684 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17685 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17686 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17687 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17688 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17689 };
17690 failure(code, &error.message)
17691}
17692
17693fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17694 let code = match error.kind {
17695 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17696 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17697 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17698 NodeFailureCode::BackendOperationFailed
17699 }
17700 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17701 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17702 }
17703 };
17704 failure(code, error.message)
17705}
17706
17707fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17708 let (code, message) = match error.kind {
17709 GitWorktreeErrorKind::Invalid => (
17710 NodeFailureCode::InvalidRequest,
17711 "managed Git input is invalid",
17712 ),
17713 GitWorktreeErrorKind::NotRepository => (
17714 NodeFailureCode::NotGitRepository,
17715 "managed source is not a Git repository",
17716 ),
17717 GitWorktreeErrorKind::Conflict => (
17718 NodeFailureCode::WorktreeConflict,
17719 "managed Git identity conflicts with existing repository state",
17720 ),
17721 GitWorktreeErrorKind::Protected => (
17722 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17723 "managed worktree ownership validation failed",
17724 ),
17725 GitWorktreeErrorKind::Dirty => (
17726 NodeFailureCode::WorktreeDirty,
17727 "managed worktree contains uncommitted changes",
17728 ),
17729 GitWorktreeErrorKind::Locked => (
17730 NodeFailureCode::WorktreeLocked,
17731 "managed worktree is locked",
17732 ),
17733 GitWorktreeErrorKind::Failed => (
17734 NodeFailureCode::BackendOperationFailed,
17735 "managed Git operation failed",
17736 ),
17737 };
17738 NodeFailure { code, message: message.to_owned() }
17739}
17740
17741fn managed_spawn_receipt(
17742 resolved: &ResolvedSpawnSpec,
17743 incarnation_id: NodeIncarnationId,
17744 session: SessionAddress,
17745 lease: ManagedWorktreeLeaseSnapshot,
17746 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17747 bundle: Option<ResolvedBundleReceipt>,
17748 context: Option<ResolvedContextPackReceipt>,
17749) -> ManagedWorktreeSpawnReceipt {
17750 let mut managed = resolved.clone();
17751 managed.target.worktree_id = Some(lease.workspace_id.clone());
17752 ManagedWorktreeSpawnReceipt {
17753 spawn: managed.receipt_with_materialization(
17754 incarnation_id,
17755 session,
17756 environment_profile,
17757 bundle,
17758 context,
17759 ),
17760 lease,
17761 }
17762}
17763
17764fn validate_selected_worktree(
17765 source_root: &str,
17766 selected_root: &str,
17767 worktrees: &[NativeGitWorktreeSnapshot],
17768) -> Result<(), NodeFailure> {
17769 if !worktrees
17770 .iter()
17771 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17772 {
17773 return Err(failure(
17774 NodeFailureCode::WorktreeProtected,
17775 "source workspace is not an authoritative non-bare Git worktree root",
17776 ));
17777 }
17778 if !worktrees.iter().any(|worktree| {
17779 worktree_paths_equal(&worktree.path, selected_root)
17780 && !worktree.is_main
17781 && !worktree.is_bare
17782 }) {
17783 return Err(failure(
17784 NodeFailureCode::WorktreeProtected,
17785 "selected workspace is not an eligible Git worktree in the source repository",
17786 ));
17787 }
17788 Ok(())
17789}
17790
17791fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17792 let code = match error.kind() {
17793 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17794 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17795 NodeFailureCode::RepositoryPathUnsafe
17796 }
17797 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17798 WorkspaceFileReadErrorKind::NotRegularFile => {
17799 NodeFailureCode::RepositoryFileNotRegular
17800 }
17801 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17802 NodeFailureCode::RepositoryFileReadFailed
17803 }
17804 WorkspaceFileReadErrorKind::RevisionConflict => {
17805 NodeFailureCode::RepositoryFileRevisionConflict
17806 }
17807 WorkspaceFileReadErrorKind::AlreadyExists
17808 | WorkspaceFileReadErrorKind::ParentNotFound
17809 | WorkspaceFileReadErrorKind::ParentNotDirectory
17810 | WorkspaceFileReadErrorKind::Canceled => {
17811 NodeFailureCode::RepositoryFileReadFailed
17812 }
17813 };
17814 failure(code, "repository file read failed")
17815}
17816
17817fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17818 let code = match error.kind() {
17819 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17820 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17821 NodeFailureCode::RepositoryPathUnsafe
17822 }
17823 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17824 WorkspaceFileReadErrorKind::NotRegularFile => {
17825 NodeFailureCode::RepositoryFileNotRegular
17826 }
17827 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17828 NodeFailureCode::RepositoryFileWriteFailed
17829 }
17830 WorkspaceFileReadErrorKind::RevisionConflict => {
17831 NodeFailureCode::RepositoryFileRevisionConflict
17832 }
17833 WorkspaceFileReadErrorKind::AlreadyExists
17834 | WorkspaceFileReadErrorKind::ParentNotFound
17835 | WorkspaceFileReadErrorKind::ParentNotDirectory
17836 | WorkspaceFileReadErrorKind::Canceled => {
17837 NodeFailureCode::RepositoryFileWriteFailed
17838 }
17839 };
17840 failure(code, "repository file write failed")
17841}
17842
17843async fn settle_workspace_entry_create<T>(
17844 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17845 commit_state: Arc<AtomicU8>,
17846 deadline: Duration,
17847 timeout_message: &'static str,
17848 task_failure_message: &'static str,
17849) -> Result<T, NodeFailure> {
17850 let joined = match timeout(deadline, &mut task).await {
17851 Ok(joined) => joined,
17852 Err(_) => {
17853 if commit_state
17854 .compare_exchange(
17855 WORKSPACE_ENTRY_CREATE_PENDING,
17856 WORKSPACE_ENTRY_CREATE_CANCELED,
17857 Ordering::AcqRel,
17858 Ordering::Acquire,
17859 )
17860 .is_ok()
17861 {
17862 return Err(failure(
17863 NodeFailureCode::RepositoryEntryCreateTimedOut,
17864 timeout_message,
17865 ));
17866 }
17867 debug_assert_eq!(
17868 commit_state.load(Ordering::Acquire),
17869 WORKSPACE_ENTRY_CREATE_COMMITTING,
17870 );
17871 task.await
17872 }
17873 };
17874 joined
17875 .map_err(|_| {
17876 failure(
17877 NodeFailureCode::RepositoryEntryCreateFailed,
17878 task_failure_message,
17879 )
17880 })?
17881 .map_err(workspace_entry_create_failure)
17882}
17883
17884fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17885 let code = match error.kind() {
17886 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17887 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17888 NodeFailureCode::RepositoryPathUnsafe
17889 }
17890 WorkspaceFileReadErrorKind::AlreadyExists => {
17891 NodeFailureCode::RepositoryEntryAlreadyExists
17892 }
17893 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17894 NodeFailureCode::RepositoryParentNotFound
17895 }
17896 WorkspaceFileReadErrorKind::ParentNotDirectory
17897 | WorkspaceFileReadErrorKind::NotRegularFile => {
17898 NodeFailureCode::RepositoryParentNotDirectory
17899 }
17900 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17901 NodeFailureCode::RepositoryEntryCreateFailed
17902 }
17903 WorkspaceFileReadErrorKind::RevisionConflict => {
17904 NodeFailureCode::RepositoryEntryCreateFailed
17905 }
17906 WorkspaceFileReadErrorKind::Canceled => {
17907 NodeFailureCode::RepositoryEntryCreateTimedOut
17908 }
17909 };
17910 failure(code, "repository entry create failed")
17911}
17912
17913fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17914 let value = digest(&SHA256, bytes)
17915 .as_ref()
17916 .iter()
17917 .map(|byte| format!("{byte:02x}"))
17918 .collect();
17919 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17920}
17921
17922fn managed_spawn_request_digest_v2(
17923 request: &ManagedWorktreeSpawnRequestV2,
17924) -> Result<String, NodeFailure> {
17925 let encoded = serde_json::to_vec(request).map_err(|_| {
17926 failure(
17927 NodeFailureCode::InvalidRequest,
17928 "managed worktree V2 request could not be canonicalized",
17929 )
17930 })?;
17931 let mut value = String::with_capacity(71);
17932 value.push_str("sha256:");
17933 for byte in digest(&SHA256, &encoded).as_ref() {
17934 use std::fmt::Write as _;
17935 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17936 }
17937 Ok(value)
17938}
17939
17940fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17941 let code = match error {
17942 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17943 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17944 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17945 NativeSessionPreviewError::UnsupportedProvider
17946 | NativeSessionPreviewError::PreviewUnavailable
17947 | NativeSessionPreviewError::SessionNotFound
17948 | NativeSessionPreviewError::AmbiguousSession => {
17949 NodeFailureCode::BackendOperationFailed
17950 }
17951 };
17952 failure(code, "native session preview failed")
17953}
17954
17955fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17956 let code = match error.kind() {
17957 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17958 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17959 };
17960 failure(code, "host directory browse failed")
17961}
17962
17963fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17964 let code = match error {
17965 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17966 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17967 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17968 NodeFailureCode::DeliveryStageConflict
17969 }
17970 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17971 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17972 NodeFailureCode::DeliveryChunkOutOfOrder
17973 }
17974 DeliveryStoreError::BlobDigestMismatch => {
17975 NodeFailureCode::DeliveryBlobDigestMismatch
17976 }
17977 DeliveryStoreError::BundleDigestMismatch => {
17978 NodeFailureCode::DeliveryBundleDigestMismatch
17979 }
17980 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17981 DeliveryStoreError::Unavailable
17982 | DeliveryStoreError::Corrupt
17983 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17984 };
17985 failure(code, "delivery operation failed")
17986}
17987
17988fn validate_workspace_root(
17989 workspace_id: &WorkspaceId,
17990 canonical_root: &str,
17991) -> Result<(), NodeServerError> {
17992 #[cfg(windows)]
17993 if canonical_root.starts_with(r"\\") {
17994 return Err(NodeServerError::InvalidWorkspaceRoot {
17995 workspace_id: workspace_id.clone(),
17996 path: canonical_root.to_owned(),
17997 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17998 });
17999 }
18000 if !platform::workspace_root_supported(canonical_root)
18001 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18002 {
18003 return Err(NodeServerError::InvalidWorkspaceRoot {
18004 workspace_id: workspace_id.clone(),
18005 path: canonical_root.to_owned(),
18006 message: "canonical path must be absolute, bounded, and free of control characters"
18007 .to_owned(),
18008 });
18009 }
18010 Ok(())
18011}
18012
18013fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18014 NodeFailure {
18015 code,
18016 message: node_failure_category(code).to_owned(),
18017 }
18018}
18019
18020fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18021 let code = match error {
18022 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18023 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18024 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18025 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18026 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18027 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18028 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18029 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18030 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18031 };
18032 failure(code, "harness MCP proxy operation failed")
18033}
18034
18035static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18039
18040fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18047 let trace = launch.trace.as_ref()?;
18048 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18049 if trace_dir.is_empty() {
18050 return None;
18051 }
18052 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18053 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18054 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18055}
18056
18057fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18058 let remaining = deadline.saturating_duration_since(Instant::now());
18059 if remaining.is_zero() {
18060 Err(failure(
18061 NodeFailureCode::SpawnDeadlineExceeded,
18062 "spawn deadline elapsed",
18063 ))
18064 } else {
18065 Ok(remaining)
18066 }
18067}
18068
18069fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18070 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18071 match deadline {
18072 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18073 None => Ok(bounded),
18074 }
18075}
18076
18077fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18078 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18079 failure(
18080 NodeFailureCode::SpawnDeadlineExceeded,
18081 "spawn dispatch exceeded the spawn deadline",
18082 )
18083 } else {
18084 error
18085 }
18086}
18087
18088fn spawn_runtime_capabilities(
18089 capabilities: &SpawnRequiredCapabilities,
18090) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18091 capabilities
18092 .iter()
18093 .map(|capability| match capability.as_str() {
18094 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18095 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18096 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18097 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18098 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18099 }
18100 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18101 _ => Err(failure(
18102 NodeFailureCode::UnsupportedSpawnCapability,
18103 "spawn required capability is unknown",
18104 )),
18105 })
18106 .collect()
18107}
18108
18109fn persistence_failure(_error: io::Error) -> NodeFailure {
18110 NodeFailure {
18111 code: NodeFailureCode::BackendOperationFailed,
18112 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18113 }
18114}
18115
18116fn node_failure_category(code: NodeFailureCode) -> &'static str {
18117 match code {
18118 NodeFailureCode::InvalidRequest => "invalid-request",
18119 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18120 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18121 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18122 NodeFailureCode::ReservationConflict => "reservation-conflict",
18123 NodeFailureCode::ReservationExpired => "reservation-expired",
18124 NodeFailureCode::BindingMismatch => "binding-mismatch",
18125 NodeFailureCode::NotActivated => "not-activated",
18126 NodeFailureCode::CallNotFound => "call-not-found",
18127 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18128 NodeFailureCode::ResponseTooLarge => "response-too-large",
18129 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18130 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18131 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18132 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18133 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18134 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18135 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18136 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18137 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18138 NodeFailureCode::Unauthorized => "unauthorized",
18139 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18140 NodeFailureCode::ControllerBusy => "controller-busy",
18141 NodeFailureCode::ControllerRequired => "controller-required",
18142 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18143 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18144 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18145 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18146 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18147 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18148 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18149 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18150 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18151 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18152 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18153 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18154 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18155 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18156 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18157 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18158 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18159 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18160 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18161 NodeFailureCode::GitReadFailed => "git-read-failed",
18162 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18163 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18164 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18165 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18166 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18167 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18168 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18169 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18170 NodeFailureCode::TurnInFlight => "turn-in-flight",
18171 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18172 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18173 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18174 "unsupported-network-allowlist-mapping"
18175 }
18176 NodeFailureCode::BrowserStationProbeUnavailable => {
18177 "browser-station-probe-unavailable"
18178 }
18179 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18180 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18181 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18182 "environment-profile-binding-mismatch"
18183 }
18184 NodeFailureCode::UnknownBundle => "unknown-bundle",
18185 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18186 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18187 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18188 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18189 NodeFailureCode::ContextPackMaterializationFailed => {
18190 "context-pack-materialization-failed"
18191 }
18192 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18193 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18194 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18195 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18196 NodeFailureCode::LastWorkspace => "last-workspace",
18197 NodeFailureCode::NotGitRepository => "not-git-repository",
18198 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18199 NodeFailureCode::WorktreeProtected => "worktree-protected",
18200 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18201 NodeFailureCode::WorktreeLocked => "worktree-locked",
18202 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18203 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18204 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18205 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18206 "managed-worktree-profile-revision-mismatch"
18207 }
18208 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18209 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18210 "standalone-workspace-recovery-required"
18211 }
18212 NodeFailureCode::UnknownSession => "unknown-session",
18213 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18214 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18215 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18216 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18217 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18218 NodeFailureCode::WorkspaceRegistrationRequired => {
18219 "workspace-registration-required"
18220 }
18221 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18222 NodeFailureCode::StaleGeneration => "stale-generation",
18223 NodeFailureCode::BackendBusy => "backend-busy",
18224 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18225 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18226 NodeFailureCode::ShuttingDown => "shutting-down",
18227 }
18228}
18229
18230fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18231 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18232}
18233
18234fn durable_state_load_error(error: io::Error) -> NodeServerError {
18235 let category = match session_registry::state_load_refusal(&error) {
18236 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18237 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18238 }
18239 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18240 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18241 }
18242 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18243 DURABLE_STATE_CONFLICT_ERROR
18244 }
18245 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18246 };
18247 durable_state_server_error(error, category)
18248}
18249
18250fn unix_time_ms() -> u64 {
18251 std::time::SystemTime::now()
18252 .duration_since(std::time::UNIX_EPOCH)
18253 .unwrap_or_default()
18254 .as_millis()
18255 .min(u64::MAX as u128) as u64
18256}
18257
18258fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18259 if record.provider_session.is_some() {
18260 ManagedSessionState::Dormant
18261 } else {
18262 ManagedSessionState::Unavailable
18263 }
18264}
18265
18266fn active_record_state(
18267 identity_admitted: bool,
18268 provider_session_is_known: bool,
18269) -> ManagedSessionState {
18270 if identity_admitted && !provider_session_is_known {
18271 ManagedSessionState::IdentityPending
18272 } else {
18273 ManagedSessionState::Live
18274 }
18275}
18276
18277fn managed_worktree_record_holders(
18278 records: &[ManagedSessionRecord],
18279) -> Vec<(SessionRecordId, WorkspaceId)> {
18280 records
18281 .iter()
18282 .filter(|record| record.provider_session.is_some())
18283 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18284 .collect()
18285}
18286
18287fn managed_spawn_record_ownership(
18288 binding: &SessionBinding,
18289 effective_runtime_policy: ProviderRuntimePolicy,
18290) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18291 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18292 if effective_runtime_policy.provider_session_identity {
18293 (binding.record_id.clone(), None)
18294 } else {
18295 (None, binding.record_id.clone())
18296 }
18297}
18298
18299#[derive(Debug, Error)]
18300pub enum NodeServerError {
18301 #[error("node endpoint must be a bounded absolute local transport path")]
18302 InvalidEndpoint,
18303 #[error("node access token must contain 1..=4096 bytes")]
18304 InvalidAccessToken,
18305 #[error("node HTTP observer must listen on a loopback address: {0}")]
18306 InvalidApiListen(std::net::SocketAddr),
18307 #[error("node browser bridge must listen on a loopback address: {0}")]
18308 InvalidBridgeListen(std::net::SocketAddr),
18309 #[error("node browser bridge token must contain 1..=4096 bytes")]
18310 InvalidBridgeToken,
18311 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18312 BridgeUnderlayRequiresBridgeListen,
18313 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18314 BridgeUnderlayRequiresBridgeToken,
18315 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18316 InvalidBridgeUnderlayToken,
18317 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18318 InvalidBridgeUnderlayKey,
18319 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18320 InvalidCallHome(std::net::SocketAddr),
18321 #[error("node could not reach its call-home relay: {0}")]
18322 CallHomeTransport(#[source] std::io::Error),
18323 #[error("node durable state path must be an absolute file path: {0}")]
18324 InvalidStatePath(String),
18325 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18326 InvalidHarnessMcpHelper,
18327 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18328 InvalidNetworkAllowlistCatalogPath(String),
18329 #[error("network allowlist catalog could not be read: {0}")]
18330 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18331 #[error("network allowlist catalog line {line}: {message}")]
18332 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18333 #[error("network allowlist catalog: {0}")]
18334 InvalidNetworkAllowlistCatalog(String),
18335 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18336 NetworkAllowlistCatalogCapacity { max: usize },
18337 #[error("the local state directory is unavailable; supply an explicit state path")]
18338 LocalStateDirectoryUnavailable,
18339 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18340 LocalRuntimeDirectoryUnavailable,
18341 #[error("node durable state failed: {0}")]
18342 DurableState(io::Error),
18343 #[error("node requires at least one configured workspace")]
18344 NoWorkspaces,
18345 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18346 InvalidWorkspaceRoot {
18347 workspace_id: WorkspaceId,
18348 path: String,
18349 message: String,
18350 },
18351 #[error("duplicate workspace ID: {0}")]
18352 DuplicateWorkspaceId(WorkspaceId),
18353 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18354 DuplicateWorkspaceRoot {
18355 first: WorkspaceId,
18356 second: WorkspaceId,
18357 root: String,
18358 },
18359 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18360 ManagedWorktreeProfileRequired(WorkspaceId),
18361 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18362 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18363 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18364 DuplicateManagedWorktreeProfile {
18365 workspace_id: WorkspaceId,
18366 profile_id: WorktreeProfileId,
18367 },
18368 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18369 ManagedWorktreeProfileCapacity {
18370 workspace_id: WorkspaceId,
18371 max: usize,
18372 },
18373 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18374 InvalidManagedWorktreeProfile {
18375 workspace_id: WorkspaceId,
18376 profile_id: WorktreeProfileId,
18377 message: String,
18378 },
18379 #[error("duplicate node environment profile: {0}")]
18380 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18381 #[error("node environment profile provider is unavailable: {0}")]
18382 EnvironmentProfileProviderUnavailable(AgentId),
18383 #[error("node environment profile capacity is {max}")]
18384 EnvironmentProfileCapacity { max: usize },
18385 #[error("node environment profiles reuse native profile ID '{0}'")]
18386 DuplicateNativeEnvironmentProfile(String),
18387 #[error("node native environment profile is invalid: {0}")]
18388 NativeEnvironmentProfile(String),
18389 #[error("node session-environment materialization root must be absolute")]
18390 InvalidSessionEnvironmentRoot,
18391 #[error("node session-environment materializer is unavailable")]
18392 SessionEnvironmentMaterializerRequired,
18393 #[error("node session-environment materializer failed to initialize")]
18394 SessionEnvironmentMaterializer,
18395 #[error("node bundle catalog is invalid: {0}")]
18396 BundleCatalog(String),
18397 #[error("node delivery store failed to initialize: {0}")]
18398 DeliveryStore(String),
18399 #[error("node context pack store failed to initialize")]
18400 ContextPackStore,
18401 #[error("active agent registry failed: {0}")]
18402 Registry(String),
18403 #[error("node provider contract manifest is invalid: {0}")]
18404 ProviderContractManifest(String),
18405 #[error("named pipe I/O failed: {0}")]
18406 Io(#[from] io::Error),
18407 #[error("node HTTP observer failed: {0}")]
18408 HttpApi(io::Error),
18409 #[error("node browser bridge failed: {0}")]
18410 BridgeApi(io::Error),
18411 #[error(transparent)]
18412 Frame(#[from] FrameError),
18413 #[error("node handshake failed: {0}")]
18414 Handshake(String),
18415 #[error("node authentication frame was not received before the bounded deadline")]
18416 AuthenticationTimedOut,
18417 #[error("node authentication primitive failed: {0}")]
18418 Authentication(String),
18419 #[error("node incarnation identity generation failed: {0}")]
18420 IncarnationIdentity(String),
18421 #[error("node pre-authentication limiter is closed")]
18422 PreauthClosed,
18423 #[error("node authenticated connection limit was reached")]
18424 AuthenticatedConnectionLimit,
18425 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18426 SignalStreamClosed,
18427 #[error("node stop-all dispatch failed: {0:?}")]
18428 ShutdownDispatch(NodeFailure),
18429 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18430 ShutdownTimedOut { active_native_sessions: usize },
18431}
18432
18433impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18434 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18435 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18436 match error {
18437 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18438 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18439 E::InvalidEntry { line, message } => {
18440 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18441 }
18442 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18443 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18444 }
18445 }
18446}
18447
18448#[cfg(test)]
18449mod agent_stream_blocked_tests {
18450 use super::*;
18451 use gate4agent_types::{
18452 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18453 ProviderSource,
18454 };
18455
18456 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18457 ProviderSource {
18458 family,
18459 binding: AdapterBinding::new(
18460 AdapterId::new(adapter).unwrap(),
18461 "fixture/v1",
18462 AdapterVerification::SyntheticFixture,
18463 )
18464 .unwrap(),
18465 }
18466 }
18467
18468 fn provider_control_event_at(
18469 family: AdapterFamily,
18470 adapter: &str,
18471 source_sequence: u64,
18472 provider_event: ProviderEvent,
18473 ) -> ControlEvent {
18474 ControlEvent {
18475 sequence: 41,
18476 command_id: None,
18477 instance_id: AgentInstanceId(7),
18478 generation: SessionGeneration(3),
18479 event: ControlEventKind::ProviderEvent {
18480 sequence: 8,
18481 source: provider_source(family, adapter),
18482 source_sequence,
18483 event: provider_event,
18484 },
18485 }
18486 }
18487
18488 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18489 provider_control_event_at(family, "fixture", 9, provider_event)
18490 }
18491
18492 fn denied_host_request(
18493 by: ProviderHostDecisionAuthority,
18494 reason: Option<String>,
18495 ) -> ControlEvent {
18496 provider_control_event_at(
18497 AdapterFamily::Acp,
18498 "claude-code",
18499 2,
18500 ProviderEvent::HostRequestObserved {
18501 method: "terminal/create".to_owned(),
18502 params_json: String::new(),
18503 decision: ProviderHostRequestDecision::Denied { by },
18504 outcome: ProviderHostRequestOutcome::Executed,
18505 reason,
18506 },
18507 )
18508 }
18509
18510 fn blocked(
18511 event: &ControlEvent,
18512 ) -> (
18513 Option<String>,
18514 String,
18515 BlockAuthorityV1,
18516 Option<String>,
18517 String,
18518 Option<String>,
18519 ) {
18520 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18521 chunk.validate().expect("minted chunk must validate");
18522 let AgentStreamChunkKindV1::Blocked {
18523 correlation_id,
18524 tool_class,
18525 authority,
18526 reason_kind,
18527 reason,
18528 help,
18529 } = chunk.kind
18530 else {
18531 panic!("expected AgentStreamChunkKindV1::Blocked");
18532 };
18533 (correlation_id, tool_class, authority, reason_kind, reason, help)
18534 }
18535
18536 #[test]
18540 fn the_enabled_provider_matrix_is_exact() {
18541 let registry = active_registry().unwrap();
18542 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18543 assert_eq!(
18544 providers
18545 .iter()
18546 .map(|contract| contract.provider.as_str())
18547 .collect::<Vec<_>>(),
18548 vec!["shell", "claude", "codex", "grok", "kimi"],
18550 );
18551 assert!(adapters.iter().all(|contract| {
18552 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18553 }));
18554 }
18555
18556 #[test]
18559 fn gate_denied_host_request_mints_a_blocked_chunk() {
18560 let gate_text =
18561 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18562 let event = denied_host_request(
18563 ProviderHostDecisionAuthority::Gate,
18564 Some(gate_text.to_owned()),
18565 );
18566 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18567 assert_eq!(chunk.source_sequence, 2);
18568 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18569 assert_eq!(authority, BlockAuthorityV1::HostGate);
18570 assert_eq!(reason, gate_text);
18571 assert_eq!(correlation_id, None);
18572 assert_eq!(reason_kind, None);
18573 assert_eq!(help, None);
18574 assert_eq!(tool_class, "Write");
18575 }
18576
18577 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.";
18578
18579 #[test]
18585 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18586 let event = provider_control_event(
18587 AdapterFamily::Acp,
18588 ProviderEvent::ToolCompleted {
18589 id: "toolu_9".to_owned(),
18590 output: CLASSIFIER_SAMPLE.to_owned(),
18591 is_error: true,
18592 duration_ms: None,
18593 agent_id: None,
18594 non_execution_kind: Some("permission-rule".to_owned()),
18595 },
18596 );
18597 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18598 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18599 assert_eq!(reason, "Blocked by classifier");
18600 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18601 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18602 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18603 }
18604
18605 #[test]
18610 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18611 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.";
18612 let event = provider_control_event(
18613 AdapterFamily::Acp,
18614 ProviderEvent::SessionEnded {
18615 result: "JSON-RPC error -32603: Internal error".to_owned(),
18616 cost_usd: None,
18617 is_error: true,
18618 stop_reason: Some(ProviderStopReason::ProviderError {
18619 code: -32603,
18620 message: message.to_owned(),
18621 vendor_code: Some("usageLimitExceeded".to_owned()),
18622 }),
18623 },
18624 );
18625 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18626 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18627 assert_eq!(reason, message);
18628 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18629 assert_eq!(tool_class, "Session");
18630 }
18631
18632 #[test]
18635 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18636 let event = provider_control_event(
18637 AdapterFamily::Acp,
18638 ProviderEvent::SessionEnded {
18639 result: "refusal".to_owned(),
18640 cost_usd: None,
18641 is_error: true,
18642 stop_reason: Some(ProviderStopReason::Refusal),
18643 },
18644 );
18645 let (_, _, authority, _, _, _) = blocked(&event);
18646 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18647 }
18648
18649 #[test]
18653 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18654 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18655 let event = provider_control_event(
18656 AdapterFamily::Acp,
18657 ProviderEvent::ToolCompleted {
18658 id: "toolu_1".to_owned(),
18659 output: "command exited with status 1".to_owned(),
18660 is_error: true,
18661 duration_ms: Some(12),
18662 agent_id: None,
18663 non_execution_kind,
18664 },
18665 );
18666 assert!(agent_stream_chunk(&event).is_none());
18667 }
18668 let narrative = provider_control_event(
18669 AdapterFamily::Acp,
18670 ProviderEvent::ToolCompleted {
18671 id: "toolu_2".to_owned(),
18672 output: "the harness blocked the write, approval needed".to_owned(),
18673 is_error: false,
18674 duration_ms: Some(3),
18675 agent_id: None,
18676 non_execution_kind: None,
18677 },
18678 );
18679 assert!(agent_stream_chunk(&narrative).is_none());
18680 }
18681
18682 #[test]
18686 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18687 for (decision, outcome) in [
18688 (
18689 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18690 ProviderHostRequestOutcome::Executed,
18691 ),
18692 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18693 (
18694 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18695 ProviderHostRequestOutcome::Failed {
18696 error: "terminal/create spawn failed: os error 3".to_owned(),
18697 },
18698 ),
18699 ] {
18700 let event = provider_control_event_at(
18701 AdapterFamily::Acp,
18702 "claude-code",
18703 3,
18704 ProviderEvent::HostRequestObserved {
18705 method: "fs/read_text_file".to_owned(),
18706 params_json: String::new(),
18707 decision: decision.clone(),
18708 outcome,
18709 reason: None,
18710 },
18711 );
18712 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18713 }
18714 }
18715
18716 #[test]
18720 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18721 for (by, expected_authority, expected_reason) in [
18722 (
18723 ProviderHostDecisionAuthority::Operator,
18724 BlockAuthorityV1::Operator,
18725 "declined by the operator",
18726 ),
18727 (
18728 ProviderHostDecisionAuthority::DeadlinePolicy,
18729 BlockAuthorityV1::HostDeadline,
18730 "denied by host policy after the operator deadline expired",
18731 ),
18732 ] {
18733 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18734 assert_eq!(authority, expected_authority);
18735 assert_eq!(reason, expected_reason);
18736 }
18737 }
18738
18739 #[test]
18743 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18744 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18745 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18746 ProviderHostDecisionAuthority::Policy,
18747 Some(oversized),
18748 ));
18749 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18750 }
18751}
18752
18753#[cfg(test)]
18754mod standalone_capability_platform_tests {
18755 use super::*;
18756
18757 #[test]
18758 fn standalone_workspace_capability_requires_windows_identity_guards() {
18759 let capabilities = baseline_capabilities().unwrap();
18760 let advertised = capabilities.iter().any(|capability| {
18761 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18762 });
18763 assert_eq!(advertised, cfg!(windows));
18764
18765 let request = NodeRequest::CreateStandaloneWorkspace {
18766 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18767 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18768 initial_branch: None,
18769 };
18770 assert_eq!(
18771 request_uses_unnegotiated_capability(&request, &capabilities),
18772 !cfg!(windows),
18773 );
18774 }
18775}
18776
18777
18778#[cfg(test)]
18779mod station_knobs_resolve_tests {
18780 use super::*;
18781 use gate4agent_catalog::EnvMutation;
18782 use gate4agent_runtime_native::{
18783 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18784 NativeLaunchProfileId,
18785 };
18786 use std::ffi::OsString;
18787 use std::sync::Arc;
18788
18789 struct EmptyEnvironmentResolver;
18790
18791 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18792 fn resolve_child_environment(
18793 &self,
18794 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18795 Ok(vec![EnvMutation {
18796 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18797 value: None,
18798 }])
18799 }
18800 }
18801
18802 fn agent(value: &str) -> AgentId {
18803 AgentId::new(value).unwrap()
18804 }
18805
18806 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18807 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18808 let profile_revision =
18809 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18810 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18811 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18812 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18813 provider: agent("claude"),
18814 mode: SessionMode::Pty,
18815 terminal_size: gate4agent_types::TerminalSize {
18816 rows: 24,
18817 columns: 80,
18818 },
18819 prompt: None,
18820 bundle_id: None,
18821 context_id: None,
18822 environment_profile_id: Some(profile_id.clone()),
18823 };
18824 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18825 let catalog = active_registry().unwrap();
18826 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18827 let control = runtime.native_launch_profile_control();
18828 let node_profile = NodeEnvironmentProfile::new(
18829 profile_id.clone(),
18830 profile_revision,
18831 agent("claude"),
18832 [NativeLaunchProfile::new(
18833 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18834 agent("claude"),
18835 TransportKind::Pty,
18836 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18837 Arc::new(EmptyEnvironmentResolver),
18838 )
18839 .unwrap()],
18840 )
18841 .unwrap();
18842 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18843 for native_profile in native_profiles {
18844 runtime.upsert_native_launch_profile(native_profile).unwrap();
18845 }
18846 let workspace = WorkspaceConfig::new(
18847 WorkspaceId::new("primary").unwrap(),
18848 std::env::current_dir().unwrap(),
18849 )
18850 .unwrap();
18851 let shared = NodeShared::new_with_incarnation(
18852 handle,
18853 "fixture-token".to_owned(),
18854 NodeId::new("node-station-knobs-test").unwrap(),
18855 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18856 vec![workspace],
18857 vec![agent("claude")],
18858 ProviderRuntimeStatuses::default(),
18859 None,
18860 Vec::new(),
18861 Vec::new(),
18862 spawn_profiles,
18863 Some(control),
18864 None,
18865 None,
18866 None,
18867 Vec::new(),
18868 Vec::new(),
18869 Vec::new(),
18870 Vec::new(),
18871 None,
18872 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18873 );
18874 shared
18875 .environment_profiles
18876 .write()
18877 .unwrap_or_else(|poisoned| poisoned.into_inner())
18878 .insert(binding.id.clone(), binding);
18879 (shared, spawn_defaults)
18880 }
18881
18882 #[test]
18885 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18886 let (shared, defaults) = station_knobs_fixture();
18887 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18888 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18889 let spec = crate::protocol::SpawnSpec {
18890 target: crate::protocol::SpawnTarget {
18891 node_id: shared.node_id.clone(),
18892 workspace_id: WorkspaceId::new("primary").unwrap(),
18893 worktree_id: None,
18894 },
18895 profile_id: defaults.profile_id.clone(),
18896 expected_profile_revision: defaults.revision.clone(),
18897 overrides: crate::protocol::SpawnOverrides {
18898 network_allowlist: Some(unknown),
18899 browser_profile_id: Some(browser),
18900 ..crate::protocol::SpawnOverrides::default()
18901 },
18902 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18903 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18904 "station-knobs-unknown-allowlist",
18905 )
18906 .unwrap(),
18907 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18908 };
18909 let resolved = spec.resolve(&defaults).unwrap();
18910 let failure = shared
18911 .resolve_environment_profile(&resolved)
18912 .expect_err("unknown allowlist must refuse");
18913 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18914 }
18915
18916 #[test]
18922 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18923 let (shared, defaults) = station_knobs_fixture();
18924 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18925 #[cfg(not(feature = "dig2-station-probe"))]
18926 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18927 shared
18928 .network_allowlist_catalog
18929 .write()
18930 .unwrap_or_else(|poisoned| poisoned.into_inner())
18931 .insert_id(allowlist.clone())
18932 .unwrap();
18933 let spec = crate::protocol::SpawnSpec {
18934 target: crate::protocol::SpawnTarget {
18935 node_id: shared.node_id.clone(),
18936 workspace_id: WorkspaceId::new("primary").unwrap(),
18937 worktree_id: None,
18938 },
18939 profile_id: defaults.profile_id.clone(),
18940 expected_profile_revision: defaults.revision.clone(),
18941 overrides: crate::protocol::SpawnOverrides {
18942 network_allowlist: Some(allowlist.clone()),
18943 #[cfg(not(feature = "dig2-station-probe"))]
18944 browser_profile_id: Some(browser.clone()),
18945 ..crate::protocol::SpawnOverrides::default()
18946 },
18947 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18948 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18949 .unwrap(),
18950 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18951 };
18952 let resolved = spec.resolve(&defaults).unwrap();
18953 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18954 #[cfg(not(feature = "dig2-station-probe"))]
18955 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18956 let receipt = shared
18957 .resolve_environment_profile(&resolved)
18958 .expect("resolve ok")
18959 .expect("env profile present");
18960 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18961 #[cfg(not(feature = "dig2-station-probe"))]
18962 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18963 #[cfg(feature = "dig2-station-probe")]
18964 assert_eq!(receipt.browser_profile_id, None);
18965 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18966
18967 let omitted = crate::protocol::SpawnSpec {
18968 overrides: crate::protocol::SpawnOverrides::default(),
18969 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18970 .unwrap(),
18971 ..spec
18972 };
18973 let resolved_omit = omitted.resolve(&defaults).unwrap();
18974 let bare = shared
18975 .resolve_environment_profile(&resolved_omit)
18976 .expect("resolve ok")
18977 .expect("env profile present");
18978 assert_eq!(bare.network_allowlist, None);
18979 assert_eq!(bare.browser_profile_id, None);
18980 }
18981
18982 #[cfg(feature = "dig2-station-probe")]
18987 #[test]
18988 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18989 let (shared, defaults) = station_knobs_fixture();
18990 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18991 let spec = crate::protocol::SpawnSpec {
18992 target: crate::protocol::SpawnTarget {
18993 node_id: shared.node_id.clone(),
18994 workspace_id: WorkspaceId::new("primary").unwrap(),
18995 worktree_id: None,
18996 },
18997 profile_id: defaults.profile_id.clone(),
18998 expected_profile_revision: defaults.revision.clone(),
18999 overrides: crate::protocol::SpawnOverrides {
19000 browser_profile_id: Some(browser),
19001 ..crate::protocol::SpawnOverrides::default()
19002 },
19003 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19004 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19005 "station-knobs-probe-refuse",
19006 )
19007 .unwrap(),
19008 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19009 };
19010 let resolved = spec.resolve(&defaults).unwrap();
19011 let failure = shared
19012 .resolve_environment_profile(&resolved)
19013 .expect_err("browser_profile with failing probe must refuse");
19014 #[cfg(windows)]
19015 {
19016 assert!(
19017 matches!(
19018 failure.code,
19019 NodeFailureCode::BrowserStationUnreachable
19020 | NodeFailureCode::BrowserStationProbeUnavailable
19021 ),
19022 "unexpected code {:?}",
19023 failure.code
19024 );
19025 }
19026 #[cfg(not(windows))]
19027 {
19028 assert_eq!(
19029 failure.code,
19030 NodeFailureCode::BrowserStationProbeUnavailable
19031 );
19032 }
19033 }
19034
19035 #[cfg(feature = "dig2-station-probe")]
19037 #[test]
19038 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19039 let (shared, defaults) = station_knobs_fixture();
19040 let spec = crate::protocol::SpawnSpec {
19041 target: crate::protocol::SpawnTarget {
19042 node_id: shared.node_id.clone(),
19043 workspace_id: WorkspaceId::new("primary").unwrap(),
19044 worktree_id: None,
19045 },
19046 profile_id: defaults.profile_id.clone(),
19047 expected_profile_revision: defaults.revision.clone(),
19048 overrides: crate::protocol::SpawnOverrides::default(),
19049 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19050 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19051 "station-knobs-probe-omit",
19052 )
19053 .unwrap(),
19054 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19055 };
19056 let resolved = spec.resolve(&defaults).unwrap();
19057 let receipt = shared
19058 .resolve_environment_profile(&resolved)
19059 .expect("omit browser_profile must not probe")
19060 .expect("env profile present");
19061 assert_eq!(receipt.browser_profile_id, None);
19062 }
19063
19064 #[cfg(feature = "dig2-station-probe")]
19068 #[test]
19069 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19070 let (shared, _defaults) = station_knobs_fixture();
19071 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19072 let workspace_id = WorkspaceId::new("primary").unwrap();
19073 let address_a = SessionAddress {
19074 workspace_id: workspace_id.clone(),
19075 session: SessionKey {
19076 instance_id: AgentInstanceId(101),
19077 generation: SessionGeneration(1),
19078 },
19079 };
19080 let address_b = SessionAddress {
19081 workspace_id: workspace_id.clone(),
19082 session: SessionKey {
19083 instance_id: AgentInstanceId(102),
19084 generation: SessionGeneration(1),
19085 },
19086 };
19087 let guard_a = crate::dig2_station_lease::acquire_lease(
19088 &shared.browser_station_leases,
19089 profile.clone(),
19090 address_a.session.instance_id,
19091 1,
19092 )
19093 .expect("first lease");
19094 let busy = crate::dig2_station_lease::acquire_lease(
19095 &shared.browser_station_leases,
19096 profile.clone(),
19097 address_b.session.instance_id,
19098 2,
19099 )
19100 .expect_err("second holder must refuse");
19101 assert!(matches!(
19102 busy,
19103 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19104 ));
19105 shared.bind_session_with_policy(
19107 &address_a,
19108 ProviderRuntimePolicy::raw_pty(),
19109 None,
19110 );
19111 guard_a.retain();
19112 assert!(shared.remove_binding(&address_a).is_some());
19113 crate::dig2_station_lease::acquire_lease(
19114 &shared.browser_station_leases,
19115 profile,
19116 address_b.session.instance_id,
19117 3,
19118 )
19119 .expect("reacquire after clear")
19120 .retain();
19121 assert!(
19122 crate::dig2_station_lease::release_lease_for_holder(
19123 &shared.browser_station_leases,
19124 address_b.session.instance_id,
19125 )
19126 );
19127 }
19128
19129 #[cfg(not(feature = "dig2-station-probe"))]
19132 #[test]
19133 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19134 let (shared, defaults) = station_knobs_fixture();
19135 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19136 let spec = crate::protocol::SpawnSpec {
19137 target: crate::protocol::SpawnTarget {
19138 node_id: shared.node_id.clone(),
19139 workspace_id: WorkspaceId::new("primary").unwrap(),
19140 worktree_id: None,
19141 },
19142 profile_id: defaults.profile_id.clone(),
19143 expected_profile_revision: defaults.revision.clone(),
19144 overrides: crate::protocol::SpawnOverrides {
19145 browser_profile_id: Some(browser.clone()),
19146 ..crate::protocol::SpawnOverrides::default()
19147 },
19148 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19149 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19150 .unwrap(),
19151 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19152 };
19153 let resolved = spec.resolve(&defaults).unwrap();
19154 let receipt = shared
19155 .resolve_environment_profile(&resolved)
19156 .expect("stub resolve")
19157 .expect("env profile");
19158 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19159 }
19160
19161
19162 #[test]
19165 fn network_allowlist_catalog_loads_from_temp_file() {
19166 let unique = std::time::SystemTime::now()
19167 .duration_since(std::time::UNIX_EPOCH)
19168 .unwrap()
19169 .as_nanos();
19170 let path = std::env::temp_dir().join(format!(
19171 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19172 std::process::id(),
19173 ));
19174 std::fs::write(
19175 &path,
19176 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19177 )
19178 .unwrap();
19179 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19180 .expect("temp catalog file must load");
19181 let _ = std::fs::remove_file(&path);
19182 assert_eq!(loaded.len(), 2);
19183 let (shared, defaults) = station_knobs_fixture();
19184 {
19185 let mut catalog = shared
19186 .network_allowlist_catalog
19187 .write()
19188 .unwrap_or_else(|poisoned| poisoned.into_inner());
19189 *catalog = loaded;
19190 }
19191 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19192 let spec = crate::protocol::SpawnSpec {
19193 target: crate::protocol::SpawnTarget {
19194 node_id: shared.node_id.clone(),
19195 workspace_id: WorkspaceId::new("primary").unwrap(),
19196 worktree_id: None,
19197 },
19198 profile_id: defaults.profile_id.clone(),
19199 expected_profile_revision: defaults.revision.clone(),
19200 overrides: crate::protocol::SpawnOverrides {
19201 network_allowlist: Some(allowlist.clone()),
19202 ..crate::protocol::SpawnOverrides::default()
19203 },
19204 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19205 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19206 "station-knobs-catalog-file",
19207 )
19208 .unwrap(),
19209 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19210 };
19211 let resolved = spec.resolve(&defaults).unwrap();
19212 let receipt = shared
19213 .resolve_environment_profile(&resolved)
19214 .expect("registered via file load")
19215 .expect("env profile present");
19216 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19217 }
19218
19219 #[test]
19222 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19223 let (shared, _defaults) = station_knobs_fixture();
19224 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19225 shared
19226 .network_allowlist_catalog
19227 .write()
19228 .unwrap_or_else(|poisoned| poisoned.into_inner())
19229 .insert_id(allowlist.clone())
19230 .unwrap();
19231 let snapshot = shared.snapshot();
19232 let inventory = snapshot
19233 .launch_inventory
19234 .expect("launch inventory present");
19235 let ids = inventory
19236 .network_allowlists
19237 .expect("non-empty catalog surfaces ids");
19238 assert_eq!(ids, vec![allowlist]);
19239 }
19240
19241 #[test]
19245 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19246 let (shared, defaults) = station_knobs_fixture();
19247 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19248 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19249 id: allowlist.clone(),
19250 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19251 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19252 peer: "127.0.0.1:443".to_owned(),
19253 }],
19254 provider_native: Some(
19255 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19256 codex_network_access: Some(true),
19257 },
19258 ),
19259 };
19260 shared
19261 .network_allowlist_catalog
19262 .write()
19263 .unwrap_or_else(|poisoned| poisoned.into_inner())
19264 .insert(entry)
19265 .unwrap();
19266 let spec = crate::protocol::SpawnSpec {
19267 target: crate::protocol::SpawnTarget {
19268 node_id: shared.node_id.clone(),
19269 workspace_id: WorkspaceId::new("primary").unwrap(),
19270 worktree_id: None,
19271 },
19272 profile_id: defaults.profile_id.clone(),
19273 expected_profile_revision: defaults.revision.clone(),
19274 overrides: crate::protocol::SpawnOverrides {
19275 network_allowlist: Some(allowlist),
19276 ..crate::protocol::SpawnOverrides::default()
19277 },
19278 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19279 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19280 "station-knobs-unsupported-mapping",
19281 )
19282 .unwrap(),
19283 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19284 };
19285 let resolved = spec.resolve(&defaults).unwrap();
19286 assert_eq!(resolved.provider.as_str(), "claude");
19287 let failure = shared
19288 .resolve_environment_profile(&resolved)
19289 .expect_err("claude + codex_network_access must refuse");
19290 assert_eq!(
19291 failure.code,
19292 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19293 );
19294 }
19295
19296 #[test]
19300 fn resolve_codex_network_access_overlay_moderate_only() {
19301 let (shared, defaults) = station_knobs_fixture();
19302 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19303 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19304 id: allowlist.clone(),
19305 permits: Vec::new(),
19306 provider_native: Some(
19307 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19308 codex_network_access: Some(true),
19309 },
19310 ),
19311 };
19312 shared
19313 .network_allowlist_catalog
19314 .write()
19315 .unwrap_or_else(|poisoned| poisoned.into_inner())
19316 .insert(entry)
19317 .unwrap();
19318
19319 let overlay = shared
19320 .resolve_codex_network_access_overlay_args(
19321 &agent("codex"),
19322 SessionMode::Pty,
19323 ApprovalLevel::Moderate,
19324 Some(&allowlist),
19325 )
19326 .expect("Moderate Codex maps network_access");
19327 assert_eq!(
19328 overlay,
19329 vec![
19330 std::ffi::OsString::from("-c"),
19331 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19332 ]
19333 );
19334
19335 let acp = shared
19336 .resolve_codex_network_access_overlay_args(
19337 &agent("codex"),
19338 SessionMode::Acp,
19339 ApprovalLevel::Moderate,
19340 Some(&allowlist),
19341 )
19342 .unwrap();
19343 assert_eq!(acp, overlay);
19344
19345 let inline = shared
19346 .resolve_codex_network_access_overlay_args(
19347 &agent("codex"),
19348 SessionMode::Inline,
19349 ApprovalLevel::Moderate,
19350 Some(&allowlist),
19351 )
19352 .unwrap_err();
19353 assert_eq!(
19354 inline.code,
19355 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19356 );
19357
19358 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19359 let spec = crate::protocol::SpawnSpec {
19360 target: crate::protocol::SpawnTarget {
19361 node_id: shared.node_id.clone(),
19362 workspace_id: WorkspaceId::new("primary").unwrap(),
19363 worktree_id: None,
19364 },
19365 profile_id: defaults.profile_id.clone(),
19366 expected_profile_revision: defaults.revision.clone(),
19367 overrides: crate::protocol::SpawnOverrides {
19368 provider: crate::protocol::SpawnOverride::Set {
19369 value: agent("codex"),
19370 },
19371 approval_level: Some(level),
19372 network_allowlist: Some(allowlist.clone()),
19373 ..crate::protocol::SpawnOverrides::default()
19374 },
19375 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19376 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19377 "codex-net-refuse-{}",
19378 match level {
19379 ApprovalLevel::ReadOnly => "ro",
19380 ApprovalLevel::FullAuto => "fa",
19381 _ => "other",
19382 }
19383 ))
19384 .unwrap(),
19385 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19386 };
19387 let resolved = spec.resolve(&defaults).unwrap();
19388 let failure = shared
19389 .resolve_environment_profile(&resolved)
19390 .expect_err("RO/FullAuto must refuse");
19391 assert_eq!(
19392 failure.code,
19393 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19394 );
19395 }
19396 }
19397
19398 #[test]
19402 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19403 let (shared, _defaults) = station_knobs_fixture();
19404 let allowlist =
19405 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19406 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19407 id: allowlist.clone(),
19408 permits: vec![
19409 crate::network_allowlist_catalog::NetworkPermitSketch {
19410 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19411 peer: "github.com:443".to_owned(),
19412 },
19413 crate::network_allowlist_catalog::NetworkPermitSketch {
19414 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19415 peer: "*.npmjs.org".to_owned(),
19416 },
19417 ],
19418 provider_native: None,
19419 };
19420 shared
19421 .network_allowlist_catalog
19422 .write()
19423 .unwrap_or_else(|poisoned| poisoned.into_inner())
19424 .insert(entry)
19425 .unwrap();
19426
19427 let unique = std::time::SystemTime::now()
19428 .duration_since(std::time::UNIX_EPOCH)
19429 .unwrap()
19430 .as_nanos();
19431 let provider_home = std::env::temp_dir().join(format!(
19432 "gate4agent-claude-provider-home-{}-{unique}",
19433 std::process::id(),
19434 ));
19435 std::fs::create_dir_all(&provider_home).unwrap();
19436
19437 let overlay = shared
19438 .resolve_network_allowlist_overlay_args(
19439 &agent("claude"),
19440 SessionMode::Pty,
19441 ApprovalLevel::Moderate,
19442 Some(&allowlist),
19443 Some(provider_home.as_path()),
19444 )
19445 .expect("Claude PTY settings overlay on unix");
19446 let settings_path = provider_home.join(
19447 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19448 );
19449 assert_eq!(
19450 overlay,
19451 vec![
19452 std::ffi::OsString::from("--settings"),
19453 std::ffi::OsString::from(settings_path.as_os_str()),
19454 ]
19455 );
19456 let body = std::fs::read_to_string(&settings_path).unwrap();
19457 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19458 assert_eq!(value["sandbox"]["enabled"], true);
19459 assert_eq!(
19460 value["sandbox"]["network"]["allowedDomains"],
19461 serde_json::json!(["github.com", "*.npmjs.org"])
19462 );
19463 assert!(settings_path.starts_with(&provider_home));
19464
19465 let acp = shared
19466 .resolve_network_allowlist_overlay_args(
19467 &agent("claude"),
19468 SessionMode::Acp,
19469 ApprovalLevel::Moderate,
19470 Some(&allowlist),
19471 Some(provider_home.as_path()),
19472 )
19473 .expect_err("Claude ACP must refuse settings overlay");
19474 assert_eq!(
19475 acp.code,
19476 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19477 );
19478
19479 let no_home = shared
19480 .resolve_network_allowlist_overlay_args(
19481 &agent("claude"),
19482 SessionMode::Pty,
19483 ApprovalLevel::Moderate,
19484 Some(&allowlist),
19485 None,
19486 )
19487 .expect_err("Claude overlay requires ProviderHome");
19488 assert_eq!(
19489 no_home.code,
19490 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19491 );
19492
19493 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19494 shared
19495 .network_allowlist_catalog
19496 .write()
19497 .unwrap_or_else(|poisoned| poisoned.into_inner())
19498 .insert_id(bare.clone())
19499 .unwrap();
19500 let empty = shared
19501 .resolve_network_allowlist_overlay_args(
19502 &agent("claude"),
19503 SessionMode::Pty,
19504 ApprovalLevel::Moderate,
19505 Some(&bare),
19506 Some(provider_home.as_path()),
19507 )
19508 .unwrap();
19509 assert!(empty.is_empty());
19510
19511 let _ = std::fs::remove_dir_all(&provider_home);
19512 }
19513
19514 #[test]
19516 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19517 let (shared, _defaults) = station_knobs_fixture();
19518 let allowlist =
19519 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19520 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19521 id: allowlist.clone(),
19522 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19523 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19524 peer: "example.com:443".to_owned(),
19525 }],
19526 provider_native: None,
19527 };
19528 shared
19529 .network_allowlist_catalog
19530 .write()
19531 .unwrap_or_else(|poisoned| poisoned.into_inner())
19532 .insert(entry)
19533 .unwrap();
19534 for provider in ["kimi", "grok"] {
19535 let overlay = shared
19536 .resolve_network_allowlist_overlay_args(
19537 &agent(provider),
19538 SessionMode::Pty,
19539 ApprovalLevel::Moderate,
19540 Some(&allowlist),
19541 None,
19542 )
19543 .unwrap();
19544 assert!(
19545 overlay.is_empty(),
19546 "{provider} must not invent network argv from permits"
19547 );
19548 }
19549 }
19550
19551 #[test]
19553 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19554 let unique = std::time::SystemTime::now()
19555 .duration_since(std::time::UNIX_EPOCH)
19556 .unwrap()
19557 .as_nanos();
19558 let path = std::env::temp_dir().join(format!(
19559 "gate4agent-node-allowlist-json-{}-{unique}.json",
19560 std::process::id(),
19561 ));
19562 std::fs::write(
19563 &path,
19564 r#"{
19565 "schema_version": 2,
19566 "entries": [
19567 {
19568 "id": "egress-default",
19569 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19570 },
19571 { "id": "lab-egress" }
19572 ]
19573 }"#,
19574 )
19575 .unwrap();
19576 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19577 .expect("json v2 catalog must load");
19578 let _ = std::fs::remove_file(&path);
19579 assert_eq!(loaded.len(), 2);
19580 let entry = loaded
19581 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19582 .unwrap();
19583 assert_eq!(entry.permits.len(), 1);
19584 let (shared, defaults) = station_knobs_fixture();
19585 {
19586 let mut catalog = shared
19587 .network_allowlist_catalog
19588 .write()
19589 .unwrap_or_else(|poisoned| poisoned.into_inner());
19590 *catalog = loaded;
19591 }
19592 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19593 let spec = crate::protocol::SpawnSpec {
19594 target: crate::protocol::SpawnTarget {
19595 node_id: shared.node_id.clone(),
19596 workspace_id: WorkspaceId::new("primary").unwrap(),
19597 worktree_id: None,
19598 },
19599 profile_id: defaults.profile_id.clone(),
19600 expected_profile_revision: defaults.revision.clone(),
19601 overrides: crate::protocol::SpawnOverrides {
19602 network_allowlist: Some(allowlist.clone()),
19603 ..crate::protocol::SpawnOverrides::default()
19604 },
19605 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19606 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19607 "station-knobs-catalog-json",
19608 )
19609 .unwrap(),
19610 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19611 };
19612 let resolved = spec.resolve(&defaults).unwrap();
19613 let receipt = shared
19614 .resolve_environment_profile(&resolved)
19615 .expect("permits-only entry ok for claude")
19616 .expect("env profile present");
19617 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19618 let inventory = shared
19619 .snapshot()
19620 .launch_inventory
19621 .expect("launch inventory present");
19622 let ids = inventory.network_allowlists.expect("ids surface");
19623 assert_eq!(
19624 ids,
19625 vec![
19626 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19627 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19628 ]
19629 );
19630 }
19631
19632
19633}
19634
19635
19636#[cfg(test)]
19637mod workspace_environment_collision_tests {
19638 use super::*;
19639 use crate::session_environment::{
19640 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19641 };
19642 use gate4agent_catalog::EnvMutation;
19643 use gate4agent_runtime_native::{
19644 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19645 NativeLaunchProfileId,
19646 };
19647 use std::ffi::OsString;
19648 use std::sync::Arc;
19649
19650 struct EmptyEnvironmentResolver;
19651
19652 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19653 fn resolve_child_environment(
19654 &self,
19655 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19656 Ok(vec![EnvMutation {
19657 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19658 value: None,
19659 }])
19660 }
19661 }
19662
19663 fn agent(value: &str) -> AgentId {
19664 AgentId::new(value).unwrap()
19665 }
19666
19667 fn claude_config_dir_collision_fixture() -> (
19669 NodeShared,
19670 ResolvedEnvironmentProfileReceipt,
19671 ResolvedEnvironmentProfileReceipt,
19672 ) {
19673 let catalog = active_registry().unwrap();
19674 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19675 let control = runtime.native_launch_profile_control();
19676 let workspace = WorkspaceConfig::new(
19677 WorkspaceId::new("primary").unwrap(),
19678 std::env::current_dir().unwrap(),
19679 )
19680 .unwrap();
19681 let shared = NodeShared::new_with_incarnation(
19682 handle,
19683 "fixture-token".to_owned(),
19684 NodeId::new("node-env-collision-test").unwrap(),
19685 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19686 vec![workspace],
19687 vec![agent("claude")],
19688 ProviderRuntimeStatuses::default(),
19689 None,
19690 Vec::new(),
19691 Vec::new(),
19692 SpawnProfileRegistry::default(),
19693 Some(control),
19694 None,
19695 None,
19696 None,
19697 Vec::new(),
19698 Vec::new(),
19699 Vec::new(),
19700 Vec::new(),
19701 None,
19702 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19703 );
19704
19705 let mut receipts = Vec::new();
19706 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19707 let profile_id =
19708 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19709 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19710 "claude-config-dir-{suffix}-r1"
19711 ))
19712 .unwrap();
19713 let materialization = NodeSessionMaterializationProfile::new(
19714 Vec::new(),
19715 vec![NodeSessionPathBinding::new(
19716 "CLAUDE_CONFIG_DIR",
19717 NodeSessionPathClass::ProviderHome,
19718 )
19719 .unwrap()],
19720 Vec::new(),
19721 )
19722 .unwrap();
19723 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19724 profile_id.clone(),
19725 profile_revision.clone(),
19726 agent("claude"),
19727 [NativeLaunchProfile::new(
19728 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19729 .unwrap(),
19730 agent("claude"),
19731 TransportKind::Pty,
19732 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19733 Arc::new(EmptyEnvironmentResolver),
19734 )
19735 .unwrap()],
19736 Some(materialization),
19737 )
19738 .unwrap();
19739 let (binding, native_profiles, materialization) = node_profile.into_parts();
19740 for native_profile in native_profiles {
19741 runtime.upsert_native_launch_profile(native_profile).unwrap();
19742 }
19743 shared
19744 .environment_profiles
19745 .write()
19746 .unwrap_or_else(|poisoned| poisoned.into_inner())
19747 .insert(binding.id.clone(), binding);
19748 shared
19749 .environment_materialization_profiles
19750 .write()
19751 .unwrap_or_else(|poisoned| poisoned.into_inner())
19752 .insert(profile_id.clone(), materialization.unwrap());
19753 receipts.push(ResolvedEnvironmentProfileReceipt {
19754 profile_id,
19755 profile_revision,
19756 network_allowlist: None,
19757 browser_profile_id: None,
19758 });
19759 }
19760 let profile_b = receipts.pop().unwrap();
19761 let profile_a = receipts.pop().unwrap();
19762 (shared, profile_a, profile_b)
19763 }
19764
19765 #[test]
19766 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19767 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19768 let primary = WorkspaceId::new("primary").unwrap();
19769 let worktree = WorkspaceId::new("worktree-b").unwrap();
19770 let address = SessionAddress {
19771 workspace_id: primary.clone(),
19772 session: SessionKey {
19773 instance_id: AgentInstanceId(41),
19774 generation: SessionGeneration(1),
19775 },
19776 };
19777 shared.bind_session_with_policy(
19778 &address,
19779 ProviderRuntimePolicy::raw_pty(),
19780 Some(profile_a.clone()),
19781 );
19782
19783 let refused = shared
19784 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19785 .unwrap_err();
19786 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19787
19788 let refused_none = shared
19790 .ensure_workspace_environment_profile_compatible(&primary, None)
19791 .unwrap_err();
19792 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19793
19794 shared
19796 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19797 .unwrap();
19798
19799 shared
19801 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19802 .unwrap();
19803 }
19804
19805 #[tokio::test]
19806 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19807 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19808 let primary = WorkspaceId::new("primary").unwrap();
19809 let address = SessionAddress {
19810 workspace_id: primary.clone(),
19811 session: SessionKey {
19812 instance_id: AgentInstanceId(41),
19813 generation: SessionGeneration(1),
19814 },
19815 };
19816 shared.bind_session_with_policy(
19817 &address,
19818 ProviderRuntimePolicy::raw_pty(),
19819 Some(profile_a),
19820 );
19821
19822 let failure = shared
19823 .spawn_session_with_deadline(
19824 primary,
19825 agent("claude"),
19826 SessionMode::Pty,
19827 gate4agent_types::TerminalSize {
19828 rows: 24,
19829 columns: 80,
19830 },
19831 None,
19832 Some(profile_b),
19833 None,
19834 None,
19835 None,
19836 None,
19837 SpawnRecordPolicy::ProviderIdentityOnly,
19838 Some(Instant::now() + Duration::from_secs(30)),
19839 &[],
19840 None,
19841 ApprovalLevel::default(),
19842 None,
19843 None,
19844 )
19845 .await
19846 .unwrap_err();
19847 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19848 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19850 assert!(shared.handle.snapshot().sessions.is_empty());
19851 }
19852}
19853
19854#[cfg(test)]
19855mod spawn_characterization_tests {
19856 use super::*;
19857 use crate::session_environment::{
19858 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19859 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19860 NodeSessionPathClass,
19861 };
19862 use gate4agent_catalog::EnvMutation;
19863 use gate4agent_runtime_native::{
19864 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19865 NativeLaunchProfile, NativeLaunchProfileId,
19866 };
19867 use std::ffi::OsString;
19868 use std::sync::Arc;
19869
19870 struct EmptyEnvironmentResolver;
19871
19872 struct FixtureSecretResolver {
19873 deny: Arc<AtomicBool>,
19874 }
19875
19876 impl NodeSecretResolver for FixtureSecretResolver {
19877 fn resolve(
19878 &self,
19879 _reference: &NodeSecretReference,
19880 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19881 if self.deny.load(Ordering::Acquire) {
19882 Err(NodeSecretResolveError::Denied)
19883 } else {
19884 NodeSecretValue::text("fixture-secret")
19885 .map_err(|_| NodeSecretResolveError::Unavailable)
19886 }
19887 }
19888 }
19889
19890 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19891 fn resolve_child_environment(
19892 &self,
19893 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19894 Ok(vec![EnvMutation {
19895 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19896 value: None,
19897 }])
19898 }
19899 }
19900
19901 fn agent(value: &str) -> AgentId {
19902 AgentId::new(value).unwrap()
19903 }
19904
19905 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19906 let unique = std::time::SystemTime::now()
19907 .duration_since(std::time::UNIX_EPOCH)
19908 .unwrap()
19909 .as_nanos();
19910 std::env::temp_dir().join(format!(
19911 "gate4agent-node-{label}-{}-{unique}",
19912 std::process::id(),
19913 ))
19914 }
19915
19916 fn materialization_test_shared(
19917 deny_secret: bool,
19918 ) -> (
19919 NodeShared,
19920 NativeRuntime,
19921 ResolvedEnvironmentProfileReceipt,
19922 PathBuf,
19923 Arc<AtomicBool>,
19924 ) {
19925 let workspace = WorkspaceConfig::new(
19926 WorkspaceId::new("primary").unwrap(),
19927 std::env::current_dir().unwrap(),
19928 )
19929 .unwrap();
19930 materialization_test_shared_with_workspace(deny_secret, workspace)
19931 }
19932
19933 fn materialization_test_shared_with_workspace(
19934 deny_secret: bool,
19935 workspace: WorkspaceConfig,
19936 ) -> (
19937 NodeShared,
19938 NativeRuntime,
19939 ResolvedEnvironmentProfileReceipt,
19940 PathBuf,
19941 Arc<AtomicBool>,
19942 ) {
19943 let root = temporary_workspace_root(if deny_secret {
19944 "materialization-denied"
19945 } else {
19946 "materialization-ready"
19947 });
19948 std::fs::create_dir_all(&root).unwrap();
19949 let materialization_root = root.join("session-environments");
19950 let deny = Arc::new(AtomicBool::new(deny_secret));
19951 let resolver = Arc::new(FixtureSecretResolver {
19952 deny: Arc::clone(&deny),
19953 });
19954 let materializer = SessionEnvironmentMaterializer::new(
19955 materialization_root,
19956 resolver,
19957 )
19958 .unwrap();
19959 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19960 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19961 "materialized-claude-r1",
19962 )
19963 .unwrap();
19964 let materialization = NodeSessionMaterializationProfile::new(
19965 vec![NodeSessionEnvironmentMutation::SetSecret {
19966 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19967 reference: NodeSecretReference::new("fixture-token").unwrap(),
19968 }],
19969 vec![NodeSessionPathBinding::new(
19970 "GATE4AGENT_PROVIDER_HOME",
19971 NodeSessionPathClass::ProviderHome,
19972 )
19973 .unwrap()],
19974 vec![NodeSessionFile::secret(
19975 NodeSessionPathClass::Config,
19976 "auth/token",
19977 NodeSecretReference::new("fixture-token").unwrap(),
19978 )
19979 .unwrap()],
19980 )
19981 .unwrap();
19982 let catalog = active_registry().unwrap();
19983 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19984 let control = runtime.native_launch_profile_control();
19985 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19986 profile_id.clone(),
19987 profile_revision.clone(),
19988 agent("claude"),
19989 [NativeLaunchProfile::new(
19990 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19991 agent("claude"),
19992 TransportKind::Pty,
19993 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19994 Arc::new(EmptyEnvironmentResolver),
19995 )
19996 .unwrap()],
19997 Some(materialization),
19998 )
19999 .unwrap();
20000 let (binding, native_profiles, materialization) = node_profile.into_parts();
20001 for native_profile in native_profiles {
20002 runtime.upsert_native_launch_profile(native_profile).unwrap();
20003 }
20004 let shared = NodeShared::new_with_incarnation(
20005 handle,
20006 "fixture-token".to_owned(),
20007 NodeId::new("node-materialization-test").unwrap(),
20008 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20009 vec![workspace],
20010 vec![agent("claude")],
20011 ProviderRuntimeStatuses::default(),
20012 None,
20013 Vec::new(),
20014 Vec::new(),
20015 SpawnProfileRegistry::default(),
20016 Some(control),
20017 None,
20018 Some(materializer),
20019 None,
20020 Vec::new(),
20021 Vec::new(),
20022 Vec::new(),
20023 Vec::new(),
20024 None,
20025 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20026 );
20027 shared
20028 .environment_profiles
20029 .write()
20030 .unwrap_or_else(|poisoned| poisoned.into_inner())
20031 .insert(binding.id.clone(), binding);
20032 shared
20033 .environment_materialization_profiles
20034 .write()
20035 .unwrap_or_else(|poisoned| poisoned.into_inner())
20036 .insert(profile_id.clone(), materialization.unwrap());
20037 (
20038 shared,
20039 runtime,
20040 ResolvedEnvironmentProfileReceipt {
20041 profile_id,
20042 profile_revision,
20043 network_allowlist: None,
20044 browser_profile_id: None,
20045 },
20046 root,
20047 deny,
20048 )
20049 }
20050
20051
20052 async fn characterization_spawn(
20055 shared: &NodeShared,
20056 mode: SessionMode,
20057 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20058 required: &[ProviderRuntimeCapability],
20059 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20060 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20061 shared
20062 .spawn_session_with_deadline(
20063 WorkspaceId::new("primary").unwrap(),
20064 agent("claude"),
20065 mode,
20066 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20067 None,
20068 environment_profile,
20069 None,
20070 None,
20071 None,
20072 Some(ProviderRuntimePolicy::raw_pty()),
20073 SpawnRecordPolicy::ProviderIdentityOnly,
20074 Some(Instant::now() + Duration::from_secs(30)),
20075 required,
20076 harness_mcp,
20077 ApprovalLevel::default(),
20078 None,
20079 None,
20080 )
20081 .await
20082 }
20083
20084 fn assert_no_session_state(shared: &NodeShared) {
20085 assert!(shared.handle.snapshot().sessions.is_empty());
20086 assert!(shared
20087 .session_bindings
20088 .lock()
20089 .unwrap_or_else(|poisoned| poisoned.into_inner())
20090 .is_empty());
20091 assert!(shared
20092 .materializations
20093 .lock()
20094 .unwrap_or_else(|poisoned| poisoned.into_inner())
20095 .is_empty());
20096 }
20097
20098 #[tokio::test]
20099 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20100 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20101 let error = characterization_spawn(
20102 &shared,
20103 SessionMode::Pty,
20104 None,
20105 &[ProviderRuntimeCapability::SemanticReadiness],
20106 None,
20107 )
20108 .await
20109 .unwrap_err();
20110 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20111 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20112 assert_no_session_state(&shared);
20113 drop(shared);
20114 drop(runtime);
20115 std::fs::remove_dir_all(root).unwrap();
20116 }
20117
20118 #[tokio::test]
20119 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20120 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20121 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20122 .await
20123 .unwrap_err();
20124 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20125 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20126 assert_no_session_state(&shared);
20127 drop(shared);
20128 drop(runtime);
20129 std::fs::remove_dir_all(root).unwrap();
20130 }
20131
20132 #[test]
20133 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20134 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20135 let address = SessionAddress {
20136 workspace_id: WorkspaceId::new("primary").unwrap(),
20137 session: SessionKey {
20138 instance_id: AgentInstanceId(77),
20139 generation: SessionGeneration(1),
20140 },
20141 };
20142 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20143 let removed = shared.remove_binding(&address).expect("binding removed");
20144 assert!(removed.environment_profile.is_some());
20145 assert!(shared.remove_binding(&address).is_none());
20146 assert_no_session_state(&shared);
20147 drop(shared);
20148 drop(runtime);
20149 std::fs::remove_dir_all(root).unwrap();
20150 }
20151
20152 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20153 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20154 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20155 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20156 .expect("review current test executable as fixture helper");
20157 let registry = HarnessMcpProxyRegistry::new(helper);
20158 let spec = crate::protocol::SpawnSpec {
20159 target: crate::protocol::SpawnTarget {
20160 node_id: NodeId::new("node-characterization").unwrap(),
20161 workspace_id: WorkspaceId::new("primary").unwrap(),
20162 worktree_id: None,
20163 },
20164 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20165 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20166 overrides: crate::protocol::SpawnOverrides::default(),
20167 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20168 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20169 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20170 };
20171 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20172 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20173 let launch = HarnessMcpLaunchV1 {
20174 server_name: "fixture-harness".to_owned(),
20175 args: vec!["--session-proxy".to_owned()],
20176 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20177 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20178 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20179 trace: None,
20180 scrub_env: Vec::new(),
20181 };
20182 let expires = unix_time_ms().saturating_add(30_000);
20183 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20184 registry
20185 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20186 .unwrap()
20187 }
20188
20189 #[tokio::test]
20190 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20191 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20192 let prepared = characterization_prepared_harness("claude").await;
20193 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20194 .await
20195 .unwrap_err();
20196 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20198 let mismatched = characterization_prepared_harness("codex").await;
20199 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20200 .await
20201 .unwrap_err();
20202 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20203 assert_no_session_state(&shared);
20204 drop(shared);
20205 drop(runtime);
20206 std::fs::remove_dir_all(root).unwrap();
20207 }
20208
20209 #[tokio::test]
20210 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20211 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20212 shared.native_launch_profile_control = None;
20213 let prepared = characterization_prepared_harness("claude").await;
20214 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20215 .await
20216 .unwrap_err();
20217 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20218 assert_no_session_state(&shared);
20219 drop(shared);
20220 drop(runtime);
20221 std::fs::remove_dir_all(root).unwrap();
20222 }
20223}
20224
20225#[cfg(all(test, windows))]
20226mod tests {
20227 use super::*;
20228 use gate4agent_types::ProviderActivity;
20229 use crate::session_environment::{
20230 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20231 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20232 NodeSessionPathClass,
20233 };
20234 use gate4agent_catalog::EnvMutation;
20235 use gate4agent_runtime_native::{
20236 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20237 NativeLaunchProfile, NativeLaunchProfileId,
20238 };
20239 use std::ffi::OsString;
20240 use std::sync::Arc;
20241
20242 struct EmptyEnvironmentResolver;
20243
20244 struct FixtureSecretResolver {
20245 deny: Arc<AtomicBool>,
20246 }
20247
20248 impl NodeSecretResolver for FixtureSecretResolver {
20249 fn resolve(
20250 &self,
20251 _reference: &NodeSecretReference,
20252 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20253 if self.deny.load(Ordering::Acquire) {
20254 Err(NodeSecretResolveError::Denied)
20255 } else {
20256 NodeSecretValue::text("fixture-secret")
20257 .map_err(|_| NodeSecretResolveError::Unavailable)
20258 }
20259 }
20260 }
20261
20262 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20263 fn resolve_child_environment(
20264 &self,
20265 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20266 Ok(vec![EnvMutation {
20267 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20268 value: None,
20269 }])
20270 }
20271 }
20272
20273 fn agent(value: &str) -> AgentId {
20274 AgentId::new(value).unwrap()
20275 }
20276
20277 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20278 let catalog = active_registry().unwrap();
20279 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20280 let workspace = WorkspaceConfig::new(
20281 WorkspaceId::new("primary").unwrap(),
20282 std::env::current_dir().unwrap(),
20283 )
20284 .unwrap()
20285 .with_worktree_service_mode(mode);
20286 NodeShared::new(
20287 handle,
20288 "fixture-token".to_owned(),
20289 NodeId::new("node-terminal-test").unwrap(),
20290 vec![workspace],
20291 vec![agent("claude")],
20292 )
20293 }
20294
20295 fn terminal_test_shared() -> NodeShared {
20296 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20297 }
20298
20299 #[cfg(feature = "fixture")]
20300 #[test]
20301 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20302 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20303 assert_eq!(probe.latest_code(), None);
20304
20305 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20306 assert_eq!(
20307 probe.latest_code(),
20308 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20309 );
20310
20311 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20312 assert_eq!(
20313 probe.latest_code(),
20314 Some(NodeFailureCode::SpawnDeadlineExceeded),
20315 );
20316
20317 probe.record(None);
20318 assert_eq!(probe.latest_code(), None);
20319 }
20320
20321 #[test]
20322 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20323 let source = temporary_workspace_root("launch-inventory-source");
20324 let allocation = temporary_workspace_root("launch-inventory-allocation");
20325 std::fs::create_dir_all(&source).unwrap();
20326 std::fs::create_dir_all(&allocation).unwrap();
20327 let workspace_id = WorkspaceId::new("primary").unwrap();
20328 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20329 .unwrap()
20330 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20331 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20332 workspace = workspace
20333 .with_managed_worktree_profile(
20334 ManagedWorktreeProfile::new(
20335 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20336 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20337 &allocation,
20338 "gate4agent",
20339 "HEAD",
20340 ManagedWorktreeRetention::RemoveWhenReleased,
20341 )
20342 .unwrap(),
20343 )
20344 .unwrap();
20345 }
20346 let overflow = workspace.clone().with_managed_worktree_profile(
20347 ManagedWorktreeProfile::new(
20348 WorktreeProfileId::new("overflow").unwrap(),
20349 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20350 &allocation,
20351 "gate4agent",
20352 "HEAD",
20353 ManagedWorktreeRetention::Retain,
20354 )
20355 .unwrap(),
20356 );
20357 assert!(matches!(
20358 overflow,
20359 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20360 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20361 ..
20362 }),
20363 ));
20364
20365 let catalog = active_registry().unwrap();
20366 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20367 let shared = NodeShared::new(
20368 handle,
20369 "fixture-token".to_owned(),
20370 NodeId::new("node-launch-inventory").unwrap(),
20371 vec![workspace],
20372 vec![agent("claude")],
20373 );
20374 let snapshot = shared.snapshot();
20375 let launch = snapshot.launch_inventory.as_ref().unwrap();
20376 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20377 assert!(launch.bundles.as_ref().unwrap().is_empty());
20378 let profiles = snapshot.workspaces[0]
20379 .managed_worktree_profiles
20380 .as_ref()
20381 .unwrap();
20382 assert_eq!(
20383 snapshot.workspaces[0].worktree_service_mode,
20384 Some(WorktreeServiceMode::Managed),
20385 );
20386 assert_eq!(
20387 profiles.profiles.len(),
20388 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20389 );
20390 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20391 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20392 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20393 }
20394 let legacy = snapshot_for_wire(
20395 &shared,
20396 true,
20397 true,
20398 false,
20399 false,
20400 true,
20401 false,
20402 true,
20403 false,
20404 true,
20405 );
20406 assert!(legacy.launch_inventory.is_none());
20407 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20408
20409 drop(shared);
20410 std::fs::remove_dir_all(source).unwrap();
20411 std::fs::remove_dir_all(allocation).unwrap();
20412 }
20413
20414 #[test]
20415 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20416 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20417 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20418 }));
20419 let source = gate4agent_types::ProviderSource {
20420 family: AdapterFamily::PtySemantic,
20421 binding: AdapterBinding::new(
20422 gate4agent_types::AdapterId::new("claude").unwrap(),
20423 "fixture/v1",
20424 gate4agent_types::AdapterVerification::SyntheticFixture,
20425 )
20426 .unwrap(),
20427 };
20428 let provider = ProviderSnapshot {
20429 sequence: 17,
20430 session: Some(ProviderSessionIdentity {
20431 key: ProviderSessionKey::SessionId,
20432 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20433 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20434 }),
20435 model: Some("PRIVATE_MODEL".to_owned()),
20436 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20437 completed_turns: 3,
20438 usage: gate4agent_types::TokenUsage {
20439 input_tokens: 10,
20440 output_tokens: 20,
20441 cache_read_tokens: 30,
20442 cache_write_tokens: 40,
20443 reasoning_tokens: 50,
20444 context_window: Some(200_000),
20445 },
20446 lead_activity: ProviderActivity::WaitingForInput,
20447 activity: ProviderActivity::WaitingForInput,
20448 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20449 active_tools: (0..9)
20450 .map(|index| gate4agent_types::ActiveProviderTool {
20451 id: format!("private-tool-id-{index}"),
20452 name: if index == 0 {
20453 format!(" Read\u{0000}{}", "x".repeat(80))
20454 } else {
20455 format!("Tool-{index}")
20456 },
20457 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20458 })
20459 .collect(),
20460 interactions: vec![gate4agent_types::ProviderInteraction {
20461 id: gate4agent_types::ProviderInteractionId(17),
20462 source: source.clone(),
20463 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20464 interaction_kind: ProviderInteractionKind::Approval,
20465 tool_name: " Approve\u{0007}Tool ".to_owned(),
20466 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20467 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20468 resume_lead_activity: Some(ProviderActivity::Working),
20469 status: ProviderInteractionStatus::Pending,
20470 }],
20471 subagents: vec![gate4agent_types::ProviderSubagent {
20472 source: source.clone(),
20473 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20474 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20475 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20476 }],
20477 sources: vec![gate4agent_types::ProviderSourceCursor {
20478 source,
20479 sequence: 17,
20480 gap_count: 2,
20481 stale: true,
20482 }],
20483 last_event: Some(ProviderEvent::Error {
20484 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20485 }),
20486 gap_count: 2,
20487 stale: true,
20488 };
20489 let address = terminal_address(3);
20490 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20491 assert_eq!(entry.address, address);
20492 assert_eq!(entry.progress.provider_sequence, 17);
20493 assert_eq!(entry.progress.active_tool_count, 9);
20494 assert_eq!(
20495 entry.progress.active_tool_labels.len(),
20496 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20497 );
20498 assert_eq!(entry.progress.subagent_count, 1);
20499 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20500 assert!(entry.progress.truncated);
20501 let encoded = serde_json::to_string(&entry).unwrap();
20502 for forbidden in [
20503 "PRIVATE_PROVIDER_SESSION",
20504 "PRIVATE_PROVIDER_PATH",
20505 "PRIVATE_MODEL",
20506 "PRIVATE_TOOL_INVENTORY",
20507 "PRIVATE_CURRENT_PROMPT",
20508 "PRIVATE_TOOL_INPUT_JSON",
20509 "PRIVATE_PROVIDER_REQUEST",
20510 "PRIVATE_INTERACTION_PROMPT",
20511 "PRIVATE_INTERACTION_AGENT",
20512 "PRIVATE_SUBAGENT_ID",
20513 "PRIVATE_SUBAGENT_TYPE",
20514 "PRIVATE_SUBAGENT_DESCRIPTION",
20515 "PRIVATE_PROVIDER_ERROR",
20516 "input_json",
20517 "current_prompt",
20518 "session_id",
20519 ] {
20520 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20521 }
20522
20523 let snapshot = NodeSnapshot {
20524 node_id: NodeId::new("node-progress-test").unwrap(),
20525 enabled_providers: vec![agent("claude")],
20526 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20527 workspaces: Vec::new(),
20528 session_records: Vec::new(),
20529 managed_worktrees: Vec::new(),
20530 launch_inventory: None,
20531 agent_progress: vec![entry],
20532 };
20533 let mut legacy_reply = ResponseEnvelope {
20534 request_id: 1,
20535 result: Ok(NodeResponse::Snapshot {
20536 event_sequence: 0,
20537 controller: None,
20538 snapshot: snapshot.clone(),
20539 }),
20540 };
20541 project_response_without_agent_progress(&mut legacy_reply);
20542 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20543 panic!("agent progress projection changed response kind");
20544 };
20545 assert!(legacy.agent_progress.is_empty());
20546 assert_eq!(snapshot.agent_progress.len(), 1);
20547 }
20548
20549 fn terminal_address(generation: u64) -> SessionAddress {
20550 SessionAddress {
20551 workspace_id: WorkspaceId::new("primary").unwrap(),
20552 session: SessionKey {
20553 instance_id: AgentInstanceId(41),
20554 generation: SessionGeneration(generation),
20555 },
20556 }
20557 }
20558
20559 fn spawn_spec_fixture() -> SpawnSpec {
20560 SpawnSpec {
20561 target: crate::protocol::SpawnTarget {
20562 node_id: NodeId::new("node-terminal-test").unwrap(),
20563 workspace_id: WorkspaceId::new("primary").unwrap(),
20564 worktree_id: None,
20565 },
20566 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20567 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20568 .unwrap(),
20569 overrides: crate::protocol::SpawnOverrides::default(),
20570 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20571 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20572 required_capabilities: SpawnRequiredCapabilities::default(),
20573 }
20574 }
20575
20576 fn environment_profile_test_shared(
20577 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20578 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20579 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20580 "local-claude-r1",
20581 )
20582 .unwrap();
20583 let spawn_profiles = SpawnProfileRegistry::new([
20584 crate::protocol::SpawnProfileDefaults {
20585 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20586 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20587 provider: agent("claude"),
20588 mode: SessionMode::Pty,
20589 terminal_size: gate4agent_types::TerminalSize {
20590 rows: 24,
20591 columns: 80,
20592 },
20593 prompt: None,
20594 bundle_id: None,
20595 context_id: None,
20596 environment_profile_id: Some(profile_id.clone()),
20597 },
20598 ])
20599 .unwrap();
20600 let catalog = active_registry().unwrap();
20601 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20602 let control = runtime.native_launch_profile_control();
20603 let node_profile = NodeEnvironmentProfile::new(
20604 profile_id.clone(),
20605 profile_revision.clone(),
20606 agent("claude"),
20607 [NativeLaunchProfile::new(
20608 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20609 agent("claude"),
20610 TransportKind::Pty,
20611 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20612 Arc::new(EmptyEnvironmentResolver),
20613 )
20614 .unwrap()],
20615 )
20616 .unwrap();
20617 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20618 for native_profile in native_profiles {
20619 runtime.upsert_native_launch_profile(native_profile).unwrap();
20620 }
20621 let workspace = WorkspaceConfig::new(
20622 WorkspaceId::new("primary").unwrap(),
20623 std::env::current_dir().unwrap(),
20624 )
20625 .unwrap();
20626 let shared = NodeShared::new_with_incarnation(
20627 handle,
20628 "fixture-token".to_owned(),
20629 NodeId::new("node-terminal-test").unwrap(),
20630 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20631 vec![workspace],
20632 vec![agent("claude")],
20633 ProviderRuntimeStatuses::default(),
20634 None,
20635 Vec::new(),
20636 Vec::new(),
20637 spawn_profiles,
20638 Some(control),
20639 None,
20640 None,
20641 None,
20642 Vec::new(),
20643 Vec::new(),
20644 Vec::new(),
20645 Vec::new(),
20646 None,
20647 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20648 );
20649 shared
20650 .environment_profiles
20651 .write()
20652 .unwrap_or_else(|poisoned| poisoned.into_inner())
20653 .insert(binding.id.clone(), binding);
20654 (
20655 shared,
20656 runtime,
20657 ResolvedEnvironmentProfileReceipt {
20658 profile_id,
20659 profile_revision,
20660 network_allowlist: None,
20661 browser_profile_id: None,
20662 },
20663 )
20664 }
20665
20666 fn materialization_test_shared(
20667 deny_secret: bool,
20668 ) -> (
20669 NodeShared,
20670 NativeRuntime,
20671 ResolvedEnvironmentProfileReceipt,
20672 PathBuf,
20673 Arc<AtomicBool>,
20674 ) {
20675 let workspace = WorkspaceConfig::new(
20676 WorkspaceId::new("primary").unwrap(),
20677 std::env::current_dir().unwrap(),
20678 )
20679 .unwrap();
20680 materialization_test_shared_with_workspace(deny_secret, workspace)
20681 }
20682
20683 fn materialization_test_shared_with_workspace(
20684 deny_secret: bool,
20685 workspace: WorkspaceConfig,
20686 ) -> (
20687 NodeShared,
20688 NativeRuntime,
20689 ResolvedEnvironmentProfileReceipt,
20690 PathBuf,
20691 Arc<AtomicBool>,
20692 ) {
20693 let root = temporary_workspace_root(if deny_secret {
20694 "materialization-denied"
20695 } else {
20696 "materialization-ready"
20697 });
20698 std::fs::create_dir_all(&root).unwrap();
20699 let materialization_root = root.join("session-environments");
20700 let deny = Arc::new(AtomicBool::new(deny_secret));
20701 let resolver = Arc::new(FixtureSecretResolver {
20702 deny: Arc::clone(&deny),
20703 });
20704 let materializer = SessionEnvironmentMaterializer::new(
20705 materialization_root,
20706 resolver,
20707 )
20708 .unwrap();
20709 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20710 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20711 "materialized-claude-r1",
20712 )
20713 .unwrap();
20714 let materialization = NodeSessionMaterializationProfile::new(
20715 vec![NodeSessionEnvironmentMutation::SetSecret {
20716 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20717 reference: NodeSecretReference::new("fixture-token").unwrap(),
20718 }],
20719 vec![NodeSessionPathBinding::new(
20720 "GATE4AGENT_PROVIDER_HOME",
20721 NodeSessionPathClass::ProviderHome,
20722 )
20723 .unwrap()],
20724 vec![NodeSessionFile::secret(
20725 NodeSessionPathClass::Config,
20726 "auth/token",
20727 NodeSecretReference::new("fixture-token").unwrap(),
20728 )
20729 .unwrap()],
20730 )
20731 .unwrap();
20732 let catalog = active_registry().unwrap();
20733 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20734 let control = runtime.native_launch_profile_control();
20735 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20736 profile_id.clone(),
20737 profile_revision.clone(),
20738 agent("claude"),
20739 [NativeLaunchProfile::new(
20740 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20741 agent("claude"),
20742 TransportKind::Pty,
20743 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20744 Arc::new(EmptyEnvironmentResolver),
20745 )
20746 .unwrap()],
20747 Some(materialization),
20748 )
20749 .unwrap();
20750 let (binding, native_profiles, materialization) = node_profile.into_parts();
20751 for native_profile in native_profiles {
20752 runtime.upsert_native_launch_profile(native_profile).unwrap();
20753 }
20754 let shared = NodeShared::new_with_incarnation(
20755 handle,
20756 "fixture-token".to_owned(),
20757 NodeId::new("node-materialization-test").unwrap(),
20758 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20759 vec![workspace],
20760 vec![agent("claude")],
20761 ProviderRuntimeStatuses::default(),
20762 None,
20763 Vec::new(),
20764 Vec::new(),
20765 SpawnProfileRegistry::default(),
20766 Some(control),
20767 None,
20768 Some(materializer),
20769 None,
20770 Vec::new(),
20771 Vec::new(),
20772 Vec::new(),
20773 Vec::new(),
20774 None,
20775 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20776 );
20777 shared
20778 .environment_profiles
20779 .write()
20780 .unwrap_or_else(|poisoned| poisoned.into_inner())
20781 .insert(binding.id.clone(), binding);
20782 shared
20783 .environment_materialization_profiles
20784 .write()
20785 .unwrap_or_else(|poisoned| poisoned.into_inner())
20786 .insert(profile_id.clone(), materialization.unwrap());
20787 (
20788 shared,
20789 runtime,
20790 ResolvedEnvironmentProfileReceipt {
20791 profile_id,
20792 profile_revision,
20793 network_allowlist: None,
20794 browser_profile_id: None,
20795 },
20796 root,
20797 deny,
20798 )
20799 }
20800
20801 #[cfg(feature = "fixture")]
20802 #[derive(Clone, Copy)]
20803 enum CodexHomeProfile {
20804 Missing,
20805 WrongClass,
20806 Exact,
20807 }
20808
20809 #[cfg(feature = "fixture")]
20810 fn codex_bundle_test_shared(
20811 home_profile: CodexHomeProfile,
20812 ) -> (
20813 NodeShared,
20814 NativeRuntime,
20815 ResolvedEnvironmentProfileReceipt,
20816 NodeBundle,
20817 PathBuf,
20818 ) {
20819 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20820 const CLAUDE_MANIFEST: &[u8] =
20821 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20822 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20823 const DIGEST: &str =
20824 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20825
20826 let (shared, mut runtime, _claude_receipt, root, _deny) =
20827 materialization_test_shared(false);
20828 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20829 CodexHomeProfile::Missing => "codex-home-missing",
20830 CodexHomeProfile::WrongClass => "codex-home-wrong",
20831 CodexHomeProfile::Exact => "codex-home-exact",
20832 })
20833 .unwrap();
20834 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20835 "codex-home-r1",
20836 )
20837 .unwrap();
20838 let materialization = match home_profile {
20839 CodexHomeProfile::Missing => None,
20840 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20841 Some(NodeSessionMaterializationProfile::new(
20842 Vec::new(),
20843 vec![NodeSessionPathBinding::new(
20844 "CODEX_HOME",
20845 if matches!(home_profile, CodexHomeProfile::Exact) {
20846 NodeSessionPathClass::ProviderHome
20847 } else {
20848 NodeSessionPathClass::Config
20849 },
20850 )
20851 .unwrap()],
20852 Vec::new(),
20853 )
20854 .unwrap())
20855 }
20856 };
20857 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20858 profile_id.clone(),
20859 profile_revision.clone(),
20860 agent("codex"),
20861 [NativeLaunchProfile::new(
20862 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20863 agent("codex"),
20864 TransportKind::Pty,
20865 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20866 Arc::new(EmptyEnvironmentResolver),
20867 )
20868 .unwrap()],
20869 materialization,
20870 )
20871 .unwrap();
20872 let (binding, native_profiles, materialization) = node_profile.into_parts();
20873 for native_profile in native_profiles {
20874 runtime.upsert_native_launch_profile(native_profile).unwrap();
20875 }
20876 shared
20877 .environment_profiles
20878 .write()
20879 .unwrap_or_else(|poisoned| poisoned.into_inner())
20880 .insert(binding.id.clone(), binding);
20881 if let Some(materialization) = materialization {
20882 shared
20883 .environment_materialization_profiles
20884 .write()
20885 .unwrap_or_else(|poisoned| poisoned.into_inner())
20886 .insert(profile_id.clone(), materialization);
20887 }
20888
20889 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20890 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20891 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20892 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20893 std::fs::write(
20894 bundle_source.join(".claude-plugin/plugin.json"),
20895 CLAUDE_MANIFEST,
20896 )
20897 .unwrap();
20898 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20899 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20900 let bundle = NodeBundle::new(
20901 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20902 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20903 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20904 &bundle_source,
20905 )
20906 .unwrap();
20907 *shared
20908 .bundle_catalog
20909 .write()
20910 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20911 BundleCatalog::new([bundle.clone()]).unwrap();
20912 (
20913 shared,
20914 runtime,
20915 ResolvedEnvironmentProfileReceipt {
20916 profile_id,
20917 profile_revision,
20918 network_allowlist: None,
20919 browser_profile_id: None,
20920 },
20921 bundle,
20922 root,
20923 )
20924 }
20925
20926 #[cfg(feature = "fixture")]
20927 #[test]
20928 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20929 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20930 let (shared, runtime, receipt, bundle, root) =
20931 codex_bundle_test_shared(home_profile);
20932 let error = shared
20933 .bundle_layout(
20934 &agent("codex"),
20935 SessionMode::Pty,
20936 Some(&receipt),
20937 &bundle,
20938 )
20939 .unwrap_err();
20940 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20941 assert!(shared
20942 .materializations
20943 .lock()
20944 .unwrap_or_else(|poisoned| poisoned.into_inner())
20945 .is_empty());
20946 let materialization_root = root.join("session-environments");
20947 assert!(!std::fs::read_dir(&materialization_root)
20948 .unwrap()
20949 .filter_map(Result::ok)
20950 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20951 drop(shared);
20952 drop(runtime);
20953 let _ = std::fs::remove_dir_all(root);
20954 }
20955 }
20956
20957 #[cfg(feature = "fixture")]
20958 #[test]
20959 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20960 let (shared, runtime, receipt, bundle, root) =
20961 codex_bundle_test_shared(CodexHomeProfile::Exact);
20962 let address = SessionAddress {
20963 workspace_id: WorkspaceId::new("primary").unwrap(),
20964 session: SessionKey {
20965 instance_id: AgentInstanceId(310),
20966 generation: SessionGeneration(1),
20967 },
20968 };
20969 let (overlay, guard) = shared
20970 .prepare_session_materialization(
20971 &address,
20972 &agent("codex"),
20973 SessionMode::Pty,
20974 Some(&receipt),
20975 Some(&bundle.receipt()),
20976 None,
20977 None,
20978 )
20979 .unwrap()
20980 .unwrap();
20981 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20982 let ownership = shared
20983 .materializations
20984 .lock()
20985 .unwrap_or_else(|poisoned| poisoned.into_inner())
20986 .get(guard.id().unwrap())
20987 .unwrap()
20988 .clone();
20989 let (profile, _, _) = shared
20990 .materialization_profile(
20991 &agent("codex"),
20992 SessionMode::Pty,
20993 Some(&receipt),
20994 Some(&bundle.receipt()),
20995 None,
20996 )
20997 .unwrap()
20998 .unwrap();
20999 let environment = shared
21000 .session_environment_materializer
21001 .as_ref()
21002 .unwrap()
21003 .resolve_environment(&ownership, &profile)
21004 .unwrap();
21005 assert!(environment.iter().any(|mutation| {
21006 mutation.key == OsString::from("CODEX_HOME")
21007 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21008 }));
21009 assert_eq!(
21010 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21011 .unwrap(),
21012 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21013 );
21014 assert!(std::fs::read_dir(ownership.bundle_root())
21015 .unwrap()
21016 .next()
21017 .is_none());
21018 drop(guard);
21019 drop(shared);
21020 drop(runtime);
21021 let _ = std::fs::remove_dir_all(root);
21022 }
21023
21024 #[cfg(feature = "fixture")]
21025 #[test]
21026 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21027 let (shared, runtime, receipt, bundle, root) =
21028 codex_bundle_test_shared(CodexHomeProfile::Exact);
21029 let bundle_receipt = bundle.receipt();
21030 let address = SessionAddress {
21031 workspace_id: WorkspaceId::new("primary").unwrap(),
21032 session: SessionKey {
21033 instance_id: AgentInstanceId(311),
21034 generation: SessionGeneration(1),
21035 },
21036 };
21037 let (_overlay, guard) = shared
21038 .prepare_session_materialization(
21039 &address,
21040 &agent("codex"),
21041 SessionMode::Pty,
21042 Some(&receipt),
21043 Some(&bundle_receipt),
21044 None,
21045 None,
21046 )
21047 .unwrap()
21048 .unwrap();
21049 let materialization_id = guard.id().unwrap().clone();
21050 let skill_path = shared
21051 .materializations
21052 .lock()
21053 .unwrap_or_else(|poisoned| poisoned.into_inner())
21054 .get(&materialization_id)
21055 .unwrap()
21056 .provider_home()
21057 .join("skills/review-code/SKILL.md");
21058 let record_id = shared
21059 .bind_spawn_session_with_materialization(
21060 &address,
21061 agent("codex"),
21062 SessionMode::Pty,
21063 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21064 SpawnRecordPolicy::ProviderIdentityOnly,
21065 Some(receipt.clone()),
21066 Some(bundle_receipt.clone()),
21067 None,
21068 Some(materialization_id.clone()),
21069 )
21070 .unwrap()
21071 .unwrap();
21072 guard.retain();
21073 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21074
21075 let error = match shared.resolve_record_materialization(
21076 &record_id,
21077 &agent("codex"),
21078 SessionMode::Pty,
21079 Some(&receipt),
21080 Some(&bundle_receipt),
21081 None,
21082 ) {
21083 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21084 Err(error) => error,
21085 };
21086 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21087 assert_eq!(
21088 shared
21089 .materializations
21090 .lock()
21091 .unwrap_or_else(|poisoned| poisoned.into_inner())
21092 .get(&materialization_id)
21093 .unwrap()
21094 .state(),
21095 MaterializationState::RecoveryRequired,
21096 );
21097 drop(shared);
21098 drop(runtime);
21099 let _ = std::fs::remove_dir_all(root);
21100 }
21101
21102 fn bind_record_owned_materialization_fixture(
21103 shared: &NodeShared,
21104 receipt: &ResolvedEnvironmentProfileReceipt,
21105 instance_id: u64,
21106 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21107 let address = SessionAddress {
21108 workspace_id: WorkspaceId::new("primary").unwrap(),
21109 session: SessionKey {
21110 instance_id: AgentInstanceId(instance_id),
21111 generation: SessionGeneration(1),
21112 },
21113 };
21114 let (overlay, materialization_guard) = shared
21115 .prepare_session_materialization(
21116 &address,
21117 &agent("claude"),
21118 SessionMode::Pty,
21119 Some(receipt),
21120 None,
21121 None,
21122 None,
21123 )
21124 .unwrap()
21125 .unwrap();
21126 let materialization_id = materialization_guard.id().unwrap().clone();
21127 let materialization_root = shared
21128 .materializations
21129 .lock()
21130 .unwrap_or_else(|poisoned| poisoned.into_inner())
21131 .get(&materialization_id)
21132 .unwrap()
21133 .root()
21134 .to_path_buf();
21135 let selection = shared
21136 .select_environment_profile(
21137 address.session.instance_id,
21138 &agent("claude"),
21139 SessionMode::Pty,
21140 Some(receipt),
21141 )
21142 .unwrap()
21143 .unwrap();
21144 assert!(shared
21145 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21146 .unwrap()
21147 .is_none());
21148 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21149 let record_id = shared
21150 .bind_spawn_session_with_materialization(
21151 &address,
21152 agent("claude"),
21153 SessionMode::Pty,
21154 policy,
21155 SpawnRecordPolicy::ProviderIdentityOnly,
21156 Some(receipt.clone()),
21157 None,
21158 None,
21159 Some(materialization_id.clone()),
21160 )
21161 .unwrap()
21162 .unwrap();
21163 selection.retain();
21164 materialization_guard.retain();
21165 (address, record_id, materialization_id, materialization_root)
21166 }
21167
21168 #[test]
21169 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21170 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21171 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21172 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21173 }));
21174 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21175 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21176 let mut spec = spawn_spec_fixture();
21177 spec.expected_profile_revision =
21178 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21179 assert!(matches!(
21180 &spec.overrides.environment_profile_id,
21181 crate::protocol::SpawnOverride::Inherit,
21182 ));
21183 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21184 .requires_child_environment_profile_capability());
21185 assert!(request_requires_child_environment_profile(
21186 &shared,
21187 &NodeRequest::SpawnSpec { spec: spec.clone() },
21188 ));
21189
21190 let address = terminal_address(1);
21191 shared.bind_session_with_policy(
21192 &address,
21193 ProviderRuntimePolicy::raw_pty(),
21194 Some(environment_profile.clone()),
21195 );
21196 assert!(request_requires_child_environment_profile(
21197 &shared,
21198 &NodeRequest::Input {
21199 session: address.clone(),
21200 text: "x".to_owned(),
21201 },
21202 ));
21203
21204 let mut record = record("claude", "profile-record");
21205 record.active_session = Some(address.clone());
21206 record.environment_profile = Some(environment_profile.clone());
21207 let record_id = record.record_id.clone();
21208 shared.insert_record(record.clone()).unwrap();
21209 assert!(request_requires_child_environment_profile(
21210 &shared,
21211 &NodeRequest::ResumeSessionRecord {
21212 record_id,
21213 terminal_size: gate4agent_types::TerminalSize {
21214 rows: 24,
21215 columns: 80,
21216 },
21217 initial_prompt: None,
21218 },
21219 ));
21220
21221 let snapshot = snapshot_for_wire(
21222 &shared, false, true, true, true, false, false, false, false, true,
21223 );
21224 assert_eq!(snapshot.session_records.len(), 1);
21225 assert!(snapshot.session_records[0].environment_profile.is_none());
21226 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21227 sequence: 1,
21228 event: NodeEvent::SessionRecordUpserted { record },
21229 });
21230 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21231 panic!("record projection changed the event kind");
21232 };
21233 assert!(record.environment_profile.is_none());
21234
21235 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21236 let mut reply = ResponseEnvelope {
21237 request_id: 1,
21238 result: Ok(NodeResponse::SpawnSpecAccepted {
21239 receipt: resolved.receipt_with_environment(
21240 shared.incarnation_id,
21241 address,
21242 Some(environment_profile),
21243 ),
21244 }),
21245 };
21246 project_response_without_child_environment_profile(&mut reply);
21247 assert_eq!(
21248 reply.result.unwrap_err().code,
21249 NodeFailureCode::UnsupportedCapability,
21250 );
21251 }
21252
21253 #[test]
21254 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21255 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21256 let snapshot = shared.snapshot();
21257 let profiles = snapshot
21258 .launch_inventory
21259 .as_ref()
21260 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21261 .unwrap();
21262 assert_eq!(profiles.len(), 1);
21263 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21264 assert!(snapshot.requires_child_environment_profile_capability());
21265
21266 let mut legacy = snapshot.clone();
21267 clear_snapshot_child_environment_profiles(&mut legacy);
21268 assert!(legacy
21269 .launch_inventory
21270 .as_ref()
21271 .unwrap()
21272 .spawn_profiles
21273 .as_ref()
21274 .unwrap()[0]
21275 .environment_profile
21276 .is_none());
21277 assert!(!legacy.requires_child_environment_profile_capability());
21278
21279 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21280 let bindings = shared
21281 .environment_profiles
21282 .read()
21283 .unwrap_or_else(|poisoned| poisoned.into_inner())
21284 .clone();
21285 let mut provider_mismatch = bindings.clone();
21286 provider_mismatch
21287 .values_mut()
21288 .next()
21289 .unwrap()
21290 .provider = agent("codex");
21291 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21292 let mut mode_mismatch = profile;
21293 mode_mismatch.mode = SessionMode::Inline;
21294 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21295
21296 shared
21297 .environment_profiles
21298 .write()
21299 .unwrap_or_else(|poisoned| poisoned.into_inner())
21300 .clear();
21301 assert!(shared
21302 .snapshot()
21303 .launch_inventory
21304 .unwrap()
21305 .spawn_profiles
21306 .unwrap()
21307 .is_empty());
21308 }
21309
21310 #[test]
21311 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21312 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21313 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21314 }));
21315 let shared = terminal_test_shared();
21316 let bundle = ResolvedBundleReceipt {
21317 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21318 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21319 digest: crate::protocol::SpawnBundleDigest::new(format!(
21320 "sha256:{}",
21321 "0".repeat(64),
21322 ))
21323 .unwrap(),
21324 };
21325 let mut bundled = record("claude", "bundle-record");
21326 bundled.bundle = Some(bundle.clone());
21327 let record_id = bundled.record_id.clone();
21328 shared.insert_record(bundled.clone()).unwrap();
21329 assert!(request_requires_session_bundle(
21330 &shared,
21331 &NodeRequest::ForgetSessionRecord {
21332 record_id: record_id.clone(),
21333 },
21334 ));
21335
21336 let snapshot = snapshot_for_wire(
21337 &shared, false, true, true, true, true, false, false, false, true,
21338 );
21339 assert!(snapshot.session_records[0].bundle.is_none());
21340 let launch = snapshot.launch_inventory.as_ref().unwrap();
21341 assert!(launch.spawn_profiles.is_some());
21342 assert!(launch.bundles.is_none());
21343 let event = project_event_without_session_bundle(NodeEventEnvelope {
21344 sequence: 1,
21345 event: NodeEvent::SessionRecordUpserted { record: bundled },
21346 });
21347 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21348 panic!("record projection changed the event kind");
21349 };
21350 assert!(record.bundle.is_none());
21351
21352 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21353 let mut reply = ResponseEnvelope {
21354 request_id: 1,
21355 result: Ok(NodeResponse::SpawnSpecAccepted {
21356 receipt: resolved.receipt_with_materialization(
21357 shared.incarnation_id,
21358 terminal_address(17),
21359 None,
21360 Some(bundle),
21361 None,
21362 ),
21363 }),
21364 };
21365 project_response_without_session_bundle(&mut reply);
21366 assert_eq!(
21367 reply.result.unwrap_err().code,
21368 NodeFailureCode::UnsupportedCapability,
21369 );
21370 }
21371
21372 #[test]
21373 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21374 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21375 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21376 }));
21377 let context_id = SpawnContextId::new("context-review").unwrap();
21378 let context = ResolvedContextPackReceipt {
21379 id: context_id.clone(),
21380 digest: crate::protocol::SpawnContextDigest::new(format!(
21381 "sha256:{}",
21382 "c".repeat(64),
21383 ))
21384 .unwrap(),
21385 lineage: ContextPackLineageReceipt {
21386 source_node_id: NodeId::new("node-source").unwrap(),
21387 source_session: terminal_address(1),
21388 source_provider: agent("kimi"),
21389 },
21390 source_message_count: 2,
21391 retained_message_count: 2,
21392 byte_len: 256,
21393 truncated: false,
21394 };
21395 assert!(context.is_valid());
21396 let mut bound_record = record("codex", "context-record");
21397 bound_record.context_id = Some(context_id);
21398 bound_record.context = Some(context.clone());
21399 let session = gate4agent_types::SessionSnapshot {
21400 instance_id: AgentInstanceId(91),
21401 agent_id: agent("kimi"),
21402 transport: TransportKind::Pty,
21403 generation: SessionGeneration(3),
21404 status: SessionStatus::Running,
21405 pending_operation: None,
21406 pending_input: None,
21407 process_id: Some(9001),
21408 terminal_size: None,
21409 terminal_frame: None,
21410 terminal_stale: None,
21411 session_options: None,
21412 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21413 history: gate4agent_types::HistorySnapshot {
21414 pending: None,
21415 candidates: vec![HistoryCandidateSummary {
21416 id: "candidate-opaque".to_owned(),
21417 session_id_hint: "session-hint".to_owned(),
21418 modified_at_unix_ms: Some(1),
21419 }],
21420 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21421 loaded: Some(HistorySessionRecord {
21422 session_id: "private-vendor-session".to_owned(),
21423 title: Some("private title".to_owned()),
21424 cwd: Some(r"C:\private\history-root".to_owned()),
21425 model: Some("private-model".to_owned()),
21426 message_count: 2,
21427 completed_turn_count: None,
21428 total_tokens: 19,
21429 messages: vec![
21430 gate4agent_types::HistoryMessageRecord {
21431 role: gate4agent_types::HistoryMessageRole::User,
21432 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21433 },
21434 gate4agent_types::HistoryMessageRecord {
21435 role: gate4agent_types::HistoryMessageRole::Assistant,
21436 text: "private answer".to_owned(),
21437 },
21438 ],
21439 }),
21440 last_error: None,
21441 },
21442 resume: gate4agent_types::ResumeSnapshot::default(),
21443 foreground: gate4agent_types::ForegroundSnapshot::default(),
21444 provider: gate4agent_types::ProviderSnapshot::default(),
21445 screen_state: gate4agent_types::PtyScreenState::default(),
21446 };
21447 let original = NodeSnapshot {
21448 node_id: NodeId::new("node-terminal-test").unwrap(),
21449 enabled_providers: vec![agent("kimi"), agent("codex")],
21450 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21451 workspaces: vec![WorkspaceSnapshot {
21452 workspace_id: WorkspaceId::new("primary").unwrap(),
21453 canonical_root: opaque_windows_path(
21454 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21455 ),
21456 sessions: vec![session],
21457 worktree_service_mode: None,
21458 managed_worktree_profiles: None,
21459 }],
21460 session_records: vec![bound_record],
21461 managed_worktrees: Vec::new(),
21462 launch_inventory: None,
21463 agent_progress: Vec::new(),
21464 };
21465
21466 let mut negotiated = original.clone();
21467 project_snapshot_history_for_wire(&mut negotiated, true);
21468 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21469 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21470 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21471 assert!(!serde_json::to_string(&negotiated)
21472 .unwrap()
21473 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21474
21475 let mut legacy = original;
21476 project_snapshot_history_for_wire(&mut legacy, false);
21477 clear_snapshot_context_packs(&mut legacy);
21478 assert_eq!(
21479 legacy.workspaces[0].sessions[0].history,
21480 gate4agent_types::HistorySnapshot::default(),
21481 );
21482 assert!(legacy.session_records[0].context_id.is_none());
21483 assert!(legacy.session_records[0].context.is_none());
21484 let encoded = serde_json::to_string(&legacy).unwrap();
21485 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21486 assert!(!encoded.contains("context-review"));
21487
21488 let mut reply = ResponseEnvelope {
21489 request_id: 7,
21490 result: Ok(NodeResponse::ContextPackExported { context }),
21491 };
21492 project_response_without_context_pack(&mut reply);
21493 assert_eq!(
21494 reply.result.unwrap_err().code,
21495 NodeFailureCode::UnsupportedCapability,
21496 );
21497 }
21498
21499 #[test]
21500 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21501 let shared = terminal_test_shared();
21502 let history = HistorySessionRecord {
21503 session_id: "fixture-history".to_owned(),
21504 title: None,
21505 cwd: None,
21506 model: None,
21507 message_count: 1,
21508 completed_turn_count: None,
21509 total_tokens: 0,
21510 messages: vec![gate4agent_types::HistoryMessageRecord {
21511 role: gate4agent_types::HistoryMessageRole::User,
21512 text: "bounded handoff".to_owned(),
21513 }],
21514 };
21515 let pack = NodeContextPack::export(
21516 ContextPackLineageReceipt {
21517 source_node_id: shared.node_id.clone(),
21518 source_session: terminal_address(1),
21519 source_provider: agent("claude"),
21520 },
21521 &history,
21522 )
21523 .unwrap();
21524 let context = shared
21525 .context_catalog
21526 .write()
21527 .unwrap_or_else(|poisoned| poisoned.into_inner())
21528 .insert(pack)
21529 .unwrap();
21530 let mut retained = record("codex", "context-retained-record");
21531 retained.context_id = Some(context.id.clone());
21532 retained.context = Some(context.clone());
21533 let record_id = retained.record_id.clone();
21534 shared.insert_record(retained).unwrap();
21535 assert_eq!(
21536 shared.forget_context_pack(&context.id).unwrap_err().code,
21537 NodeFailureCode::ContextPackBusy,
21538 );
21539 assert!(shared
21540 .context_catalog
21541 .read()
21542 .unwrap_or_else(|poisoned| poisoned.into_inner())
21543 .get(&context.id)
21544 .is_some());
21545 shared
21546 .session_records
21547 .lock()
21548 .unwrap_or_else(|poisoned| poisoned.into_inner())
21549 .records
21550 .remove(&record_id);
21551
21552 let mut spec = spawn_spec_fixture();
21553 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21554 value: context.id.clone(),
21555 };
21556 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21557 let receipt = resolved.receipt_with_materialization(
21558 shared.incarnation_id,
21559 terminal_address(2),
21560 None,
21561 None,
21562 Some(context.clone()),
21563 );
21564 assert!(receipt.context_binding_is_valid());
21565 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21566
21567 shared.forget_context_pack(&context.id).unwrap();
21568 assert!(shared
21569 .context_catalog
21570 .read()
21571 .unwrap_or_else(|poisoned| poisoned.into_inner())
21572 .get(&context.id)
21573 .is_none());
21574 assert!(shared
21575 .replay_spawn_spec(&spec, Instant::now())
21576 .unwrap()
21577 .is_none());
21578 }
21579
21580 #[test]
21581 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21582 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21583 let address = terminal_address(1);
21584 let selection = shared
21585 .select_environment_profile(
21586 address.session.instance_id,
21587 &agent("claude"),
21588 SessionMode::Pty,
21589 Some(&environment_profile),
21590 )
21591 .unwrap()
21592 .unwrap();
21593 shared.bind_session_with_policy(
21594 &address,
21595 ProviderRuntimePolicy::raw_pty(),
21596 Some(environment_profile.clone()),
21597 );
21598 selection.retain();
21599 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21600 assert!(matches!(
21601 runtime.remove_native_launch_profile(&native_id),
21602 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21603 ));
21604 assert!(shared.remove_binding(&address).is_some());
21605 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21606
21607 let mismatched = ResolvedEnvironmentProfileReceipt {
21608 profile_id: environment_profile.profile_id,
21609 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21610 "local-claude-r2",
21611 )
21612 .unwrap(),
21613 network_allowlist: None,
21614 browser_profile_id: None,
21615 };
21616 let error = match shared.select_environment_profile(
21617 AgentInstanceId(42),
21618 &agent("claude"),
21619 SessionMode::Pty,
21620 Some(&mismatched),
21621 ) {
21622 Ok(_) => panic!("mismatched environment profile revision was selected"),
21623 Err(error) => error,
21624 };
21625 assert_eq!(
21626 error.code,
21627 NodeFailureCode::EnvironmentProfileBindingMismatch,
21628 );
21629 assert!(!shared
21630 .session_bindings
21631 .lock()
21632 .unwrap_or_else(|poisoned| poisoned.into_inner())
21633 .contains_key(&AgentInstanceId(42)));
21634 }
21635
21636 #[test]
21637 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21638 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21639 let address = terminal_address(1);
21640 let selection = shared
21641 .select_environment_profile(
21642 address.session.instance_id,
21643 &agent("claude"),
21644 SessionMode::Pty,
21645 Some(&environment_profile),
21646 )
21647 .unwrap()
21648 .unwrap();
21649 let overlay = NativeInstanceLaunchOverlay::new(
21650 agent("claude"),
21651 TransportKind::Pty,
21652 vec![EnvMutation {
21653 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21654 value: Some(OsString::from("fixture-secret")),
21655 }],
21656 vec![
21657 OsString::from("--plugin-dir"),
21658 std::env::current_dir().unwrap().into_os_string(),
21659 ],
21660 )
21661 .unwrap();
21662 let instance_overlay = shared
21663 .install_prepared_launch_overlay(
21664 address.session.instance_id,
21665 PreparedNativeLaunchOverlay::Instance(overlay),
21666 )
21667 .unwrap()
21668 .unwrap();
21669 let bundle = ResolvedBundleReceipt {
21670 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21671 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21672 digest: crate::protocol::SpawnBundleDigest::new(format!(
21673 "sha256:{}",
21674 "0".repeat(64),
21675 ))
21676 .unwrap(),
21677 };
21678 shared.bind_session_with_materialization(
21679 &address,
21680 ProviderRuntimePolicy::raw_pty(),
21681 Some(environment_profile),
21682 Some(bundle),
21683 None,
21684 None,
21685 );
21686 selection.retain();
21687 instance_overlay.retain();
21688
21689 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21690 assert!(matches!(
21691 runtime.remove_native_launch_profile(&native_id),
21692 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21693 ));
21694 assert!(shared.remove_binding(&address).is_some());
21695 assert!(!shared
21696 .native_launch_profile_control
21697 .as_ref()
21698 .unwrap()
21699 .clear_native_instance_launch_overlay(address.session.instance_id));
21700 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21701 }
21702
21703 #[test]
21704 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21705 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21706 let address = SessionAddress {
21707 workspace_id: WorkspaceId::new("primary").unwrap(),
21708 session: SessionKey {
21709 instance_id: AgentInstanceId(201),
21710 generation: SessionGeneration(1),
21711 },
21712 };
21713 let error = match shared.prepare_session_materialization(
21714 &address,
21715 &agent("claude"),
21716 SessionMode::Pty,
21717 Some(&receipt),
21718 None,
21719 None,
21720 None,
21721 ) {
21722 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21723 Err(error) => error,
21724 };
21725 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21726 assert!(shared.handle.snapshot().sessions.is_empty());
21727 assert!(shared
21728 .session_bindings
21729 .lock()
21730 .unwrap_or_else(|poisoned| poisoned.into_inner())
21731 .is_empty());
21732 assert!(shared
21733 .materializations
21734 .lock()
21735 .unwrap_or_else(|poisoned| poisoned.into_inner())
21736 .is_empty());
21737 drop(shared);
21738 drop(runtime);
21739 std::fs::remove_dir_all(root).unwrap();
21740 }
21741
21742 #[test]
21743 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21744 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21745 let address = SessionAddress {
21746 workspace_id: WorkspaceId::new("primary").unwrap(),
21747 session: SessionKey {
21748 instance_id: AgentInstanceId(202),
21749 generation: SessionGeneration(1),
21750 },
21751 };
21752 let (overlay, materialization_guard) = shared
21753 .prepare_session_materialization(
21754 &address,
21755 &agent("claude"),
21756 SessionMode::Pty,
21757 Some(&receipt),
21758 None,
21759 None,
21760 None,
21761 )
21762 .unwrap()
21763 .unwrap();
21764 let materialization_id = materialization_guard.id().unwrap().clone();
21765 let materialization_root = shared
21766 .materializations
21767 .lock()
21768 .unwrap_or_else(|poisoned| poisoned.into_inner())
21769 .get(&materialization_id)
21770 .unwrap()
21771 .root()
21772 .to_path_buf();
21773 let selection = shared
21774 .select_environment_profile(
21775 address.session.instance_id,
21776 &agent("claude"),
21777 SessionMode::Pty,
21778 Some(&receipt),
21779 )
21780 .unwrap()
21781 .unwrap();
21782 assert!(shared
21783 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21784 .unwrap()
21785 .is_none());
21786 shared.bind_session_with_materialization(
21787 &address,
21788 ProviderRuntimePolicy::raw_pty(),
21789 Some(receipt),
21790 None,
21791 None,
21792 Some(materialization_id),
21793 );
21794 selection.retain();
21795 materialization_guard.retain();
21796
21797 assert!(materialization_root.is_dir());
21798 assert!(shared
21799 .session_bindings
21800 .lock()
21801 .unwrap_or_else(|poisoned| poisoned.into_inner())
21802 .contains_key(&address.session.instance_id));
21803
21804 let removed = shared.remove_binding(&address).unwrap();
21805 shared
21806 .cleanup_session_owned_materialization(&address, &removed)
21807 .unwrap();
21808 assert!(!materialization_root.exists());
21809 assert!(shared
21810 .materializations
21811 .lock()
21812 .unwrap_or_else(|poisoned| poisoned.into_inner())
21813 .is_empty());
21814 drop(shared);
21815 drop(runtime);
21816 std::fs::remove_dir_all(root).unwrap();
21817 }
21818
21819 #[tokio::test]
21820 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21821 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21822 let address = SessionAddress {
21823 workspace_id: WorkspaceId::new("primary").unwrap(),
21824 session: SessionKey {
21825 instance_id: AgentInstanceId(203),
21826 generation: SessionGeneration(1),
21827 },
21828 };
21829 let (overlay, materialization_guard) = shared
21830 .prepare_session_materialization(
21831 &address,
21832 &agent("claude"),
21833 SessionMode::Pty,
21834 Some(&receipt),
21835 None,
21836 None,
21837 None,
21838 )
21839 .unwrap()
21840 .unwrap();
21841 let materialization_id = materialization_guard.id().unwrap().clone();
21842 let materialization_root = shared
21843 .materializations
21844 .lock()
21845 .unwrap_or_else(|poisoned| poisoned.into_inner())
21846 .get(&materialization_id)
21847 .unwrap()
21848 .root()
21849 .to_path_buf();
21850 let selection = shared
21851 .select_environment_profile(
21852 address.session.instance_id,
21853 &agent("claude"),
21854 SessionMode::Pty,
21855 Some(&receipt),
21856 )
21857 .unwrap()
21858 .unwrap();
21859 assert!(shared
21860 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21861 .unwrap()
21862 .is_none());
21863 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21864 let record_id = shared
21865 .bind_spawn_session_with_materialization(
21866 &address,
21867 agent("claude"),
21868 SessionMode::Pty,
21869 policy,
21870 SpawnRecordPolicy::ProviderIdentityOnly,
21871 Some(receipt.clone()),
21872 None,
21873 None,
21874 Some(materialization_id.clone()),
21875 )
21876 .unwrap()
21877 .unwrap();
21878 selection.retain();
21879 materialization_guard.retain();
21880 assert_eq!(
21881 shared
21882 .materializations
21883 .lock()
21884 .unwrap_or_else(|poisoned| poisoned.into_inner())
21885 .get(&materialization_id)
21886 .unwrap()
21887 .owner(),
21888 &MaterializationOwner::Record {
21889 record_id: record_id.clone(),
21890 },
21891 );
21892
21893 let removed = shared.remove_binding(&address).unwrap();
21894 shared
21895 .cleanup_session_owned_materialization(&address, &removed)
21896 .unwrap();
21897 assert!(materialization_root.is_dir());
21898 assert!(shared
21899 .resolve_record_materialization(
21900 &record_id,
21901 &agent("claude"),
21902 SessionMode::Pty,
21903 Some(&receipt),
21904 None,
21905 None,
21906 )
21907 .unwrap()
21908 .is_some());
21909 {
21910 let mut records = shared
21911 .session_records
21912 .lock()
21913 .unwrap_or_else(|poisoned| poisoned.into_inner());
21914 let record = records.records.get_mut(&record_id).unwrap();
21915 record.active_session = None;
21916 record.state = ManagedSessionState::Unavailable;
21917 }
21918 shared.forget_session_record(&record_id).await.unwrap();
21919 assert!(!materialization_root.exists());
21920 assert!(shared
21921 .materializations
21922 .lock()
21923 .unwrap_or_else(|poisoned| poisoned.into_inner())
21924 .is_empty());
21925 drop(shared);
21926 drop(runtime);
21927 std::fs::remove_dir_all(root).unwrap();
21928 }
21929
21930 #[test]
21931 fn record_materialization_revalidation_failure_persists_recovery_required() {
21932 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21933 let state_path = root.join("node-state-v6.json");
21934 shared.state_path = Some(state_path.clone());
21935 let (_address, _record_id, materialization_id, materialization_root) =
21936 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21937 std::fs::write(
21938 materialization_root.join(".gate4agent-materialization-owner"),
21939 b"tampered",
21940 )
21941 .unwrap();
21942
21943 let error = match shared.resolve_record_materialization(
21944 &_record_id,
21945 &agent("claude"),
21946 SessionMode::Pty,
21947 Some(&receipt),
21948 None,
21949 None,
21950 ) {
21951 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21952 Err(error) => error,
21953 };
21954 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21955 assert_eq!(
21956 shared
21957 .materializations
21958 .lock()
21959 .unwrap_or_else(|poisoned| poisoned.into_inner())
21960 .get(&materialization_id)
21961 .unwrap()
21962 .state(),
21963 MaterializationState::RecoveryRequired,
21964 );
21965 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21966 assert_eq!(loaded.materializations.len(), 1);
21967 assert_eq!(
21968 loaded.materializations[0].state(),
21969 MaterializationState::RecoveryRequired,
21970 );
21971 drop(shared);
21972 drop(runtime);
21973 std::fs::remove_dir_all(root).unwrap();
21974 }
21975
21976 #[test]
21977 fn transient_record_materialization_resolution_failure_remains_ready() {
21978 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21979 let (_address, record_id, materialization_id, _materialization_root) =
21980 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21981 deny.store(true, Ordering::Release);
21982
21983 let error = match shared.resolve_record_materialization(
21984 &record_id,
21985 &agent("claude"),
21986 SessionMode::Pty,
21987 Some(&receipt),
21988 None,
21989 None,
21990 ) {
21991 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21992 Err(error) => error,
21993 };
21994 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21995 assert_eq!(
21996 shared
21997 .materializations
21998 .lock()
21999 .unwrap_or_else(|poisoned| poisoned.into_inner())
22000 .get(&materialization_id)
22001 .unwrap()
22002 .state(),
22003 MaterializationState::Ready,
22004 );
22005 drop(shared);
22006 drop(runtime);
22007 std::fs::remove_dir_all(root).unwrap();
22008 }
22009
22010 #[test]
22011 fn startup_missing_record_materialization_is_not_resumable() {
22012 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22013 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22014 shared
22015 .insert_record(ManagedSessionRecord {
22016 record_id: record_id.clone(),
22017 display_name: "missing materialization".to_owned(),
22018 provider: agent("claude"),
22019 mode: SessionMode::Pty,
22020 state: ManagedSessionState::Dormant,
22021 workspace_id: WorkspaceId::new("primary").unwrap(),
22022 canonical_root: opaque_windows_path(
22023 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22024 ),
22025 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22026 key: gate4agent_types::ProviderSessionKey::SessionId,
22027 id: "provider-session-missing-materialization".to_owned(),
22028 transcript_path: None,
22029 }),
22030 active_session: None,
22031 environment_profile: Some(receipt),
22032 bundle: None,
22033 context_id: None,
22034 context: None,
22035 exported_context: None,
22036 task_binding: None,
22037 created_at_unix_ms: 1,
22038 updated_at_unix_ms: 1,
22039 last_error: None,
22040 })
22041 .unwrap();
22042
22043 shared.reconcile_materializations();
22044 let record = shared.record(&record_id).unwrap();
22045 assert_eq!(record.state, ManagedSessionState::Unavailable);
22046 assert!(record.provider_session.is_none());
22047 assert_eq!(
22048 record.last_error.as_deref(),
22049 Some("environment-profile-unavailable"),
22050 );
22051 drop(shared);
22052 drop(runtime);
22053 std::fs::remove_dir_all(root).unwrap();
22054 }
22055
22056 #[tokio::test]
22057 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22058 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22059 let lease = install_managed_lease(&shared);
22060 let address = SessionAddress {
22061 workspace_id: WorkspaceId::new("primary").unwrap(),
22062 session: SessionKey {
22063 instance_id: AgentInstanceId(204),
22064 generation: SessionGeneration(1),
22065 },
22066 };
22067 let (overlay, materialization_guard) = shared
22068 .prepare_session_materialization(
22069 &address,
22070 &agent("claude"),
22071 SessionMode::Pty,
22072 Some(&receipt),
22073 None,
22074 None,
22075 Some(lease.lease_id.clone()),
22076 )
22077 .unwrap()
22078 .unwrap();
22079 let materialization_id = materialization_guard.id().unwrap().clone();
22080 let materialization_root = shared
22081 .materializations
22082 .lock()
22083 .unwrap_or_else(|poisoned| poisoned.into_inner())
22084 .get(&materialization_id)
22085 .unwrap()
22086 .root()
22087 .to_path_buf();
22088 let selection = shared
22089 .select_environment_profile(
22090 address.session.instance_id,
22091 &agent("claude"),
22092 SessionMode::Pty,
22093 Some(&receipt),
22094 )
22095 .unwrap()
22096 .unwrap();
22097 assert!(shared
22098 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22099 .unwrap()
22100 .is_none());
22101 shared.bind_session_with_materialization(
22102 &address,
22103 ProviderRuntimePolicy::raw_pty(),
22104 Some(receipt),
22105 None,
22106 None,
22107 Some(materialization_id),
22108 );
22109 selection.retain();
22110 materialization_guard.retain();
22111
22112 let blocked = shared
22113 .cleanup_managed_worktree(&lease.lease_id, false)
22114 .await
22115 .unwrap_err();
22116 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22117 assert!(materialization_root.is_dir());
22118 let removed = shared.remove_binding(&address).unwrap();
22119 shared
22120 .cleanup_session_owned_materialization(&address, &removed)
22121 .unwrap();
22122 assert!(!materialization_root.exists());
22123 drop(shared);
22124 drop(runtime);
22125 std::fs::remove_dir_all(root).unwrap();
22126 }
22127
22128 #[tokio::test]
22129 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22130 let git_root = temporary_workspace_root("raw-managed-inventory");
22131 let source = git_root.join("source");
22132 let allocation = git_root.join("allocation");
22133 std::fs::create_dir_all(&source).unwrap();
22134 std::fs::create_dir_all(&allocation).unwrap();
22135 run_test_git(&source, &["init"]);
22136 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22137 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22138 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22139 run_test_git(&source, &["add", "seed.txt"]);
22140 run_test_git(&source, &["commit", "-m", "seed"]);
22141 let profile = ManagedWorktreeProfile::new(
22142 WorktreeProfileId::new("default").unwrap(),
22143 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22144 &allocation,
22145 "gate4agent",
22146 "HEAD",
22147 ManagedWorktreeRetention::RemoveWhenReleased,
22148 )
22149 .unwrap();
22150 let workspace = WorkspaceConfig::new(
22151 WorkspaceId::new("primary").unwrap(),
22152 &source,
22153 )
22154 .unwrap()
22155 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22156 .with_managed_worktree_profile(profile.clone())
22157 .unwrap();
22158 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22159 materialization_test_shared_with_workspace(false, workspace);
22160 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22161 let source_text = source.to_string_lossy().into_owned();
22162 let target_text = target.to_string_lossy().into_owned();
22163 let created = create_git_worktree(
22164 &source_text,
22165 &target_text,
22166 "gate4agent/mw-raw-inventory",
22167 Some("HEAD"),
22168 )
22169 .await
22170 .unwrap();
22171 let lease = ManagedWorktreeLeaseRecord {
22172 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22173 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22174 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22175 profile_id: profile.profile_id().clone(),
22176 profile_revision: profile.revision().clone(),
22177 target_root: created.path.clone(),
22178 branch: "gate4agent/mw-raw-inventory".to_owned(),
22179 base_commit: created.head.clone(),
22180 expected_head: Some(created.head.clone()),
22181 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22182 state: ManagedWorktreeLeaseState::Ready,
22183 session_holders: Vec::new(),
22184 record_holders: Vec::new(),
22185 cleanup_failure: None,
22186 created_at_unix_ms: 1,
22187 updated_at_unix_ms: 1,
22188 };
22189 shared
22190 .workspaces
22191 .write()
22192 .unwrap_or_else(|poisoned| poisoned.into_inner())
22193 .insert(lease.workspace_id.clone(), created.path.clone());
22194 *shared
22195 .managed_worktrees
22196 .lock()
22197 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22198 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22199 let address = SessionAddress {
22200 workspace_id: lease.workspace_id.clone(),
22201 session: SessionKey {
22202 instance_id: AgentInstanceId(205),
22203 generation: SessionGeneration(1),
22204 },
22205 };
22206 let (overlay, materialization_guard) = shared
22207 .prepare_session_materialization(
22208 &address,
22209 &agent("claude"),
22210 SessionMode::Pty,
22211 Some(&receipt),
22212 None,
22213 None,
22214 Some(lease.lease_id.clone()),
22215 )
22216 .unwrap()
22217 .unwrap();
22218 let materialization_id = materialization_guard.id().unwrap().clone();
22219 let materialization_root = shared
22220 .materializations
22221 .lock()
22222 .unwrap_or_else(|poisoned| poisoned.into_inner())
22223 .get(&materialization_id)
22224 .unwrap()
22225 .root()
22226 .to_path_buf();
22227 let selection = shared
22228 .select_environment_profile(
22229 address.session.instance_id,
22230 &agent("claude"),
22231 SessionMode::Pty,
22232 Some(&receipt),
22233 )
22234 .unwrap()
22235 .unwrap();
22236 assert!(shared
22237 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22238 .unwrap()
22239 .is_none());
22240 let record_id = shared
22241 .bind_spawn_session_with_materialization(
22242 &address,
22243 agent("claude"),
22244 SessionMode::Pty,
22245 ProviderRuntimePolicy::raw_pty(),
22246 SpawnRecordPolicy::Always,
22247 Some(receipt.clone()),
22248 None,
22249 None,
22250 Some(materialization_id.clone()),
22251 )
22252 .unwrap()
22253 .unwrap();
22254 selection.retain();
22255 materialization_guard.retain();
22256 let record = shared.record(&record_id).unwrap();
22257 assert_eq!(record.state, ManagedSessionState::Live);
22258 assert!(record.provider_session.is_none());
22259 assert!(record.environment_profile.is_none());
22260 assert!(record.bundle.is_none());
22261 assert!(record.context_id.is_none());
22262 assert!(record.context.is_none());
22263 assert_eq!(record.active_session.as_ref(), Some(&address));
22264 assert_eq!(
22265 shared
22266 .materializations
22267 .lock()
22268 .unwrap_or_else(|poisoned| poisoned.into_inner())
22269 .get(&materialization_id)
22270 .unwrap()
22271 .owner(),
22272 &MaterializationOwner::Session {
22273 incarnation_id: shared.incarnation_id,
22274 instance_id: address.session.instance_id,
22275 generation: address.session.generation,
22276 },
22277 );
22278 {
22279 let mut bindings = shared
22280 .session_bindings
22281 .lock()
22282 .unwrap_or_else(|poisoned| poisoned.into_inner());
22283 bindings
22284 .get_mut(&address.session.instance_id)
22285 .unwrap()
22286 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22287 }
22288 let lease_snapshot = shared
22289 .managed_worktrees
22290 .lock()
22291 .unwrap_or_else(|poisoned| poisoned.into_inner())
22292 .bind_session(
22293 &lease.lease_id,
22294 ManagedWorktreeSessionHolder {
22295 incarnation_id: shared.incarnation_id,
22296 instance_id: address.session.instance_id,
22297 generation: address.session.generation,
22298 },
22299 None,
22300 unix_time_ms(),
22301 )
22302 .unwrap();
22303 assert_eq!(lease_snapshot.active_session_count, 1);
22304 assert_eq!(lease_snapshot.managed_record_count, 0);
22305 shared.persist_state().unwrap();
22306 assert!(shared
22307 .managed_worktrees
22308 .lock()
22309 .unwrap_or_else(|poisoned| poisoned.into_inner())
22310 .get(&lease.lease_id)
22311 .unwrap()
22312 .record_holders
22313 .is_empty());
22314 shared
22315 .handle
22316 .dispatch(shared.prepare_command(ControlCommand::Register {
22317 instance_id: address.session.instance_id,
22318 agent_id: agent("claude"),
22319 transport: TransportKind::Pty,
22320 }))
22321 .unwrap();
22322 runtime.tick().await;
22323 shared.reconcile_managed_record(
22324 &record_id,
22325 &address,
22326 &ControlEvent {
22327 sequence: 1,
22328 command_id: None,
22329 instance_id: address.session.instance_id,
22330 generation: address.session.generation,
22331 event: ControlEventKind::Removed,
22332 },
22333 false,
22334 false,
22335 );
22336 let remove = shared.remove_session(&address);
22337 let drive_runtime = async {
22338 runtime.tick().await;
22339 };
22340 let (removed, ()) = tokio::join!(remove, drive_runtime);
22341 removed.unwrap();
22342
22343 assert!(!materialization_root.exists());
22344 assert!(!Path::new(&created.path).exists());
22345 assert_eq!(
22346 shared
22347 .managed_worktrees
22348 .lock()
22349 .unwrap_or_else(|poisoned| poisoned.into_inner())
22350 .get(&lease.lease_id)
22351 .unwrap()
22352 .state,
22353 ManagedWorktreeLeaseState::Removed,
22354 );
22355 let renamed = shared
22356 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22357 .unwrap();
22358 assert_eq!(renamed.record_id, record_id);
22359 assert_eq!(renamed.display_name, "raw managed inventory");
22360 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22361 assert!(renamed.active_session.is_none());
22362
22363 drop(shared);
22364 drop(runtime);
22365 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22366 std::fs::remove_dir_all(git_root).unwrap();
22367 }
22368
22369 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22370 let root = std::env::current_dir().unwrap()
22371 .join("managed-test-target")
22372 .to_string_lossy()
22373 .into_owned();
22374 let lease = ManagedWorktreeLeaseRecord {
22375 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22376 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22377 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22378 profile_id: WorktreeProfileId::new("default").unwrap(),
22379 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22380 target_root: root.clone(),
22381 branch: "gate4agent/mw-test".to_owned(),
22382 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22383 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22384 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22385 state: ManagedWorktreeLeaseState::Ready,
22386 session_holders: Vec::new(),
22387 record_holders: Vec::new(),
22388 cleanup_failure: None,
22389 created_at_unix_ms: 1,
22390 updated_at_unix_ms: 1,
22391 };
22392 shared.workspaces.write()
22393 .unwrap_or_else(|poisoned| poisoned.into_inner())
22394 .insert(lease.workspace_id.clone(), root);
22395 *shared.managed_worktrees.lock()
22396 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22397 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22398 lease
22399 }
22400
22401 #[cfg(feature = "fixture")]
22402 #[test]
22403 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22404 let mut shared = terminal_test_shared();
22405 shared.fixture_semantic_hook_policy = true;
22406 let lease = install_managed_lease(&shared);
22407 let address = SessionAddress {
22408 workspace_id: lease.workspace_id.clone(),
22409 session: SessionKey {
22410 instance_id: AgentInstanceId(206),
22411 generation: SessionGeneration(1),
22412 },
22413 };
22414 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22415 let effective_runtime_policy =
22416 shared.effective_spawn_runtime_policy(pre_admission_policy);
22417 assert_ne!(effective_runtime_policy, pre_admission_policy);
22418 assert!(effective_runtime_policy.provider_session_identity);
22419
22420 let record_id = shared
22421 .bind_spawn_session(
22422 &address,
22423 agent("claude"),
22424 SessionMode::Pty,
22425 effective_runtime_policy,
22426 SpawnRecordPolicy::Always,
22427 None,
22428 )
22429 .unwrap()
22430 .unwrap();
22431 assert_eq!(
22432 shared.record(&record_id).unwrap().state,
22433 ManagedSessionState::IdentityPending,
22434 );
22435 let (record_holder, raw_inventory_record_id) = {
22436 let mut bindings = shared
22437 .session_bindings
22438 .lock()
22439 .unwrap_or_else(|poisoned| poisoned.into_inner());
22440 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22441 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22442 managed_spawn_record_ownership(binding, effective_runtime_policy)
22443 };
22444 assert_eq!(record_holder.as_ref(), Some(&record_id));
22445 assert!(raw_inventory_record_id.is_none());
22446 let snapshot = shared
22447 .managed_worktrees
22448 .lock()
22449 .unwrap_or_else(|poisoned| poisoned.into_inner())
22450 .bind_session(
22451 &lease.lease_id,
22452 ManagedWorktreeSessionHolder {
22453 incarnation_id: shared.incarnation_id,
22454 instance_id: address.session.instance_id,
22455 generation: address.session.generation,
22456 },
22457 record_holder,
22458 unix_time_ms(),
22459 )
22460 .unwrap();
22461 assert_eq!(snapshot.managed_record_count, 1);
22462 assert_eq!(
22463 shared
22464 .managed_worktrees
22465 .lock()
22466 .unwrap_or_else(|poisoned| poisoned.into_inner())
22467 .get(&lease.lease_id)
22468 .unwrap()
22469 .record_holders,
22470 vec![record_id.clone()],
22471 );
22472
22473 let identity = ProviderSessionIdentity {
22474 key: ProviderSessionKey::SessionId,
22475 id: "monitoring-fixture-managed-session".to_owned(),
22476 transcript_path: None,
22477 };
22478 shared.publish_control(ControlEvent {
22479 sequence: 1,
22480 command_id: None,
22481 instance_id: address.session.instance_id,
22482 generation: address.session.generation,
22483 event: ControlEventKind::ProviderEvent {
22484 sequence: 1,
22485 source: gate4agent_types::ProviderSource {
22486 family: AdapterFamily::Hook,
22487 binding: AdapterBinding::new(
22488 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22489 "fixture/v1",
22490 gate4agent_types::AdapterVerification::SyntheticFixture,
22491 )
22492 .unwrap(),
22493 },
22494 source_sequence: 1,
22495 event: ProviderEvent::SessionIdentityObserved {
22496 identity: identity.clone(),
22497 },
22498 },
22499 });
22500
22501 let live = shared.record(&record_id).unwrap();
22502 assert_eq!(live.state, ManagedSessionState::Live);
22503 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22504 assert_eq!(live.active_session.as_ref(), Some(&address));
22505 assert_eq!(
22506 shared
22507 .managed_worktrees
22508 .lock()
22509 .unwrap_or_else(|poisoned| poisoned.into_inner())
22510 .get(&lease.lease_id)
22511 .unwrap()
22512 .record_holders,
22513 vec![record_id],
22514 );
22515 }
22516
22517 fn managed_v2_test_shared(
22518 allocation_root: &Path,
22519 state_path: Option<PathBuf>,
22520 profile_revision: &str,
22521 ) -> NodeShared {
22522 let profile = ManagedWorktreeProfile::new(
22523 WorktreeProfileId::new("default").unwrap(),
22524 WorktreeProfileRevision::new(profile_revision).unwrap(),
22525 allocation_root,
22526 "gate4agent",
22527 "HEAD",
22528 ManagedWorktreeRetention::RemoveWhenReleased,
22529 )
22530 .unwrap();
22531 let workspace = WorkspaceConfig::new(
22532 WorkspaceId::new("primary").unwrap(),
22533 std::env::current_dir().unwrap(),
22534 )
22535 .unwrap()
22536 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22537 .with_managed_worktree_profile(profile)
22538 .unwrap();
22539 let catalog = active_registry().unwrap();
22540 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22541 let mut shared = NodeShared::new(
22542 handle,
22543 "fixture-token".to_owned(),
22544 NodeId::new("node-terminal-test").unwrap(),
22545 vec![workspace],
22546 vec![agent("claude")],
22547 );
22548 shared.state_path = state_path;
22549 shared
22550 }
22551
22552 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22553 let mut spawn_spec = spawn_spec_fixture();
22554 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22555 ManagedWorktreeSpawnRequestV2 {
22556 spawn_spec,
22557 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22558 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22559 }
22560 }
22561
22562 #[test]
22563 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22564 let shared = terminal_test_shared();
22565 let mut lease = install_managed_lease(&shared);
22566 let session = SessionAddress {
22567 workspace_id: lease.workspace_id.clone(),
22568 session: SessionKey {
22569 instance_id: AgentInstanceId(73),
22570 generation: SessionGeneration(4),
22571 },
22572 };
22573 lease.state = ManagedWorktreeLeaseState::InUse;
22574 lease.session_holders.push(ManagedWorktreeSessionHolder {
22575 incarnation_id: shared.incarnation_id,
22576 instance_id: session.session.instance_id,
22577 generation: session.session.generation,
22578 });
22579 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22580 assert!(resolved.target.worktree_id.is_none());
22581
22582 let receipt = managed_spawn_receipt(
22583 &resolved,
22584 shared.incarnation_id,
22585 session.clone(),
22586 lease.snapshot(),
22587 None,
22588 None,
22589 None,
22590 );
22591 assert!(resolved.target.worktree_id.is_none());
22592 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22593 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22594 assert_eq!(receipt.spawn.session, session);
22595 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22596 let encoded = serde_json::to_vec(&receipt).unwrap();
22597 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22598 assert_eq!(decoded, receipt);
22599 }
22600
22601 #[tokio::test]
22602 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22603 let root = temporary_workspace_root("managed-v2-revision");
22604 let allocation = root.join("allocation");
22605 std::fs::create_dir_all(&allocation).unwrap();
22606 let shared = managed_v2_test_shared(
22607 &allocation,
22608 Some(root.join("node-state.json")),
22609 "v1",
22610 );
22611 let mut request = managed_v2_request("managed-v2-revision");
22612 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22613
22614 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22615
22616 assert_eq!(
22617 failure.code,
22618 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22619 );
22620 assert!(shared
22621 .managed_worktrees
22622 .lock()
22623 .unwrap_or_else(|poisoned| poisoned.into_inner())
22624 .records()
22625 .is_empty());
22626 assert!(shared
22627 .managed_spawn_replays
22628 .lock()
22629 .unwrap_or_else(|poisoned| poisoned.into_inner())
22630 .is_empty());
22631 std::fs::remove_dir_all(root).unwrap();
22632 }
22633
22634 #[test]
22635 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22636 let root = temporary_workspace_root("managed-v2-concurrent");
22637 let allocation = root.join("allocation");
22638 std::fs::create_dir_all(&allocation).unwrap();
22639 let shared = Arc::new(managed_v2_test_shared(
22640 &allocation,
22641 Some(root.join("node-state.json")),
22642 "v1",
22643 ));
22644 let request = managed_v2_request("managed-v2-concurrent");
22645 let barrier = Arc::new(std::sync::Barrier::new(3));
22646 let mut joins = Vec::new();
22647 for _ in 0..2 {
22648 let shared = Arc::clone(&shared);
22649 let request = request.clone();
22650 let barrier = Arc::clone(&barrier);
22651 joins.push(std::thread::spawn(move || {
22652 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22653 barrier.wait();
22654 shared.acquire_durable_managed_spawn_v2(&request, digest)
22655 }));
22656 }
22657 barrier.wait();
22658 let results = joins
22659 .into_iter()
22660 .map(|join| join.join().unwrap())
22661 .collect::<Vec<_>>();
22662 assert_eq!(
22663 results
22664 .iter()
22665 .filter(|result| {
22666 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22667 })
22668 .count(),
22669 1,
22670 );
22671 assert_eq!(
22672 results
22673 .iter()
22674 .filter(|result| {
22675 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22676 })
22677 .count(),
22678 1,
22679 );
22680 assert_eq!(
22681 shared
22682 .managed_spawn_replays
22683 .lock()
22684 .unwrap_or_else(|poisoned| poisoned.into_inner())
22685 .len(),
22686 1,
22687 );
22688 assert!(shared
22689 .managed_worktrees
22690 .lock()
22691 .unwrap_or_else(|poisoned| poisoned.into_inner())
22692 .records()
22693 .is_empty());
22694 drop(shared);
22695 std::fs::remove_dir_all(root).unwrap();
22696 }
22697
22698 #[tokio::test]
22699 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22700 let root = temporary_workspace_root("managed-v2-linked-pending");
22701 let allocation = root.join("allocation");
22702 std::fs::create_dir_all(&allocation).unwrap();
22703 let state_path = root.join("node-state.json");
22704 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22705 let lease = install_managed_lease(&shared);
22706 let request = managed_v2_request("managed-v2-linked-pending");
22707 assert!(matches!(
22708 shared
22709 .acquire_durable_managed_spawn_v2(
22710 &request,
22711 managed_spawn_request_digest_v2(&request).unwrap(),
22712 )
22713 .unwrap(),
22714 DurableManagedSpawnReplayDecision::Reserved,
22715 ));
22716 {
22717 let _transaction = shared
22718 .state_transaction
22719 .lock()
22720 .unwrap_or_else(|poisoned| poisoned.into_inner());
22721 shared
22722 .link_durable_managed_spawn_v2_lease_locked(
22723 &request.spawn_spec.idempotency_key,
22724 &lease.lease_id,
22725 )
22726 .unwrap();
22727 shared.persist_state_locked().unwrap();
22728 }
22729 drop(shared);
22730
22731 let mut loaded = session_registry::load(
22732 Some(&state_path),
22733 &NodeId::new("node-terminal-test").unwrap(),
22734 )
22735 .unwrap();
22736 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22737 *reopened
22738 .managed_worktrees
22739 .lock()
22740 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22741 ManagedWorktreeRegistry::from_records(
22742 std::mem::take(&mut loaded.managed_worktrees),
22743 std::mem::take(&mut loaded.managed_worktree_tombstones),
22744 )
22745 .unwrap();
22746 *reopened
22747 .managed_spawn_replays
22748 .lock()
22749 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22750 .managed_spawn_replays
22751 .into_iter()
22752 .map(|record| (record.idempotency_key.clone(), record))
22753 .collect();
22754 let lease_count = reopened
22755 .managed_worktrees
22756 .lock()
22757 .unwrap_or_else(|poisoned| poisoned.into_inner())
22758 .records()
22759 .len();
22760
22761 let failure = reopened
22762 .spawn_managed_worktree_v2(request)
22763 .await
22764 .unwrap_err();
22765
22766 assert_eq!(
22767 failure.code,
22768 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22769 );
22770 assert_eq!(
22771 reopened
22772 .managed_worktrees
22773 .lock()
22774 .unwrap_or_else(|poisoned| poisoned.into_inner())
22775 .records()
22776 .len(),
22777 lease_count,
22778 );
22779 std::fs::remove_dir_all(root).unwrap();
22780 }
22781
22782 #[tokio::test]
22783 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22784 let root = temporary_workspace_root("managed-v2-reopen");
22785 let allocation = root.join("allocation");
22786 std::fs::create_dir_all(&allocation).unwrap();
22787 let state_path = root.join("node-state.json");
22788 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22789 let lease = install_managed_lease(&shared);
22790 let request = managed_v2_request("managed-v2-reopen");
22791 let session = SessionAddress {
22792 workspace_id: lease.workspace_id.clone(),
22793 session: SessionKey {
22794 instance_id: AgentInstanceId(91),
22795 generation: SessionGeneration(1),
22796 },
22797 };
22798 let snapshot = {
22799 let mut registry = shared
22800 .managed_worktrees
22801 .lock()
22802 .unwrap_or_else(|poisoned| poisoned.into_inner());
22803 let current = registry.get_mut(&lease.lease_id).unwrap();
22804 current.state = ManagedWorktreeLeaseState::InUse;
22805 current.session_holders.push(ManagedWorktreeSessionHolder {
22806 incarnation_id: shared.incarnation_id,
22807 instance_id: session.session.instance_id,
22808 generation: session.session.generation,
22809 });
22810 current.snapshot()
22811 };
22812 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22813 let receipt = managed_spawn_receipt(
22814 &resolved,
22815 shared.incarnation_id,
22816 session,
22817 snapshot,
22818 None,
22819 None,
22820 None,
22821 );
22822 assert!(matches!(
22823 shared
22824 .acquire_durable_managed_spawn_v2(
22825 &request,
22826 managed_spawn_request_digest_v2(&request).unwrap(),
22827 )
22828 .unwrap(),
22829 DurableManagedSpawnReplayDecision::Reserved,
22830 ));
22831 {
22832 let _transaction = shared
22833 .state_transaction
22834 .lock()
22835 .unwrap_or_else(|poisoned| poisoned.into_inner());
22836 shared
22837 .link_durable_managed_spawn_v2_lease_locked(
22838 &request.spawn_spec.idempotency_key,
22839 &lease.lease_id,
22840 )
22841 .unwrap();
22842 shared.persist_state_locked().unwrap();
22843 }
22844 shared
22845 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22846 .unwrap();
22847 let legacy_same_key = ManagedWorktreeSpawnRequest {
22848 spawn_spec: request.spawn_spec.clone(),
22849 worktree_profile_id: request.worktree_profile_id.clone(),
22850 };
22851 shared.remember_managed_spawn_attempt(
22852 Some(&request.spawn_spec.idempotency_key),
22853 legacy_same_key.clone(),
22854 Ok(receipt.clone()),
22855 Instant::now(),
22856 );
22857 assert!(shared
22858 .replay_managed_spawn(&legacy_same_key, Instant::now())
22859 .unwrap()
22860 .is_none());
22861 drop(shared);
22862
22863 let mut loaded = session_registry::load(
22864 Some(&state_path),
22865 &NodeId::new("node-terminal-test").unwrap(),
22866 )
22867 .unwrap();
22868 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22869 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22870 *reopened
22871 .managed_worktrees
22872 .lock()
22873 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22874 ManagedWorktreeRegistry::from_records(
22875 std::mem::take(&mut loaded.managed_worktrees),
22876 std::mem::take(&mut loaded.managed_worktree_tombstones),
22877 )
22878 .unwrap();
22879 *reopened
22880 .managed_spawn_replays
22881 .lock()
22882 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22883 .managed_spawn_replays
22884 .into_iter()
22885 .map(|record| (record.idempotency_key.clone(), record))
22886 .collect();
22887 let lease_count_before = reopened
22888 .managed_worktrees
22889 .lock()
22890 .unwrap_or_else(|poisoned| poisoned.into_inner())
22891 .records()
22892 .len();
22893
22894 assert_eq!(
22895 reopened
22896 .spawn_managed_worktree_v2(request.clone())
22897 .await
22898 .unwrap(),
22899 receipt,
22900 );
22901 assert_eq!(
22902 reopened
22903 .managed_worktrees
22904 .lock()
22905 .unwrap_or_else(|poisoned| poisoned.into_inner())
22906 .records()
22907 .len(),
22908 lease_count_before,
22909 );
22910 let mut changed = request;
22911 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22912 let failure = reopened
22913 .spawn_managed_worktree_v2(changed)
22914 .await
22915 .unwrap_err();
22916 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22917 assert_eq!(
22918 reopened
22919 .managed_worktrees
22920 .lock()
22921 .unwrap_or_else(|poisoned| poisoned.into_inner())
22922 .records()
22923 .len(),
22924 lease_count_before,
22925 );
22926 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22927 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22928 let failure = reopened
22929 .spawn_managed_worktree_v2(new_key)
22930 .await
22931 .unwrap_err();
22932 assert_eq!(
22933 failure.code,
22934 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22935 );
22936 assert_eq!(
22937 reopened
22938 .managed_worktrees
22939 .lock()
22940 .unwrap_or_else(|poisoned| poisoned.into_inner())
22941 .records()
22942 .len(),
22943 lease_count_before,
22944 );
22945 assert_eq!(
22946 reopened
22947 .managed_spawn_replays
22948 .lock()
22949 .unwrap_or_else(|poisoned| poisoned.into_inner())
22950 .len(),
22951 1,
22952 );
22953 std::fs::remove_dir_all(root).unwrap();
22954 }
22955
22956 #[test]
22957 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22958 let shared = terminal_test_shared();
22959 let request = ManagedWorktreeSpawnRequest {
22960 spawn_spec: spawn_spec_fixture(),
22961 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22962 };
22963 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22964 shared.remember_managed_spawn(
22965 request.clone(),
22966 Err(expected.clone()),
22967 Instant::now(),
22968 );
22969
22970 assert_eq!(
22971 shared
22972 .replay_managed_spawn(&request, Instant::now())
22973 .unwrap()
22974 .unwrap(),
22975 Err(expected),
22976 );
22977 assert!(shared
22978 .managed_spawn_replays
22979 .lock()
22980 .unwrap_or_else(|poisoned| poisoned.into_inner())
22981 .is_empty());
22982 }
22983
22984 #[tokio::test]
22985 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22986 let shared = terminal_test_shared();
22987 let lease = install_managed_lease(&shared);
22988 let raw = shared.spawn_session(
22989 lease.workspace_id.clone(),
22990 agent("claude"),
22991 SessionMode::Pty,
22992 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22993 None,
22994 ).await.unwrap_err();
22995 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22996 let mut spec = spawn_spec_fixture();
22997 spec.target.workspace_id = lease.workspace_id.clone();
22998 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22999 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23000 let mut selected = spawn_spec_fixture();
23001 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23002 selected.target.worktree_id = Some(lease.workspace_id);
23003 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23004 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23005 assert!(shared.handle.snapshot().sessions.is_empty());
23006 }
23007
23008 #[test]
23009 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23010 let shared = terminal_test_shared();
23011 let lease = install_managed_lease(&shared);
23012 let snapshot = snapshot_for_wire(
23013 &shared, true, true, true, false, true, true, false, false, true,
23014 );
23015 assert!(snapshot.managed_worktrees.is_empty());
23016 assert!(snapshot.workspaces.iter().all(|workspace| {
23017 workspace.managed_worktree_profiles.is_none()
23018 }));
23019 let mut reply = ResponseEnvelope {
23020 request_id: 1,
23021 result: Ok(NodeResponse::Resync {
23022 event_sequence: 1,
23023 oldest_available_sequence: 1,
23024 snapshot: shared.snapshot(),
23025 events: vec![NodeEventEnvelope {
23026 sequence: 1,
23027 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23028 }],
23029 }),
23030 };
23031 project_response_without_managed_worktrees(&mut reply);
23032 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23033 panic!("projection changed the response kind");
23034 };
23035 assert!(snapshot.managed_worktrees.is_empty());
23036 assert!(events.is_empty());
23037 let mut git = git_snapshot_for_parser();
23038 git.branch = Some(lease.branch.clone());
23039 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23040 lease_id: lease.lease_id.clone(),
23041 source_workspace_id: lease.source_workspace_id.clone(),
23042 branch: lease.branch.clone(),
23043 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23044 active_session_count: 1,
23045 managed_record_count: 0,
23046 });
23047 let mut inspection_reply = ResponseEnvelope {
23048 request_id: 2,
23049 result: Ok(NodeResponse::WorkspaceInspected {
23050 inspection: WorkspaceInspection {
23051 workspace_id: lease.workspace_id.clone(),
23052 entries: Vec::new(),
23053 tree_truncated: false,
23054 git,
23055 truncation: None,
23056 },
23057 }),
23058 };
23059 project_response_without_managed_worktrees(&mut inspection_reply);
23060 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23061 panic!("projection changed the inspection response kind");
23062 };
23063 assert!(inspection.git.managed_worktree.is_none());
23064 let failure = managed_git_worktree_failure(GitWorktreeError {
23065 kind: GitWorktreeErrorKind::Failed,
23066 message: r"git failed at C:\private\managed-target".to_owned(),
23067 });
23068 assert!(!failure.message.contains("private"));
23069 assert!(!failure.message.contains("managed-target"));
23070 }
23071
23072 #[tokio::test]
23073 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23074 let root = temporary_workspace_root("managed-restart");
23075 let source = root.join("source");
23076 let allocation = root.join("allocation");
23077 std::fs::create_dir_all(&source).unwrap();
23078 std::fs::create_dir_all(&allocation).unwrap();
23079 run_test_git(&source, &["init"]);
23080 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23081 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23082 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23083 run_test_git(&source, &["add", "seed.txt"]);
23084 run_test_git(&source, &["commit", "-m", "seed"]);
23085 let profile = ManagedWorktreeProfile::new(
23086 WorktreeProfileId::new("default").unwrap(),
23087 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23088 &allocation,
23089 "gate4agent",
23090 "HEAD",
23091 ManagedWorktreeRetention::Retain,
23092 ).unwrap();
23093 let workspace = WorkspaceConfig::new(
23094 WorkspaceId::new("primary").unwrap(),
23095 &source,
23096 ).unwrap()
23097 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23098 .with_managed_worktree_profile(profile.clone()).unwrap();
23099 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23100 let source_text = workspace.canonical_root().to_owned();
23101 let target_text = target.to_string_lossy().into_owned();
23102 let created = create_git_worktree(
23103 &source_text,
23104 &target_text,
23105 "gate4agent/mw-restart",
23106 Some("HEAD"),
23107 ).await.unwrap();
23108 let lease = ManagedWorktreeLeaseRecord {
23109 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23110 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23111 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23112 profile_id: profile.profile_id().clone(),
23113 profile_revision: profile.revision().clone(),
23114 target_root: created.path.clone(),
23115 branch: "gate4agent/mw-restart".to_owned(),
23116 base_commit: created.head.clone(),
23117 expected_head: None,
23118 retention: ManagedWorktreeRetention::Retain,
23119 state: ManagedWorktreeLeaseState::Allocating,
23120 session_holders: Vec::new(),
23121 record_holders: Vec::new(),
23122 cleanup_failure: None,
23123 created_at_unix_ms: 1,
23124 updated_at_unix_ms: 1,
23125 };
23126 let catalog = active_registry().unwrap();
23127 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23128 let shared = NodeShared::new_with_incarnation(
23129 handle,
23130 "fixture-token".to_owned(),
23131 NodeId::new("node-managed-restart").unwrap(),
23132 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23133 vec![workspace],
23134 vec![agent("claude")],
23135 ProviderRuntimeStatuses::default(),
23136 None,
23137 Vec::new(),
23138 Vec::new(),
23139 SpawnProfileRegistry::default(),
23140 None,
23141 None,
23142 None,
23143 None,
23144 Vec::new(),
23145 vec![lease.clone()],
23146 Vec::new(),
23147 Vec::new(),
23148 None,
23149 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23150 );
23151 shared.reconcile_managed_worktrees().await;
23152 let retained = shared.managed_worktrees.lock()
23153 .unwrap_or_else(|poisoned| poisoned.into_inner())
23154 .get(&lease.lease_id).unwrap().clone();
23155 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23156 assert!(shared.workspaces.read()
23157 .unwrap_or_else(|poisoned| poisoned.into_inner())
23158 .contains_key(&lease.workspace_id));
23159 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23160 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23161 assert!(!Path::new(&created.path).exists());
23162 std::fs::remove_dir_all(root).unwrap();
23163 }
23164
23165 fn run_test_git(root: &Path, arguments: &[&str]) {
23166 let output = std::process::Command::new("git")
23167 .arg("-C").arg(root).args(arguments).output().unwrap();
23168 assert!(
23169 output.status.success(),
23170 "git {:?} failed: {}",
23171 arguments,
23172 String::from_utf8_lossy(&output.stderr),
23173 );
23174 }
23175
23176 #[tokio::test]
23177 async fn spawn_spec_validation_fails_before_session_mutation() {
23178 let shared = terminal_test_shared();
23179 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23180 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23181 }));
23182 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23183 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23184 }));
23185 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23186 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23187 }));
23188
23189 let mut target_mismatch = spawn_spec_fixture();
23190 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23191 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23192 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23193
23194 let mut unknown_profile = spawn_spec_fixture();
23195 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23196 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23197 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23198 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23199
23200 let mut revision_mismatch = spawn_spec_fixture();
23201 revision_mismatch.idempotency_key =
23202 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23203 revision_mismatch.expected_profile_revision =
23204 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23205 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23206 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23207
23208 let mut unsupported_materializer = spawn_spec_fixture();
23209 unsupported_materializer.idempotency_key =
23210 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23211 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23212 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23213 };
23214 let failure = shared
23215 .spawn_from_spec(unsupported_materializer)
23216 .await
23217 .unwrap_err();
23218 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23219
23220 let failure = shared
23221 .spawn_session_with_deadline(
23222 WorkspaceId::new("primary").unwrap(),
23223 agent("claude"),
23224 SessionMode::Pty,
23225 gate4agent_types::TerminalSize {
23226 rows: 24,
23227 columns: 80,
23228 },
23229 None,
23230 None,
23231 None,
23232 None,
23233 None,
23234 None,
23235 SpawnRecordPolicy::ProviderIdentityOnly,
23236 Some(Instant::now()),
23237 &[],
23238 None,
23239 ApprovalLevel::default(),
23240 None,
23241 None,
23242 )
23243 .await
23244 .unwrap_err();
23245 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23246 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23247 assert!(shared.handle.snapshot().sessions.is_empty());
23248 }
23249
23250 #[test]
23251 fn worktree_selection_requires_its_separate_negotiated_capability() {
23252 let mut spec = spawn_spec_fixture();
23253 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23254 let request = NodeRequest::SpawnSpec { spec };
23255 let spawn_spec_capability =
23256 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23257 assert!(request_uses_unnegotiated_capability(
23258 &request,
23259 &[spawn_spec_capability.clone()],
23260 ));
23261 let worktree_capability =
23262 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23263 assert!(request_uses_unnegotiated_capability(
23264 &request,
23265 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23266 ));
23267 let profile_revision_capability =
23268 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23269 assert!(request_uses_unnegotiated_capability(
23270 &request,
23271 &[
23272 spawn_spec_capability.clone(),
23273 worktree_capability.clone(),
23274 profile_revision_capability.clone(),
23275 ],
23276 ));
23277 let bundle_materialization_capability =
23284 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23285 assert!(request_uses_unnegotiated_capability(
23286 &request,
23287 &[
23288 spawn_spec_capability.clone(),
23289 worktree_capability.clone(),
23290 profile_revision_capability.clone(),
23291 bundle_materialization_capability.clone(),
23292 ],
23293 ));
23294 let history_context_pack_capability =
23295 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23296 assert!(!request_uses_unnegotiated_capability(
23297 &request,
23298 &[
23299 spawn_spec_capability,
23300 worktree_capability,
23301 profile_revision_capability,
23302 bundle_materialization_capability,
23303 history_context_pack_capability,
23304 ],
23305 ));
23306 }
23307
23308 #[tokio::test]
23309 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23310 let mut shared = terminal_test_shared();
23311 let spec = spawn_spec_fixture();
23312 let receipt = shared
23313 .resolve_spawn_spec(&spec)
23314 .unwrap()
23315 .receipt(shared.incarnation_id, terminal_address(1));
23316 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23317
23318 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23319 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23320 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23321
23322 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23323
23324 let mut revision_conflicting = spec.clone();
23325 revision_conflicting.expected_profile_revision =
23326 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23327 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23328 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23329
23330 let mut stale_cache_miss = spec.clone();
23331 stale_cache_miss.idempotency_key =
23332 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23333 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23334 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23335
23336 let mut conflicting = spec;
23337 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23338 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23339 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23340 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23341 assert!(shared.handle.snapshot().sessions.is_empty());
23342 }
23343
23344 #[tokio::test]
23345 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23346 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23347 let mut spec = spawn_spec_fixture();
23348 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23349 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23350 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23351
23352 let failure = shared
23353 .create_worktree(
23354 WorkspaceId::new("primary").unwrap(),
23355 WorkspaceId::new("new-worktree").unwrap(),
23356 "not-an-absolute-path".to_owned(),
23357 "invalid branch".to_owned(),
23358 None,
23359 )
23360 .await
23361 .unwrap_err();
23362 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23363
23364 let failure = shared
23365 .remove_worktree(
23366 WorkspaceId::new("primary").unwrap(),
23367 "not-an-absolute-path".to_owned(),
23368 )
23369 .await
23370 .unwrap_err();
23371 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23372 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23373 assert!(shared.handle.snapshot().sessions.is_empty());
23374 }
23375
23376 #[tokio::test]
23377 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23378 let root = temporary_workspace_root("manual-worktree-compensation");
23379 let source = root.join("source");
23380 let target = root.join("target");
23381 let invalid_state_parent = root.join("state-parent-is-a-file");
23382 let invalid_state_path = invalid_state_parent.join("state.json");
23383 std::fs::create_dir_all(&source).unwrap();
23384 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23385 run_test_git(&source, &["init"]);
23386 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23387 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23388 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23389 run_test_git(&source, &["add", "seed.txt"]);
23390 run_test_git(&source, &["commit", "-m", "seed"]);
23391
23392 let catalog = active_registry().unwrap();
23393 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23394 let workspace = WorkspaceConfig::new(
23395 WorkspaceId::new("primary").unwrap(),
23396 &source,
23397 ).unwrap();
23398 let mut shared = NodeShared::new(
23399 handle,
23400 "fixture-token".to_owned(),
23401 NodeId::new("node-worktree-compensation").unwrap(),
23402 vec![workspace],
23403 vec![agent("claude")],
23404 );
23405 shared.state_path = Some(invalid_state_path);
23406
23407 let error = shared.create_worktree(
23408 WorkspaceId::new("primary").unwrap(),
23409 WorkspaceId::new("compensated").unwrap(),
23410 target.to_string_lossy().into_owned(),
23411 "gate4agent/compensated".to_owned(),
23412 Some("HEAD".to_owned()),
23413 ).await.unwrap_err();
23414
23415 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23416 assert!(!target.exists());
23417 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23418 .iter()
23419 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23420 std::fs::remove_dir_all(root).unwrap();
23421 }
23422
23423 #[test]
23424 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23425 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23426 NativeGitWorktreeSnapshot {
23427 path: path.to_owned(),
23428 head: "0123456789abcdef".to_owned(),
23429 branch: Some("refs/heads/test".to_owned()),
23430 is_bare,
23431 is_main,
23432 locked: false,
23433 lock_reason: None,
23434 prunable: false,
23435 prunable_reason: None,
23436 workspace_id: None,
23437 }
23438 }
23439
23440 let source = r"C:\repo";
23441 let selected = r"C:\repo-worktree";
23442 let listed = vec![
23443 worktree(source, true, false),
23444 worktree(selected, false, false),
23445 ];
23446 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23447
23448 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23449 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23450
23451 let bare = vec![
23452 worktree(source, true, false),
23453 worktree(selected, false, true),
23454 ];
23455 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23456 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23457
23458 let foreign_failure =
23459 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23460 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23461 }
23462
23463 fn terminal_frame(sequence: u64) -> TerminalFrame {
23464 TerminalFrame {
23465 sequence,
23466 size: gate4agent_types::TerminalSize {
23467 rows: 24,
23468 columns: 80,
23469 },
23470 cursor_row: 2,
23471 cursor_column: 3,
23472 contents: format!("frame-{sequence}"),
23473 formatted: vec![1, 2, 3],
23474 scrollback_formatted: Vec::new(),
23475 alternate_screen: false,
23476 mouse_protocol_enabled: false,
23477 mouse_protocol_encoding: Default::default(),
23478 produced_at_unix_ms: 0,
23479 screen_state: gate4agent_types::PtyScreenState::default(),
23480 bracketed_paste: None,
23481 }
23482 }
23483
23484 #[test]
23485 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23486 let shared = terminal_test_shared();
23487 let mut receiver = shared.terminal_event_tx.subscribe();
23488 let original = terminal_address(1);
23489
23490 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23491 let first = receiver.try_recv().unwrap();
23492 assert_eq!(first.len(), 1);
23493 assert!(matches!(
23494 &first[0].event,
23495 NodeEvent::TerminalFrame { address, frame }
23496 if address == &original && frame.sequence == 7
23497 ));
23498
23499 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23500 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23501 assert!(matches!(
23502 receiver.try_recv(),
23503 Err(broadcast::error::TryRecvError::Empty)
23504 ));
23505
23506 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23507 let advanced = receiver.try_recv().unwrap();
23508 assert_eq!(advanced[0].sequence, 2);
23509
23510 let resumed = terminal_address(2);
23511 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23512 let reset = receiver.try_recv().unwrap();
23513 assert_eq!(reset[0].sequence, 3);
23514 assert!(matches!(
23515 &reset[0].event,
23516 NodeEvent::TerminalFrame { address, frame }
23517 if address == &resumed && frame.sequence == 1
23518 ));
23519 shared.clear_terminal_frame_watermark(&resumed);
23520 assert!(!shared
23521 .terminal_frame_watermarks
23522 .lock()
23523 .unwrap_or_else(|poisoned| poisoned.into_inner())
23524 .contains_key(&resumed.session.instance_id));
23525 }
23526
23527 #[test]
23528 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23529 let shared = terminal_test_shared();
23530 let mut receiver = shared.terminal_event_tx.subscribe();
23531 let address = terminal_address(1);
23532 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23533
23534 for sequence in 1..=count {
23535 shared.publish_terminal_frame_candidates(vec![(
23536 address.clone(),
23537 terminal_frame(sequence),
23538 )]);
23539 }
23540
23541 let history = shared
23542 .history
23543 .lock()
23544 .unwrap_or_else(|poisoned| poisoned.into_inner());
23545 assert!(history.events.is_empty());
23546 assert_eq!(history.last_sequence, count);
23547 assert_eq!(history.replay_floor_sequence, 1);
23548 drop(history);
23549 let NodeResponse::Resync {
23550 event_sequence,
23551 oldest_available_sequence,
23552 events,
23553 ..
23554 } = shared.resync(0) else {
23555 unreachable!("resync helper returned another response")
23556 };
23557 assert_eq!(event_sequence, count);
23558 assert_eq!(oldest_available_sequence, 1);
23559 assert!(events.is_empty());
23560 assert!(matches!(
23561 resync_required_event(&shared).event,
23562 NodeEvent::ResyncRequired {
23563 oldest_available_sequence: 1,
23564 }
23565 ));
23566 assert!(matches!(
23567 receiver.try_recv(),
23568 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23569 ));
23570 let latest = receiver.try_recv().unwrap();
23571 assert_eq!(latest.len(), 1);
23572 assert_eq!(latest[0].sequence, count);
23573 }
23574
23575 #[test]
23576 fn real_durable_eviction_advances_authoritative_replay_floor() {
23577 let shared = terminal_test_shared();
23578 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23579 shared.publish(NodeEvent::ControllerChanged { controller: None });
23580 }
23581
23582 let NodeResponse::Resync {
23583 event_sequence,
23584 oldest_available_sequence,
23585 events,
23586 ..
23587 } = shared.resync(0) else {
23588 unreachable!("resync helper returned another response")
23589 };
23590 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23591 assert_eq!(oldest_available_sequence, 2);
23592 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23593 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23594 assert!(matches!(
23595 resync_required_event(&shared).event,
23596 NodeEvent::ResyncRequired {
23597 oldest_available_sequence: 2,
23598 }
23599 ));
23600 }
23601
23602 #[test]
23603 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23604 let shared = terminal_test_shared();
23605 let without_capability = terminal_event_subscription(&shared, false);
23606 let with_capability = terminal_event_subscription(&shared, true);
23607 assert!(without_capability.is_none());
23608 assert!(with_capability.is_some());
23609 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23610 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23611 }));
23612 }
23613
23614 #[test]
23615 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23616 let mut pending = Vec::new();
23617 let mut event_sequence = 0_u64;
23618 for frame_sequence in 1..=4 {
23619 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23620 event_sequence += 1;
23621 let mut address = terminal_address(1);
23622 address.session.instance_id = AgentInstanceId(instance);
23623 queue_connection_event(
23624 &mut pending,
23625 NodeEventEnvelope {
23626 sequence: event_sequence,
23627 event: NodeEvent::TerminalFrame {
23628 address,
23629 frame: terminal_frame(frame_sequence),
23630 },
23631 },
23632 0,
23633 );
23634 }
23635 }
23636 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23637 pending.sort_unstable_by_key(|event| event.sequence);
23638
23639 let mut uninterrupted = pending.clone();
23640 let first_burst = connection_event_burst_len(uninterrupted.len());
23641 uninterrupted.drain(..first_burst);
23642 let second_burst = connection_event_burst_len(uninterrupted.len());
23643 uninterrupted.drain(..second_burst);
23644 assert!(uninterrupted.is_empty());
23645
23646 let (frame_tx, mut frame_rx) =
23647 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23648 frame_tx
23649 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23650 request_id: 77,
23651 request: NodeRequest::Snapshot,
23652 })))
23653 .unwrap();
23654 let burst_len = connection_event_burst_len(pending.len());
23655 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23656 pending.drain(..burst_len);
23657 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23658 assert!(matches!(
23659 frame_rx.try_recv(),
23660 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23661 request_id: 77,
23662 request: NodeRequest::Snapshot,
23663 })))
23664 ));
23665 }
23666
23667 #[test]
23668 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23669 let mut pending = Vec::new();
23670 for sequence in [7_u64, 8] {
23671 let mut address = terminal_address(1);
23672 address.session.instance_id = AgentInstanceId(sequence);
23673 queue_connection_event(
23674 &mut pending,
23675 NodeEventEnvelope {
23676 sequence,
23677 event: NodeEvent::TerminalFrame {
23678 address,
23679 frame: terminal_frame(sequence),
23680 },
23681 },
23682 0,
23683 );
23684 }
23685 let mut discard_through = 0;
23686 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23687 assert!(pending.is_empty());
23688
23689 let replayed = NodeEventEnvelope {
23690 sequence: 8,
23691 event: NodeEvent::TerminalFrame {
23692 address: terminal_address(1),
23693 frame: terminal_frame(8),
23694 },
23695 };
23696 let advanced = NodeEventEnvelope {
23697 sequence: 9,
23698 event: NodeEvent::TerminalFrame {
23699 address: terminal_address(2),
23700 frame: terminal_frame(1),
23701 },
23702 };
23703 queue_connection_event_batch(
23704 &mut pending,
23705 &[replayed, advanced.clone()],
23706 discard_through,
23707 );
23708 assert_eq!(pending, vec![advanced]);
23709 }
23710
23711 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23712 ManagedSessionRecord {
23713 record_id: SessionRecordId::new(record_id).unwrap(),
23714 display_name: format!("{provider} record"),
23715 provider: agent(provider),
23716 mode: SessionMode::Pty,
23717 state: ManagedSessionState::Unavailable,
23718 workspace_id: WorkspaceId::new("primary").unwrap(),
23719 canonical_root: opaque_windows_path(
23720 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23721 ),
23722 provider_session: None,
23723 active_session: None,
23724 environment_profile: None,
23725 bundle: None,
23726 context_id: None,
23727 context: None,
23728 exported_context: None,
23729 task_binding: None,
23730 created_at_unix_ms: 1,
23731 updated_at_unix_ms: 1,
23732 last_error: None,
23733 }
23734 }
23735
23736 fn retention_test_record(
23737 workspace_id: &str,
23738 record_id: &str,
23739 state: ManagedSessionState,
23740 updated_at_unix_ms: u64,
23741 ) -> ManagedSessionRecord {
23742 ManagedSessionRecord {
23743 record_id: SessionRecordId::new(record_id).unwrap(),
23744 display_name: format!("{record_id} display"),
23745 provider: agent("claude"),
23746 mode: SessionMode::Pty,
23747 state,
23748 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23749 canonical_root: opaque_windows_path(
23750 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23751 ),
23752 provider_session: None,
23753 active_session: None,
23754 environment_profile: None,
23755 bundle: None,
23756 context_id: None,
23757 context: None,
23758 exported_context: None,
23759 task_binding: None,
23760 created_at_unix_ms: updated_at_unix_ms,
23761 updated_at_unix_ms,
23762 last_error: None,
23763 }
23764 }
23765
23766 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23774 vec![
23775 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23776 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23777 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23778 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23779 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23780 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23781 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23782 retention_test_record(
23783 "workspace-a",
23784 "decoy-pending",
23785 ManagedSessionState::IdentityPending,
23786 now - 500_000,
23787 ),
23788 retention_test_record(
23789 "workspace-a",
23790 "decoy-dormant",
23791 ManagedSessionState::Dormant,
23792 now - 500_000,
23793 ),
23794 ]
23795 }
23796
23797 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23798 let root = temporary_workspace_root("session-record-retention");
23799 std::fs::create_dir_all(&root).unwrap();
23800 let catalog = active_registry().unwrap();
23801 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23802 let workspace_a =
23803 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23804 let workspace_b =
23805 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23806 let mut shared = NodeShared::new(
23807 handle,
23808 "fixture-token".to_owned(),
23809 NodeId::new("node-retention-test").unwrap(),
23810 vec![workspace_a, workspace_b],
23811 vec![agent("claude")],
23812 );
23813 shared.state_path = Some(root.join("node-state-v10.json"));
23814 let now = unix_time_ms();
23815 for record in retention_fixture_records(now) {
23816 shared.insert_record(record).unwrap();
23817 }
23818 shared
23819 .persist_state_locked()
23820 .expect("fixture-only, empty-history persist against a fresh temp state path");
23821 (shared, runtime, root, now)
23822 }
23823
23824 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23825 shared
23826 .history
23827 .lock()
23828 .unwrap_or_else(|poisoned| poisoned.into_inner())
23829 .events
23830 .iter()
23831 .filter_map(|envelope| match &envelope.event {
23832 NodeEvent::SessionRecordRemoved { record_id } => {
23833 Some(record_id.as_str().to_owned())
23834 }
23835 _ => None,
23836 })
23837 .collect()
23838 }
23839
23840 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23841 shared
23842 .session_records
23843 .lock()
23844 .unwrap_or_else(|poisoned| poisoned.into_inner())
23845 .records
23846 .keys()
23847 .map(|record_id| record_id.as_str().to_owned())
23848 .collect()
23849 }
23850
23851 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23852 values.iter().map(|value| (*value).to_owned()).collect()
23853 }
23854
23855 #[tokio::test]
23856 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23857 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23858 shared.session_record_retention = SessionRecordRetentionConfig {
23859 age_ms: 0,
23860 keep_per_workspace: 1,
23861 };
23862 shared.retire_unavailable_session_records().await;
23863
23864 assert_eq!(
23865 retention_remaining_record_ids(&shared),
23866 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23867 );
23868 assert_eq!(
23869 retention_removed_record_ids(&shared),
23870 owned(&["a1", "a2", "b1", "b2"]),
23871 );
23872 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23873
23874 drop(shared);
23875 drop(runtime);
23876 std::fs::remove_dir_all(root).unwrap();
23877 }
23878
23879 #[tokio::test]
23885 async fn session_record_retention_both_set_retires_only_the_intersection() {
23886 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23887 shared.session_record_retention = SessionRecordRetentionConfig {
23888 age_ms: 250_000,
23889 keep_per_workspace: 1,
23890 };
23891 shared.retire_unavailable_session_records().await;
23892
23893 assert_eq!(
23894 retention_remaining_record_ids(&shared),
23895 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23896 );
23897 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23898 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23899
23900 drop(shared);
23901 drop(runtime);
23902 std::fs::remove_dir_all(root).unwrap();
23903 }
23904
23905 #[tokio::test]
23906 async fn session_record_retention_both_disabled_is_a_no_op() {
23907 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23908 shared.session_record_retention = SessionRecordRetentionConfig::default();
23909 shared.retire_unavailable_session_records().await;
23910
23911 assert_eq!(
23912 retention_remaining_record_ids(&shared),
23913 owned(&[
23914 "a1", "a2", "a3", "b1", "b2", "b3",
23915 "decoy-live", "decoy-pending", "decoy-dormant",
23916 ]),
23917 );
23918 assert!(retention_removed_record_ids(&shared).is_empty());
23919 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23920
23921 drop(shared);
23922 drop(runtime);
23923 std::fs::remove_dir_all(root).unwrap();
23924 }
23925
23926 #[test]
23927 fn session_record_context_export_selects_one_exact_history_identity() {
23928 let candidates = vec![
23929 HistoryCandidateSummary {
23930 id: "candidate-other".to_owned(),
23931 session_id_hint: "provider-other".to_owned(),
23932 modified_at_unix_ms: Some(1),
23933 },
23934 HistoryCandidateSummary {
23935 id: "candidate-exact".to_owned(),
23936 session_id_hint: "provider-exact".to_owned(),
23937 modified_at_unix_ms: Some(2),
23938 },
23939 ];
23940 assert_eq!(
23941 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23942 "candidate-exact",
23943 );
23944 }
23945
23946 #[test]
23947 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23948 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23949 id: id.to_owned(),
23950 session_id_hint: hint.to_owned(),
23951 modified_at_unix_ms: None,
23952 };
23953 let missing = select_history_candidate(
23954 &[candidate("candidate-other", "provider-other")],
23955 Some("provider-exact"),
23956 )
23957 .unwrap_err();
23958 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23959 let ambiguous = select_history_candidate(
23960 &[
23961 candidate("candidate-a", "provider-exact"),
23962 candidate("candidate-b", "provider-exact"),
23963 ],
23964 Some("provider-exact"),
23965 )
23966 .unwrap_err();
23967 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23968 }
23969
23970 #[test]
23978 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23979 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23980 id: id.to_owned(),
23981 session_id_hint: hint.to_owned(),
23982 modified_at_unix_ms: None,
23983 };
23984 assert_eq!(
23985 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23986 .unwrap(),
23987 "candidate-sole",
23988 );
23989 let missing = select_history_candidate(&[], None).unwrap_err();
23990 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23991 let ambiguous = select_history_candidate(
23992 &[
23993 candidate("candidate-a", "sidecar-session-a"),
23994 candidate("candidate-b", "sidecar-session-b"),
23995 ],
23996 None,
23997 )
23998 .unwrap_err();
23999 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24000 }
24001
24002 #[test]
24003 fn session_record_context_export_revalidation_rejects_record_change() {
24004 let session = terminal_address(1);
24005 let identity = ProviderSessionIdentity {
24006 key: ProviderSessionKey::SessionId,
24007 id: "provider-exact".to_owned(),
24008 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24009 };
24010 let mut expected = record("claude", "record-context-export");
24011 expected.state = ManagedSessionState::Live;
24012 expected.provider_session = Some(identity.clone());
24013 expected.active_session = Some(session.clone());
24014 let binding = SessionBinding {
24015 workspace_id: session.workspace_id.clone(),
24016 generation: session.session.generation,
24017 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24018 pending_resume: None,
24019 record_id: Some(expected.record_id.clone()),
24020 managed_worktree_lease_id: None,
24021 environment_profile: None,
24022 bundle: None,
24023 context: None,
24024 materialization_id: None,
24025 };
24026 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24027 assert!(session_record_context_export_binding_is_exact(
24028 &expected,
24029 &expected,
24030 Some(&identity),
24031 &session,
24032 &binding,
24033 &expected.provider,
24034 ¤t_root,
24035 false,
24036 ));
24037 let mut changed = expected.clone();
24038 changed.updated_at_unix_ms += 1;
24039 assert!(!session_record_context_export_binding_is_exact(
24040 &expected,
24041 &changed,
24042 Some(&identity),
24043 &session,
24044 &binding,
24045 &expected.provider,
24046 ¤t_root,
24047 false,
24048 ));
24049 assert!(!session_record_context_export_binding_is_exact(
24054 &expected,
24055 &changed,
24056 Some(&identity),
24057 &session,
24058 &binding,
24059 &expected.provider,
24060 ¤t_root,
24061 true,
24062 ));
24063 let mut detached = expected.clone();
24064 detached.state = ManagedSessionState::Dormant;
24065 detached.active_session = None;
24066 detached.updated_at_unix_ms += 1;
24067 assert!(!session_record_context_export_binding_is_exact(
24068 &expected,
24069 &detached,
24070 Some(&identity),
24071 &session,
24072 &binding,
24073 &expected.provider,
24074 ¤t_root,
24075 false,
24076 ));
24077 assert!(session_record_context_export_binding_is_exact(
24078 &expected,
24079 &detached,
24080 Some(&identity),
24081 &session,
24082 &binding,
24083 &expected.provider,
24084 ¤t_root,
24085 true,
24086 ));
24087 }
24088
24089 #[test]
24096 fn session_record_context_export_binding_is_exact_without_identity() {
24097 let session = terminal_address(1);
24098 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24099 expected.state = ManagedSessionState::Live;
24100 expected.active_session = Some(session.clone());
24101 let binding = SessionBinding {
24102 workspace_id: session.workspace_id.clone(),
24103 generation: session.session.generation,
24104 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24105 pending_resume: None,
24106 record_id: Some(expected.record_id.clone()),
24107 managed_worktree_lease_id: None,
24108 environment_profile: None,
24109 bundle: None,
24110 context: None,
24111 materialization_id: None,
24112 };
24113 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24114 assert!(session_record_context_export_binding_is_exact(
24115 &expected,
24116 &expected,
24117 None,
24118 &session,
24119 &binding,
24120 &expected.provider,
24121 ¤t_root,
24122 false,
24123 ));
24124 let mut drifted = expected.clone();
24128 drifted.provider_session = Some(ProviderSessionIdentity {
24129 key: ProviderSessionKey::SessionId,
24130 id: "unexpected-identity".to_owned(),
24131 transcript_path: None,
24132 });
24133 assert!(!session_record_context_export_binding_is_exact(
24134 &expected,
24135 &drifted,
24136 None,
24137 &session,
24138 &binding,
24139 &expected.provider,
24140 ¤t_root,
24141 false,
24142 ));
24143 }
24144
24145 #[test]
24146 fn session_record_context_export_preflight_requires_live_usable_source() {
24147 let mut source = record("claude", "record-context-export-preflight");
24148 source.state = ManagedSessionState::Live;
24149 assert!(session_record_context_export_source_is_usable(
24150 &source,
24151 &SessionStatus::Running,
24152 false,
24153 ));
24154 assert!(session_record_context_export_source_is_usable(
24155 &source,
24156 &SessionStatus::Exited { exit_code: Some(0) },
24157 false,
24158 ));
24159
24160 source.state = ManagedSessionState::Dormant;
24161 assert!(!session_record_context_export_source_is_usable(
24162 &source,
24163 &SessionStatus::Running,
24164 false,
24165 ));
24166 assert!(session_record_context_export_source_is_usable(
24170 &source,
24171 &SessionStatus::Exited { exit_code: Some(0) },
24172 true,
24173 ));
24174 assert!(!session_record_context_export_source_is_usable(
24175 &source,
24176 &SessionStatus::Exited { exit_code: Some(0) },
24177 false,
24178 ));
24179 source.state = ManagedSessionState::Live;
24180 assert!(!session_record_context_export_source_is_usable(
24181 &source,
24182 &SessionStatus::Starting,
24183 false,
24184 ));
24185 }
24186
24187 #[test]
24188 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24189 let catalog = active_registry().unwrap();
24190 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24191 let workspace = WorkspaceConfig::new(
24192 WorkspaceId::new("primary").unwrap(),
24193 std::env::current_dir().unwrap(),
24194 )
24195 .unwrap();
24196 let shared = NodeShared::new(
24197 handle,
24198 "fixture-token".to_owned(),
24199 NodeId::new("node-context-export-conflict").unwrap(),
24200 vec![workspace],
24201 vec![agent("claude")],
24202 );
24203 let history = HistorySessionRecord {
24204 session_id: "provider-exact".to_owned(),
24205 title: None,
24206 cwd: None,
24207 model: None,
24208 message_count: 1,
24209 completed_turn_count: None,
24210 total_tokens: 0,
24211 messages: vec![gate4agent_types::HistoryMessageRecord {
24212 role: gate4agent_types::HistoryMessageRole::User,
24213 text: "bounded context".to_owned(),
24214 }],
24215 };
24216 let pack = NodeContextPack::export(
24217 ContextPackLineageReceipt {
24218 source_node_id: shared.node_id.clone(),
24219 source_session: terminal_address(1),
24220 source_provider: agent("claude"),
24221 },
24222 &history,
24223 )
24224 .unwrap();
24225 let context_id = pack.receipt().id.clone();
24226 let session = terminal_address(1);
24227 let identity = ProviderSessionIdentity {
24228 key: ProviderSessionKey::SessionId,
24229 id: history.session_id.clone(),
24230 transcript_path: None,
24231 };
24232 let mut expected = record("claude", "record-context-export-final-conflict");
24233 expected.state = ManagedSessionState::Live;
24234 expected.provider_session = Some(identity.clone());
24235 expected.active_session = Some(session.clone());
24236 let mut changed = expected.clone();
24237 changed.updated_at_unix_ms += 1;
24238 shared.insert_record(changed).unwrap();
24239
24240 let error = shared
24241 .commit_context_pack_for_session_record(
24242 &expected,
24243 Some(&identity),
24244 &session,
24245 "candidate-exact",
24246 &history,
24247 pack,
24248 false,
24249 )
24250 .unwrap_err();
24251
24252 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24253 assert!(shared
24254 .context_catalog
24255 .read()
24256 .unwrap_or_else(|poisoned| poisoned.into_inner())
24257 .get(&context_id)
24258 .is_none());
24259 }
24260
24261 #[tokio::test]
24262 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24263 let root = temporary_workspace_root("session-task-binding");
24264 std::fs::create_dir_all(&root).unwrap();
24265 let state_path = root.join("node-state.json");
24266 let catalog = active_registry().unwrap();
24267 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24268 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24269 let node_id = NodeId::new("node-1").unwrap();
24270 let mut shared = NodeShared::new(
24271 handle,
24272 "fixture-token".to_owned(),
24273 node_id.clone(),
24274 vec![workspace],
24275 vec![agent("claude")],
24276 );
24277 shared.state_path = Some(state_path.clone());
24278 let record = record("claude", "sr-task");
24279 let record_id = record.record_id.clone();
24280 shared.insert_record(record).unwrap();
24281 shared.persist_state().unwrap();
24282 shared
24283 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24284 .unwrap();
24285
24286 let minted = process_request_inner(
24287 &shared,
24288 77,
24289 ClientRole::Operator,
24290 NodeRequest::SetSessionTask {
24291 record_id: record_id.clone(),
24292 expected_revision: 0,
24293 target: SessionTaskTargetV1::New,
24294 },
24295 )
24296 .await
24297 .unwrap();
24298 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24299 panic!("session task mutation returned another response");
24300 };
24301 let minted_binding = minted.task_binding.clone().unwrap();
24302 let minted_task_id = minted_binding.task_id.clone().unwrap();
24303 assert_eq!(minted_binding.revision, 1);
24304 assert!(minted_task_id.as_str().starts_with("task-"));
24305 assert_eq!(minted_task_id.as_str().len(), 29);
24306
24307 let event_count = shared.current_sequence();
24308 let repeated = process_request_inner(
24309 &shared,
24310 77,
24311 ClientRole::Operator,
24312 NodeRequest::SetSessionTask {
24313 record_id: record_id.clone(),
24314 expected_revision: 1,
24315 target: SessionTaskTargetV1::Existing {
24316 task_id: minted_task_id.clone(),
24317 },
24318 },
24319 )
24320 .await
24321 .unwrap();
24322 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24323 assert_eq!(shared.current_sequence(), event_count);
24324
24325 let stale = process_request_inner(
24326 &shared,
24327 77,
24328 ClientRole::Operator,
24329 NodeRequest::SetSessionTask {
24330 record_id: record_id.clone(),
24331 expected_revision: 0,
24332 target: SessionTaskTargetV1::Existing {
24333 task_id: minted_task_id,
24334 },
24335 },
24336 )
24337 .await
24338 .unwrap_err();
24339 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24340
24341 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24342 let assigned = process_request_inner(
24343 &shared,
24344 77,
24345 ClientRole::Operator,
24346 NodeRequest::SetSessionTask {
24347 record_id: record_id.clone(),
24348 expected_revision: 1,
24349 target: SessionTaskTargetV1::Existing {
24350 task_id: assigned_id.clone(),
24351 },
24352 },
24353 )
24354 .await
24355 .unwrap();
24356 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24357 panic!("session task assignment returned another response");
24358 };
24359 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24360 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24361
24362 let cleared = process_request_inner(
24363 &shared,
24364 77,
24365 ClientRole::Operator,
24366 NodeRequest::SetSessionTask {
24367 record_id: record_id.clone(),
24368 expected_revision: 2,
24369 target: SessionTaskTargetV1::Clear,
24370 },
24371 )
24372 .await
24373 .unwrap();
24374 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24375 panic!("session task clear returned another response");
24376 };
24377 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24378 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24379 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24380 assert_eq!(
24381 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24382 cleared.updated_at_unix_ms,
24383 );
24384
24385 let persisted = std::fs::read(&state_path).unwrap();
24386 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24387 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24388 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24389 std::fs::remove_dir_all(root).unwrap();
24390 }
24391
24392 #[test]
24393 fn session_task_capability_strips_snapshots_events_and_replies() {
24394 let mut bound = record("claude", "sr-task-projection");
24395 bound.updated_at_unix_ms = 2;
24396 bound.task_binding = Some(SessionTaskBindingV1 {
24397 revision: 1,
24398 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24399 changed_at_unix_ms: 2,
24400 });
24401 let request = NodeRequest::SetSessionTask {
24402 record_id: bound.record_id.clone(),
24403 expected_revision: 1,
24404 target: SessionTaskTargetV1::Clear,
24405 };
24406 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24407 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24408 }));
24409 assert!(!request_uses_unnegotiated_capability(
24410 &request,
24411 &baseline_capabilities().unwrap(),
24412 ));
24413 assert!(request_uses_unnegotiated_capability(&request, &[]));
24414
24415 let mut snapshot = terminal_test_shared().snapshot();
24416 snapshot.session_records.push(bound.clone());
24417 let mut reply = ResponseEnvelope {
24418 request_id: 1,
24419 result: Ok(NodeResponse::Resync {
24420 event_sequence: 1,
24421 oldest_available_sequence: 1,
24422 snapshot,
24423 events: vec![NodeEventEnvelope {
24424 sequence: 1,
24425 event: NodeEvent::SessionRecordUpserted {
24426 record: bound.clone(),
24427 },
24428 }],
24429 }),
24430 };
24431 project_response_without_session_task_binding(&mut reply);
24432 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24433 panic!("task projection changed the response kind");
24434 };
24435 assert!(snapshot.session_records[0].task_binding.is_none());
24436 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24437 panic!("task projection changed the event kind");
24438 };
24439 assert!(record.task_binding.is_none());
24440
24441 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24442 sequence: 2,
24443 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24444 });
24445 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24446 panic!("task event projection changed the event kind");
24447 };
24448 assert!(record.task_binding.is_none());
24449
24450 let mut direct = ResponseEnvelope {
24451 request_id: 2,
24452 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24453 };
24454 project_response_without_session_task_binding(&mut direct);
24455 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24456 panic!("task reply projection changed the response kind");
24457 };
24458 assert!(record.task_binding.is_none());
24459 }
24460
24461 #[test]
24462 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24463 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24464 let workspace = WorkspaceConfig::new(
24465 WorkspaceId::new("primary").unwrap(),
24466 std::env::current_dir().unwrap(),
24467 )
24468 .unwrap();
24469 let config = NodeServerConfig::new(
24470 DEFAULT_NODE_ENDPOINT,
24471 "fixture-token",
24472 NodeId::new("node-1").unwrap(),
24473 [workspace],
24474 )
24475 .unwrap();
24476 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24477 }
24478
24479 #[cfg(all(feature = "fixture", windows))]
24480 #[test]
24481 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24482 let root = temporary_workspace_root("provider-bundle-argv-hold");
24483 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24484 std::fs::create_dir_all(&root).unwrap();
24485 std::fs::create_dir_all(&outside).unwrap();
24486 let proof_path = root.join("provider.proof");
24487 let release_signal = root.join("release.signal");
24488 let config = NodeServerConfig::new(
24489 format!(
24490 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24491 std::process::id(),
24492 ),
24493 "fixture-token",
24494 NodeId::new("provider-bundle-hold-node").unwrap(),
24495 [WorkspaceConfig::new(
24496 WorkspaceId::new("primary").unwrap(),
24497 std::env::current_dir().unwrap(),
24498 )
24499 .unwrap()],
24500 )
24501 .unwrap()
24502 .with_spawn_profiles(SpawnProfileRegistry::new([
24503 crate::protocol::SpawnProfileDefaults {
24504 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24505 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24506 .unwrap(),
24507 provider: agent("kimi"),
24508 mode: SessionMode::Pty,
24509 terminal_size: gate4agent_types::TerminalSize {
24510 rows: 24,
24511 columns: 80,
24512 },
24513 prompt: None,
24514 bundle_id: None,
24515 context_id: None,
24516 environment_profile_id: None,
24517 },
24518 ])
24519 .unwrap());
24520 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24521 config,
24522 agent("kimi"),
24523 proof_path.clone(),
24524 release_signal.clone(),
24525 )
24526 .unwrap();
24527 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24528
24529 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24530 agent("kimi"),
24531 proof_path.clone(),
24532 Some(release_signal.clone()),
24533 )
24534 .unwrap();
24535 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24536 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24537 let release_wait = wrapper
24538 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24539 .unwrap();
24540 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24541 assert!(proof_write < release_wait);
24542 assert!(release_wait < fixture_ready);
24543 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24544 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24545 assert_eq!(
24546 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24547 &[
24548 proof_path.to_string_lossy().into_owned(),
24549 release_signal.to_string_lossy().into_owned(),
24550 ],
24551 );
24552
24553 let error = NodeServer::provider_bundle_argv_fixture_spec(
24554 agent("kimi"),
24555 proof_path,
24556 Some(outside.join("release.signal")),
24557 )
24558 .unwrap_err();
24559 assert!(matches!(
24560 error,
24561 NodeServerError::Registry(message)
24562 if message == "provider bundle argv release path must share the proof parent"
24563 ));
24564 drop(server);
24565 std::fs::remove_dir_all(root).unwrap();
24566 std::fs::remove_dir_all(outside).unwrap();
24567 }
24568
24569 #[cfg(feature = "fixture")]
24570 #[test]
24571 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24572 let root = temporary_workspace_root("context-fixture-registry");
24573 std::fs::create_dir_all(&root).unwrap();
24574 let proof_path = root.join("context.proof");
24575 let config = NodeServerConfig::new(
24576 format!(
24577 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24578 std::process::id(),
24579 ),
24580 "fixture-token",
24581 NodeId::new("context-fixture-node").unwrap(),
24582 [WorkspaceConfig::new(
24583 WorkspaceId::new("primary").unwrap(),
24584 std::env::current_dir().unwrap(),
24585 )
24586 .unwrap()],
24587 )
24588 .unwrap();
24589 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24590 let mut enabled = server
24591 .shared
24592 .enabled_providers
24593 .iter()
24594 .map(AgentId::as_str)
24595 .collect::<Vec<_>>();
24596 enabled.sort_unstable();
24597 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24598 for provider in &server.shared.enabled_providers {
24599 assert!(server
24600 .shared
24601 .provider_adapter_contracts
24602 .iter()
24603 .any(|contract| {
24604 &contract.provider == provider
24605 && contract.family == AdapterFamily::History
24606 }));
24607 }
24608 drop(server);
24609 std::fs::remove_dir_all(root).unwrap();
24610 }
24611
24612 #[cfg(feature = "fixture")]
24613 #[test]
24614 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24615 let root = temporary_workspace_root("monitoring-context-fixture");
24616 std::fs::create_dir_all(&root).unwrap();
24617 let proof_path = root.join("context.proof");
24618 let standard = NodeServer::context_pack_fixture_catalog(
24619 proof_path.clone(),
24620 None,
24621 false,
24622 ).unwrap();
24623 let monitoring = NodeServer::context_pack_fixture_catalog(
24624 proof_path.clone(),
24625 None,
24626 true,
24627 ).unwrap();
24628 let claude = AgentId::new("claude").unwrap();
24629 let standard_claude = standard.get(&claude).unwrap();
24630 let monitoring_claude = monitoring.get(&claude).unwrap();
24631 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24635 assert_eq!(
24636 monitoring_claude.capabilities.adapters.history,
24637 standard_claude.capabilities.adapters.history,
24638 );
24639 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24640 let codex = AgentId::new("codex").unwrap();
24641 let standard_codex = standard.get(&codex).unwrap();
24642 let monitoring_codex = monitoring.get(&codex).unwrap();
24643 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24644 assert_eq!(
24645 monitoring_codex.capabilities.adapters.history,
24646 standard_codex.capabilities.adapters.history,
24647 );
24648 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24649 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24650 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24651 .unwrap();
24652 for required in [
24653 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24654 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24655 "@('role', 'text')",
24656 "g4a-private-provider-session-canary",
24657 "private-provider-login-identity",
24658 "$rawProof",
24659 "$env:GATE4AGENT_HOOK_URL",
24660 "g4a-private-provider-session-canary",
24661 ] {
24662 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24663 }
24664 for provider in ["grok", "kimi"] {
24665 let provider = AgentId::new(provider).unwrap();
24666 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24667 }
24668
24669 let config = NodeServerConfig::new(
24670 format!(
24671 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24672 std::process::id(),
24673 ),
24674 "fixture-token",
24675 NodeId::new("monitoring-context-fixture-node").unwrap(),
24676 [WorkspaceConfig::new(
24677 WorkspaceId::new("primary").unwrap(),
24678 std::env::current_dir().unwrap(),
24679 )
24680 .unwrap()],
24681 )
24682 .unwrap();
24683 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24684 assert!(server.shared.fixture_semantic_hook_policy);
24685 drop(server);
24686 std::fs::remove_dir_all(root).unwrap();
24687 }
24688
24689 #[cfg(feature = "fixture")]
24690 #[test]
24691 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24692 let root = temporary_workspace_root("kimi-context-only-fixture");
24693 std::fs::create_dir_all(&root).unwrap();
24694 let proof_path = root.join("context.proof");
24695 let standard = NodeServer::context_pack_fixture_catalog(
24696 proof_path.clone(),
24697 None,
24698 false,
24699 ).unwrap();
24700 let kimi = AgentId::new("kimi").unwrap();
24701 let context_only = NodeServer::context_pack_fixture_catalog(
24702 proof_path,
24703 Some(&kimi),
24704 false,
24705 )
24706 .unwrap();
24707
24708 let codex = AgentId::new("codex").unwrap();
24709 assert_eq!(
24710 standard.get(&codex).unwrap().launch.fixed_args,
24711 context_only.get(&codex).unwrap().launch.fixed_args,
24712 );
24713 let script = context_only
24714 .get(&kimi)
24715 .unwrap()
24716 .launch
24717 .fixed_args
24718 .iter()
24719 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24720 .expect("Kimi context-only validation script is missing");
24721 for required in [
24722 "$bundleArgs.Count -ne 0",
24723 "GATE4AGENT_CONTEXT_ROOT",
24724 "context-pack.json",
24725 "$entries.Count -ne 1",
24726 "g4a-context-pack-v1",
24727 "$document.PSObject.Properties['cwd']",
24728 "'claude', 'codex', 'grok', 'kimi'",
24729 "$hasUser",
24730 "$hasAssistant",
24731 "SHA256",
24732 "context-only",
24733 ] {
24734 assert!(script.contains(required), "missing validation: {required}");
24735 }
24736 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24737 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24738 }
24739 assert!(!standard
24740 .get(&kimi)
24741 .unwrap()
24742 .launch
24743 .fixed_args
24744 .iter()
24745 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24746
24747 std::fs::remove_dir_all(root).unwrap();
24748 }
24749
24750 #[cfg(all(feature = "fixture", windows))]
24751 #[tokio::test]
24752 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24753 let root = temporary_workspace_root("kimi-context-only-spawn");
24754 let workspace_root = root.join("workspace");
24755 std::fs::create_dir_all(&workspace_root).unwrap();
24756 let materialization_root = root.join("private-materializations");
24757 let proof_path = root.join("context.proof");
24758 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24759 let workspace_id = WorkspaceId::new("primary").unwrap();
24760 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24761 let endpoint = format!(
24762 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24763 std::process::id(),
24764 unix_time_ms(),
24765 );
24766 let spawn_profiles = SpawnProfileRegistry::new([
24767 crate::protocol::SpawnProfileDefaults {
24768 profile_id: profile_id.clone(),
24769 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24770 provider: agent("kimi"),
24771 mode: SessionMode::Pty,
24772 terminal_size: gate4agent_types::TerminalSize {
24773 rows: 30,
24774 columns: 120,
24775 },
24776 prompt: None,
24777 bundle_id: None,
24778 context_id: None,
24779 environment_profile_id: None,
24780 },
24781 ])
24782 .unwrap();
24783 let mut config = NodeServerConfig::new(
24784 &endpoint,
24785 "fixture-token",
24786 node_id.clone(),
24787 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24788 )
24789 .unwrap()
24790 .with_spawn_profiles(spawn_profiles)
24791 .with_session_environment_materialization(
24792 materialization_root,
24793 Arc::new(FixtureSecretResolver {
24794 deny: Arc::new(AtomicBool::new(false)),
24795 }),
24796 )
24797 .unwrap();
24798 config.fixture_raw_pty_runtime = true;
24799 let server = NodeServer::new_context_only_proof_fixture(
24800 config,
24801 agent("kimi"),
24802 proof_path.clone(),
24803 )
24804 .unwrap();
24805 let shared = Arc::clone(&server.shared);
24806 let shutdown = server.shutdown_handle();
24807
24808 let history = HistorySessionRecord {
24809 session_id: "context-only-source-history".to_owned(),
24810 title: None,
24811 cwd: None,
24812 model: None,
24813 message_count: 2,
24814 completed_turn_count: None,
24815 total_tokens: 0,
24816 messages: vec![
24817 gate4agent_types::HistoryMessageRecord {
24818 role: gate4agent_types::HistoryMessageRole::User,
24819 text: "continue the bounded parent run through native C2".to_owned(),
24820 },
24821 gate4agent_types::HistoryMessageRecord {
24822 role: gate4agent_types::HistoryMessageRole::Assistant,
24823 text: "the exact parent context is ready for the child run".to_owned(),
24824 },
24825 ],
24826 };
24827 let pack = NodeContextPack::export(
24828 ContextPackLineageReceipt {
24829 source_node_id: node_id.clone(),
24830 source_session: SessionAddress {
24831 workspace_id: workspace_id.clone(),
24832 session: SessionKey {
24833 instance_id: AgentInstanceId(71),
24834 generation: SessionGeneration(1),
24835 },
24836 },
24837 source_provider: agent("codex"),
24838 },
24839 &history,
24840 )
24841 .unwrap();
24842 let expected_context_hash = digest(&SHA256, pack.bytes())
24843 .as_ref()
24844 .iter()
24845 .map(|byte| format!("{byte:02x}"))
24846 .collect::<String>();
24847 let context = shared
24848 .context_catalog
24849 .write()
24850 .unwrap_or_else(|poisoned| poisoned.into_inner())
24851 .insert(pack)
24852 .unwrap();
24853 let spec = SpawnSpec {
24854 target: crate::protocol::SpawnTarget {
24855 node_id,
24856 workspace_id: workspace_id.clone(),
24857 worktree_id: None,
24858 },
24859 profile_id,
24860 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24861 "target-kimi-r1",
24862 ).unwrap(),
24863 overrides: crate::protocol::SpawnOverrides {
24864 context_id: crate::protocol::SpawnOverride::Set {
24865 value: context.id.clone(),
24866 },
24867 ..crate::protocol::SpawnOverrides::default()
24868 },
24869 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24870 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24871 required_capabilities: SpawnRequiredCapabilities::default(),
24872 };
24873 let resolved = match shared.resolve_spawn_spec(&spec) {
24874 Ok(resolved) => resolved,
24875 Err(error) => panic!(
24876 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24877 error.code,
24878 error.message,
24879 ),
24880 };
24881 let resolved_context = match shared.resolve_context(&resolved) {
24882 Ok(Some(resolved_context)) => resolved_context,
24883 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24884 Err(error) => panic!(
24885 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24886 error.code,
24887 error.message,
24888 ),
24889 };
24890 assert_eq!(resolved_context, context);
24891 let runtime_policy = match shared
24892 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24893 .await
24894 {
24895 Ok(runtime_policy) => runtime_policy,
24896 Err(error) => panic!(
24897 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24898 error.code,
24899 error.message,
24900 ),
24901 };
24902 assert!(runtime_policy.raw_pty_lifecycle);
24903
24904 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24905 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24906 agent("kimi"),
24907 TransportKind::Pty,
24908 vec![EnvMutation {
24909 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24910 value: Some(OsString::from("must-remain-rejected")),
24911 }],
24912 )
24913 .unwrap();
24914 assert_eq!(
24915 launch_control
24916 .install_native_launch_environment_overlay(
24917 AgentInstanceId(72),
24918 generic_overlay,
24919 )
24920 .unwrap_err(),
24921 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24922 );
24923
24924 let diagnostic_address = SessionAddress {
24925 workspace_id: workspace_id.clone(),
24926 session: SessionKey {
24927 instance_id: AgentInstanceId(73),
24928 generation: SessionGeneration(1),
24929 },
24930 };
24931 let (diagnostic_overlay, diagnostic_materialization) = match shared
24932 .prepare_session_materialization(
24933 &diagnostic_address,
24934 &agent("kimi"),
24935 SessionMode::Pty,
24936 None,
24937 None,
24938 Some(&resolved_context),
24939 None,
24940 )
24941 {
24942 Ok(Some(prepared)) => prepared,
24943 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24944 Err(error) => panic!(
24945 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24946 error.code,
24947 error.message,
24948 ),
24949 };
24950 let diagnostic_overlay = diagnostic_overlay
24951 .expect("Kimi context-only materialization omitted its environment overlay");
24952 assert!(matches!(
24953 &diagnostic_overlay,
24954 PreparedNativeLaunchOverlay::Environment(_),
24955 ));
24956 match shared.install_prepared_launch_overlay(
24957 diagnostic_address.session.instance_id,
24958 diagnostic_overlay,
24959 ) {
24960 Ok(None) => {}
24961 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24962 Err(error) => panic!(
24963 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24964 error.code,
24965 error.message,
24966 ),
24967 }
24968 assert!(launch_control.clear_native_instance_launch_overlay(
24969 diagnostic_address.session.instance_id,
24970 ));
24971 drop(diagnostic_materialization);
24972 assert!(shared
24973 .materializations
24974 .lock()
24975 .unwrap_or_else(|poisoned| poisoned.into_inner())
24976 .is_empty());
24977
24978 let node_task = tokio::spawn(server.run());
24979 let ready_client = timeout(Duration::from_secs(5), async {
24980 loop {
24981 match gate4agent_node_wire::LocalNodeClient::connect(
24982 &endpoint,
24983 &shared.node_id,
24984 ClientRole::Observer,
24985 "fixture-token",
24986 )
24987 .await
24988 {
24989 Ok(client) => break client,
24990 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24991 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24992 }
24993 }
24994 })
24995 .await
24996 .expect("Kimi context-only Node did not become ready");
24997 assert_eq!(
24998 ready_client
24999 .hello()
25000 .snapshot
25001 .provider_runtime_statuses
25002 .iter()
25003 .find(|status| status.provider().as_str() == "kimi")
25004 .expect("Kimi runtime status is missing")
25005 .mode(),
25006 crate::protocol::ProviderRuntimeMode::RawPassthrough,
25007 );
25008 drop(ready_client);
25009
25010 let receipt = match shared.spawn_from_spec(spec).await {
25011 Ok(receipt) => receipt,
25012 Err(error) => panic!(
25013 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25014 error.code,
25015 error.message,
25016 ),
25017 };
25018 assert_eq!(receipt.provider, agent("kimi"));
25019 assert_eq!(receipt.context.as_ref(), Some(&context));
25020 assert!(receipt.context_binding_is_valid());
25021 timeout(Duration::from_secs(5), async {
25022 while !proof_path.is_file() {
25023 sleep(Duration::from_millis(10)).await;
25024 }
25025 })
25026 .await
25027 .expect("Kimi context-only fixture did not write its proof");
25028
25029 let proof = std::fs::read_to_string(&proof_path).unwrap();
25030 let lines = proof.lines().collect::<Vec<_>>();
25031 assert_eq!(lines.len(), 7);
25032 assert_eq!(lines[0], "context-only");
25033 assert!(Path::new(lines[1]).is_absolute());
25034 assert_ne!(
25035 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25036 std::fs::canonicalize(&workspace_root).unwrap(),
25037 );
25038 assert_eq!(
25039 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25040 std::fs::canonicalize(&workspace_root).unwrap(),
25041 );
25042 assert_eq!(lines[3], expected_context_hash);
25043 assert_eq!(lines[4], "g4a-context-pack-v1");
25044 assert_eq!(lines[5], "codex");
25045 assert_eq!(lines[6], "2");
25046 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25047 session.instance_id == receipt.session.session.instance_id
25048 && session.agent_id.as_str() == "kimi"
25049 }));
25050
25051 shutdown.request_shutdown().await.unwrap();
25052 timeout(Duration::from_secs(5), node_task)
25053 .await
25054 .unwrap()
25055 .unwrap()
25056 .unwrap();
25057 drop(shutdown);
25058 drop(shared);
25059 std::fs::remove_dir_all(root).unwrap();
25060 }
25061
25062 #[cfg(feature = "fixture")]
25063 #[test]
25064 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25065 let root = temporary_workspace_root("clean-exit-constructor");
25066 std::fs::create_dir_all(&root).unwrap();
25067 let config = construction_test_config("clean-exit-constructor")
25068 .with_state_path(root.join("node-state.json"))
25069 .unwrap();
25070 let server = NodeServer::new_clean_exit_fixture(
25071 config,
25072 root.clone(),
25073 root.join("started.marker"),
25074 root.join("release.signal"),
25075 )
25076 .unwrap();
25077 assert_eq!(
25078 server
25079 .shared
25080 .enabled_providers
25081 .iter()
25082 .map(AgentId::as_str)
25083 .collect::<Vec<_>>(),
25084 ["claude"],
25085 );
25086 drop(server);
25087 std::fs::remove_dir_all(root).unwrap();
25088 }
25089
25090 #[test]
25091 fn production_registry_provider_contract_manifest_is_exact() {
25092 let registry = active_registry().unwrap();
25093 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25094 assert_eq!(
25095 providers
25096 .iter()
25097 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25098 .collect::<Vec<_>>(),
25099 vec![
25100 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25101 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25102 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25103 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25104 ]
25105 );
25106 assert_eq!(
25107 adapters
25108 .iter()
25109 .map(|contract| (
25110 contract.provider.as_str(),
25111 contract.family,
25112 contract.adapter_id.as_str(),
25113 contract.revision.as_str(),
25114 ))
25115 .collect::<Vec<_>>(),
25116 vec![
25117 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25118 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25119 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25120 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25121 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25122 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25123 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25124 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25125 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25126 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25127 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25128 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25129 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25130 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25131 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25132 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25133 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25134 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25135 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25136 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25137 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25138 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25139 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25140 ]
25141 );
25142 }
25143
25144 fn construction_test_config(name: &str) -> NodeServerConfig {
25145 let workspace = WorkspaceConfig::new(
25146 WorkspaceId::new("primary").unwrap(),
25147 std::env::current_dir().unwrap(),
25148 )
25149 .unwrap();
25150 NodeServerConfig::new(
25151 format!(r"\\.\pipe\gate4agent-{name}"),
25152 "fixture-token",
25153 NodeId::new(name).unwrap(),
25154 [workspace],
25155 )
25156 .unwrap()
25157 }
25158
25159 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25160 let adapter = AdapterBinding::new(
25161 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25162 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25163 gate4agent_types::AdapterVerification::SyntheticFixture,
25164 )
25165 .unwrap();
25166 let agent_ids = ["claude", "codex", "kimi"]
25167 .into_iter()
25168 .map(|agent_id| AgentId::new(agent_id).unwrap())
25169 .collect::<Vec<_>>();
25170 let adapters = gate4agent_catalog::AdapterRegistry::new(
25171 [
25172 AdapterFamily::PtySemantic,
25173 AdapterFamily::Pipe,
25174 AdapterFamily::OneShot,
25175 AdapterFamily::Acp,
25176 AdapterFamily::Hook,
25177 AdapterFamily::ManagedHook,
25178 AdapterFamily::History,
25179 AdapterFamily::Resume,
25180 AdapterFamily::SessionOptions,
25181 AdapterFamily::CapabilityProbe,
25182 ]
25183 .into_iter()
25184 .map(|family| gate4agent_catalog::AdapterDescriptor {
25185 family,
25186 binding: adapter.clone(),
25187 agents: agent_ids.clone(),
25188 }),
25189 )
25190 .unwrap();
25191 let specs = agent_ids
25192 .iter()
25193 .map(|agent_id| {
25194 let agent_id = agent_id.as_str();
25195 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25196 spec.revision =
25197 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25198 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25199 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25200 adapter: adapter.clone(),
25201 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25202 launch_override: None,
25203 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25204 });
25205 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25206 adapter: adapter.clone(),
25207 launch_override: None,
25208 });
25209 spec.capabilities.adapters.hook = Some(adapter.clone());
25210 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25211 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25212 spec.capabilities.adapters.history = Some(adapter.clone());
25213 spec.capabilities.adapters.resume = Some(adapter.clone());
25214 spec.capabilities.adapters.session_options = Some(adapter.clone());
25215 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25216 spec
25217 })
25218 .collect::<Vec<_>>();
25219 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25220 }
25221
25222 #[test]
25230 fn node_server_construction_accepts_the_production_manifest() {
25231 let registry = active_registry().unwrap();
25232 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25233 assert_eq!(providers.len(), registry.iter().count());
25234 assert!(!providers.is_empty());
25235 let server = NodeServer::new_with_registry(
25236 construction_test_config("manifest-production-bound"),
25237 registry,
25238 )
25239 .unwrap();
25240 drop(server);
25241 }
25242
25243 #[test]
25244 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25245 let registry = maximum_size_provider_manifest_registry();
25246 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25247 assert_eq!(providers.len(), 3);
25248 assert_eq!(adapters.len(), 30);
25249 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25250 let error = match NodeServer::new_with_registry(
25251 construction_test_config("manifest-handshake-overflow"),
25252 registry,
25253 ) {
25254 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25255 Err(error) => error,
25256 };
25257 assert!(matches!(
25258 error,
25259 NodeServerError::ProviderContractManifest(message)
25260 if message.contains("authentication binding length")
25261 && message.contains("8192-byte limit")
25262 ));
25263 }
25264
25265 #[test]
25266 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25267 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25268 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25269 let registry = AgentRegistry::new([spec]).unwrap();
25270 assert!(matches!(
25271 provider_contract_manifest(®istry),
25272 Err(NodeServerError::ProviderContractManifest(message))
25273 if message.contains("exceeds the 128-byte limit")
25274 ));
25275 }
25276
25277 #[test]
25278 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25279 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25280 spec.revision = "invalid revision".to_owned();
25281 let registry = AgentRegistry::new([spec]).unwrap();
25282 assert!(matches!(
25283 provider_contract_manifest(®istry),
25284 Err(NodeServerError::ProviderContractManifest(message))
25285 if message.contains("bounded lowercase ASCII")
25286 ));
25287 }
25288
25289 #[test]
25290 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25291 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25292 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25293 let conflicting_binding = builtin_registry()
25294 .get_by_id("codex")
25295 .unwrap()
25296 .capabilities
25297 .adapters
25298 .one_shot
25299 .clone()
25300 .unwrap();
25301 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25302 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25303 pipe.adapter = transport_binding;
25304 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25305 let registry = AgentRegistry::new([spec]).unwrap();
25306 assert!(matches!(
25307 provider_contract_manifest(®istry),
25308 Err(NodeServerError::ProviderContractManifest(message))
25309 if message.contains("conflicting OneShot adapter bindings")
25310 ));
25311 }
25312
25313 #[test]
25314 fn public_node_failures_expose_only_fixed_categories() {
25315 let provider = failure(
25316 NodeFailureCode::BackendOperationFailed,
25317 r"provider bearer-secret failed at C:\private\session.jsonl",
25318 );
25319 assert_eq!(provider.message, "backend-operation-failed");
25320 assert!(!provider.message.contains("bearer-secret"));
25321 assert!(!provider.message.contains(r"C:\private"));
25322 let persistence = persistence_failure(io::Error::new(
25323 io::ErrorKind::Other,
25324 r"state write failed at C:\private\state-v1.json",
25325 ));
25326 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25327 assert!(!persistence.message.contains(r"C:\private"));
25328
25329 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25330 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25331 assert_eq!(
25332 generic.to_string(),
25333 "node durable state failed: durable-state-load-failed",
25334 );
25335 }
25336 }
25337
25338 #[test]
25339 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25340 let sequence = b"\x1b[1;5D".to_vec();
25341 assert_eq!(
25342 terminal_bytes_action(sequence.clone()).unwrap(),
25343 InputAction::TerminalBytes(sequence),
25344 );
25345 assert_eq!(
25346 terminal_bytes_action(Vec::new()).unwrap_err().code,
25347 NodeFailureCode::InvalidRequest,
25348 );
25349 assert_eq!(
25350 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25351 .unwrap_err()
25352 .code,
25353 NodeFailureCode::InvalidRequest,
25354 );
25355 }
25356
25357 #[test]
25358 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25359 let root = temporary_workspace_root("exclusive-state-owner");
25360 std::fs::create_dir_all(&root).unwrap();
25361 let workspace = WorkspaceConfig::new(
25362 WorkspaceId::new("primary").unwrap(),
25363 &root,
25364 )
25365 .unwrap();
25366 let state_path = root.join("state-v1.json");
25367 let endpoint = format!(
25368 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25369 std::process::id(),
25370 std::time::SystemTime::now()
25371 .duration_since(std::time::UNIX_EPOCH)
25372 .unwrap()
25373 .as_nanos(),
25374 );
25375 let config = NodeServerConfig::new(
25376 endpoint,
25377 "fixture-token",
25378 NodeId::new("node-1").unwrap(),
25379 [workspace],
25380 )
25381 .unwrap()
25382 .with_state_path(&state_path)
25383 .unwrap();
25384
25385 let first = NodeServer::new(config.clone()).unwrap();
25386 let error = match NodeServer::new(config.clone()) {
25387 Ok(_) => panic!("a second node server acquired the same durable state path"),
25388 Err(error) => error,
25389 };
25390 assert!(matches!(
25391 &error,
25392 NodeServerError::DurableState(error)
25393 if error.kind() == io::ErrorKind::WouldBlock
25394 ));
25395 assert_eq!(
25396 error.to_string(),
25397 "node durable state failed: durable-state-lock-failed",
25398 );
25399 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25400 drop(first);
25401 let second = NodeServer::new(config).unwrap();
25402 drop(second);
25403 std::fs::remove_dir_all(root).unwrap();
25404 }
25405
25406 #[test]
25407 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25408 let catalog = active_registry().unwrap();
25409 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25410 let workspace_id = WorkspaceId::new("primary").unwrap();
25411 let workspace = WorkspaceConfig::new(
25412 workspace_id.clone(),
25413 std::env::current_dir().unwrap(),
25414 )
25415 .unwrap();
25416 let shared = NodeShared::new(
25417 handle,
25418 "fixture-token".to_owned(),
25419 NodeId::new("node-1").unwrap(),
25420 vec![workspace.clone()],
25421 vec![agent("claude"), agent("codex"), agent("kimi")],
25422 );
25423 let instance_id = AgentInstanceId(17);
25424 let generation = SessionGeneration(3);
25425 assert!(shared.address_for(instance_id, generation).is_none());
25426 shared.bind_session(instance_id, workspace_id.clone(), generation);
25427 let address = SessionAddress {
25428 workspace_id,
25429 session: SessionKey { instance_id, generation },
25430 };
25431 assert_eq!(
25432 shared.workspace_root(&address.workspace_id).unwrap(),
25433 workspace.canonical_root,
25434 );
25435 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25436 shared.remove_binding(&address);
25437 assert!(shared.address_for(instance_id, generation).is_none());
25438 }
25439
25440 #[test]
25441 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25442 let address = SessionAddress {
25443 workspace_id: WorkspaceId::new("primary").unwrap(),
25444 session: SessionKey {
25445 instance_id: AgentInstanceId(7),
25446 generation: SessionGeneration(2),
25447 },
25448 };
25449 let record_id = SessionRecordId::new("record-a").unwrap();
25450 let size = gate4agent_types::TerminalSize {
25451 rows: 24,
25452 columns: 80,
25453 };
25454 let address_requests = [
25455 NodeRequest::Resume {
25456 session: address.clone(),
25457 terminal_size: size,
25458 initial_prompt: None,
25459 },
25460 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25461 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25462 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25463 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25464 NodeRequest::TerminalControl {
25465 session: address.clone(),
25466 control: TerminalControl::Enter,
25467 },
25468 NodeRequest::Resize { session: address.clone(), size },
25469 NodeRequest::Interrupt { session: address.clone() },
25470 NodeRequest::Stop { session: address.clone(), force: false },
25471 NodeRequest::Remove { session: address },
25472 ];
25473 for request in &address_requests {
25474 assert!(request_requires_open_provider_ids_with(
25475 request,
25476 |_| Some(agent("third-party-agent")),
25477 |_| Some(agent("claude")),
25478 ));
25479 assert!(!request_requires_open_provider_ids_with(
25480 request,
25481 |_| Some(agent("claude")),
25482 |_| Some(agent("claude")),
25483 ));
25484 assert!(request_requires_open_provider_ids_with(
25485 request,
25486 |_| None,
25487 |_| Some(agent("claude")),
25488 ));
25489 }
25490
25491 let record_requests = [
25492 NodeRequest::RenameSessionRecord {
25493 record_id: record_id.clone(),
25494 display_name: "renamed".to_owned(),
25495 },
25496 NodeRequest::ResumeSessionRecord {
25497 record_id: record_id.clone(),
25498 terminal_size: size,
25499 initial_prompt: None,
25500 },
25501 NodeRequest::ForgetSessionRecord { record_id },
25502 ];
25503 for request in &record_requests {
25504 assert!(request_requires_open_provider_ids_with(
25505 request,
25506 |_| Some(agent("claude")),
25507 |_| Some(agent("grok")),
25508 ));
25509 assert!(!request_requires_open_provider_ids_with(
25510 request,
25511 |_| Some(agent("claude")),
25512 |_| Some(agent("kimi")),
25513 ));
25514 assert!(request_requires_open_provider_ids_with(
25515 request,
25516 |_| Some(agent("claude")),
25517 |_| None,
25518 ));
25519 }
25520
25521 assert!(request_requires_open_provider_ids_with(
25522 &NodeRequest::Spawn {
25523 workspace_id: WorkspaceId::new("primary").unwrap(),
25524 provider: agent("third-party-agent"),
25525 mode: SessionMode::Pty,
25526 terminal_size: size,
25527 initial_prompt: None,
25528 },
25529 |_| None,
25530 |_| None,
25531 ));
25532 assert!(!request_requires_open_provider_ids_with(
25533 &NodeRequest::Snapshot,
25534 |_| None,
25535 |_| None,
25536 ));
25537 assert!(request_requires_open_provider_ids_with(
25538 &NodeRequest::ForgetContextPack {
25539 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25540 },
25541 |_| None,
25542 |_| None,
25543 ));
25544 }
25545
25546 #[test]
25547 fn legacy_projection_filters_open_record_replies_and_event_history() {
25548 let catalog = active_registry().unwrap();
25549 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25550 let workspace = WorkspaceConfig::new(
25551 WorkspaceId::new("primary").unwrap(),
25552 std::env::current_dir().unwrap(),
25553 )
25554 .unwrap();
25555 let shared = NodeShared::new(
25556 handle,
25557 "fixture-token".to_owned(),
25558 NodeId::new("node-1").unwrap(),
25559 vec![workspace],
25560 vec![agent("claude"), agent("grok")],
25561 );
25562 let legacy = record("claude", "legacy-record");
25563 let open = record("grok", "open-record");
25564 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25565 record: legacy.clone(),
25566 });
25567 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25568 record: open.clone(),
25569 });
25570 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25571 record_id: legacy.record_id.clone(),
25572 });
25573 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25574 record_id: open.record_id.clone(),
25575 });
25576 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25577 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25578 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25579 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25580
25581 let mut record_reply = ResponseEnvelope {
25582 request_id: 1,
25583 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25584 };
25585 project_response_legacy_provider_ids(&shared, &mut record_reply);
25586 assert!(matches!(
25587 record_reply.result,
25588 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25589 ));
25590
25591 let mut resync = ResponseEnvelope {
25592 request_id: 2,
25593 result: Ok(NodeResponse::Resync {
25594 event_sequence: open_removed.sequence,
25595 oldest_available_sequence: 1,
25596 snapshot: shared.snapshot(),
25597 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25598 }),
25599 };
25600 project_response_legacy_provider_ids(&shared, &mut resync);
25601 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25602 panic!("legacy projection changed the resync response kind");
25603 };
25604 assert!(snapshot.session_records.iter().all(|item| {
25605 provider_id_is_legacy(&item.provider)
25606 }));
25607 assert_eq!(events.len(), 2);
25608 assert!(events.iter().all(|event| matches!(
25609 &event.event,
25610 NodeEvent::SessionRecordUpserted { record }
25611 if provider_id_is_legacy(&record.provider)
25612 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25613 }
25614
25615 #[test]
25616 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25617 let catalog = active_registry().unwrap();
25618 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25619 let workspace_id = WorkspaceId::new("primary").unwrap();
25620 let workspace = WorkspaceConfig::new(
25621 workspace_id.clone(),
25622 std::env::current_dir().unwrap(),
25623 )
25624 .unwrap();
25625 let shared = NodeShared::new(
25626 handle,
25627 "fixture-token".to_owned(),
25628 NodeId::new("node-1").unwrap(),
25629 vec![workspace],
25630 vec![agent("claude")],
25631 );
25632 for index in 0..CONTROL_SESSIONS_MAX {
25633 shared.bind_session(
25634 AgentInstanceId((index + 1) as u64),
25635 workspace_id.clone(),
25636 SessionGeneration(1),
25637 );
25638 }
25639 let error = shared.ensure_binding_capacity().unwrap_err();
25640 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25641 }
25642
25643 #[tokio::test]
25644 async fn provider_spawn_admission_rejects_before_session_mutation() {
25645 let catalog = active_registry().unwrap();
25646 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25647 let workspace = WorkspaceConfig::new(
25648 WorkspaceId::new("primary").unwrap(),
25649 std::env::current_dir().unwrap(),
25650 )
25651 .unwrap();
25652 let mut shared = NodeShared::new(
25653 handle,
25654 "fixture-token".to_owned(),
25655 NodeId::new("node-provider-admission").unwrap(),
25656 vec![workspace],
25657 vec![agent("claude")],
25658 );
25659 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25660 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25661 ])
25662 .unwrap();
25663 let terminal_size = gate4agent_types::TerminalSize {
25664 rows: 24,
25665 columns: 80,
25666 };
25667
25668 let unavailable = shared
25669 .spawn_session(
25670 WorkspaceId::new("primary").unwrap(),
25671 agent("claude"),
25672 SessionMode::Pty,
25673 terminal_size,
25674 None,
25675 )
25676 .await
25677 .unwrap_err();
25678 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25679 assert_eq!(unavailable.message, "backend-operation-failed");
25680
25681 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25682 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25683 agent("claude"),
25684 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25685 ),
25686 ])
25687 .unwrap();
25688 let semantic = shared
25689 .spawn_session(
25690 WorkspaceId::new("primary").unwrap(),
25691 agent("claude"),
25692 SessionMode::Inline,
25693 terminal_size,
25694 None,
25695 )
25696 .await
25697 .unwrap_err();
25698 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25699 assert_eq!(semantic.message, "unsupported-capability");
25700
25701 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25702 assert!(shared
25703 .session_bindings
25704 .lock()
25705 .unwrap_or_else(|poisoned| poisoned.into_inner())
25706 .is_empty());
25707 assert!(shared
25708 .session_records
25709 .lock()
25710 .unwrap_or_else(|poisoned| poisoned.into_inner())
25711 .records
25712 .is_empty());
25713 assert!(shared.handle.snapshot().sessions.is_empty());
25714 }
25715
25716 #[tokio::test]
25717 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25718 let catalog = active_registry().unwrap();
25719 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25720 let workspace_id = WorkspaceId::new("primary").unwrap();
25721 let workspace = WorkspaceConfig::new(
25722 workspace_id.clone(),
25723 std::env::current_dir().unwrap(),
25724 )
25725 .unwrap();
25726 let shared = NodeShared::new(
25727 handle,
25728 "fixture-token".to_owned(),
25729 NodeId::new("node-raw-spawn-record").unwrap(),
25730 vec![workspace],
25731 vec![agent("claude")],
25732 );
25733 let address = SessionAddress {
25734 workspace_id,
25735 session: SessionKey {
25736 instance_id: AgentInstanceId(71),
25737 generation: SessionGeneration::default(),
25738 },
25739 };
25740
25741 let record_id = shared
25742 .bind_spawn_session(
25743 &address,
25744 agent("claude"),
25745 SessionMode::Pty,
25746 ProviderRuntimePolicy::raw_pty(),
25747 SpawnRecordPolicy::Always,
25748 None,
25749 )
25750 .unwrap()
25751 .unwrap();
25752 shared
25753 .handle
25754 .dispatch(shared.prepare_command(ControlCommand::Register {
25755 instance_id: address.session.instance_id,
25756 agent_id: agent("claude"),
25757 transport: TransportKind::Pty,
25758 }))
25759 .unwrap();
25760 runtime.tick().await;
25761
25762 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25763 session.instance_id == address.session.instance_id
25764 && session.generation == address.session.generation
25765 }));
25766 let record = shared.record(&record_id).unwrap();
25767 assert_eq!(record.display_name, "claude #1");
25768 assert_eq!(record.state, ManagedSessionState::Live);
25769 assert_eq!(record.active_session.as_ref(), Some(&address));
25770 assert!(record.provider_session.is_none());
25771 let binding_record_id = shared
25772 .session_bindings
25773 .lock()
25774 .unwrap_or_else(|poisoned| poisoned.into_inner())
25775 .get(&address.session.instance_id)
25776 .and_then(|binding| binding.record_id.clone());
25777 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25778
25779 let renamed = shared
25780 .rename_session_record(&record_id, "raw review".to_owned())
25781 .unwrap();
25782 assert_eq!(renamed.record_id, record_id);
25783 assert_eq!(renamed.display_name, "raw review");
25784 shared.reconcile_managed_record(
25785 &record_id,
25786 &address,
25787 &ControlEvent {
25788 sequence: 1,
25789 command_id: None,
25790 instance_id: address.session.instance_id,
25791 generation: address.session.generation,
25792 event: ControlEventKind::Running { process_id: None },
25793 },
25794 false,
25795 false,
25796 );
25797 let running = shared.record(&record_id).unwrap();
25798 assert_eq!(running.state, ManagedSessionState::Live);
25799 assert_eq!(running.active_session.as_ref(), Some(&address));
25800 assert_eq!(running.display_name, "raw review");
25801
25802 shared.reconcile_managed_record(
25803 &record_id,
25804 &address,
25805 &ControlEvent {
25806 sequence: 2,
25807 command_id: None,
25808 instance_id: address.session.instance_id,
25809 generation: address.session.generation,
25810 event: ControlEventKind::Exited {
25811 exit_code: Some(0),
25812 forced: false,
25813 },
25814 },
25815 false,
25816 false,
25817 );
25818 let detached = shared.record(&record_id).unwrap();
25819 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25820 assert!(detached.active_session.is_none());
25821 assert_eq!(detached.display_name, "raw review");
25822 let renamed_detached = shared
25823 .rename_session_record(&record_id, "raw review detached".to_owned())
25824 .unwrap();
25825 assert_eq!(renamed_detached.record_id, record_id);
25826 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25827 assert!(renamed_detached.active_session.is_none());
25828 assert_eq!(renamed_detached.display_name, "raw review detached");
25829 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25830 }
25831
25832 #[test]
25833 fn legacy_raw_spawn_binding_remains_untracked() {
25834 let catalog = active_registry().unwrap();
25835 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25836 let workspace_id = WorkspaceId::new("primary").unwrap();
25837 let workspace = WorkspaceConfig::new(
25838 workspace_id.clone(),
25839 std::env::current_dir().unwrap(),
25840 )
25841 .unwrap();
25842 let shared = NodeShared::new(
25843 handle,
25844 "fixture-token".to_owned(),
25845 NodeId::new("node-legacy-raw-spawn").unwrap(),
25846 vec![workspace],
25847 vec![agent("claude")],
25848 );
25849 let address = SessionAddress {
25850 workspace_id,
25851 session: SessionKey {
25852 instance_id: AgentInstanceId(72),
25853 generation: SessionGeneration::default(),
25854 },
25855 };
25856
25857 let record_id = shared
25858 .bind_spawn_session(
25859 &address,
25860 agent("claude"),
25861 SessionMode::Pty,
25862 ProviderRuntimePolicy::raw_pty(),
25863 SpawnRecordPolicy::ProviderIdentityOnly,
25864 None,
25865 )
25866 .unwrap();
25867
25868 assert!(record_id.is_none());
25869 let binding_record_id = shared
25870 .session_bindings
25871 .lock()
25872 .unwrap_or_else(|poisoned| poisoned.into_inner())
25873 .get(&address.session.instance_id)
25874 .and_then(|binding| binding.record_id.clone());
25875 assert!(binding_record_id.is_none());
25876 assert!(shared.snapshot().session_records.is_empty());
25877 }
25878
25879 #[tokio::test]
25880 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25881 let catalog = active_registry().unwrap();
25882 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25883 let workspace_id = WorkspaceId::new("primary").unwrap();
25884 let workspace = WorkspaceConfig::new(
25885 workspace_id.clone(),
25886 std::env::current_dir().unwrap(),
25887 )
25888 .unwrap();
25889 let mut shared = NodeShared::new(
25890 handle,
25891 "fixture-token".to_owned(),
25892 NodeId::new("node-raw-semantic-gate").unwrap(),
25893 vec![workspace],
25894 vec![agent("claude")],
25895 );
25896 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25897 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25898 ])
25899 .unwrap();
25900 shared
25901 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25902 .unwrap();
25903 let address = SessionAddress {
25904 workspace_id,
25905 session: SessionKey {
25906 instance_id: AgentInstanceId(72),
25907 generation: SessionGeneration::default(),
25908 },
25909 };
25910 shared.bind_session_with_policy(
25911 &address,
25912 ProviderRuntimePolicy::raw_pty(),
25913 None,
25914 );
25915 shared
25916 .handle
25917 .dispatch(shared.prepare_command(ControlCommand::Register {
25918 instance_id: address.session.instance_id,
25919 agent_id: agent("claude"),
25920 transport: TransportKind::Pty,
25921 }))
25922 .unwrap();
25923 runtime.tick().await;
25924 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25925 let revision_before = shared.handle.snapshot().revision;
25926 let history_sequence_before = shared.current_sequence();
25927 let terminal_size = gate4agent_types::TerminalSize {
25928 rows: 24,
25929 columns: 80,
25930 };
25931
25932 for request in [
25933 NodeRequest::Prompt {
25934 session: address.clone(),
25935 text: "prompt".to_owned(),
25936 },
25937 NodeRequest::Paste {
25938 session: address.clone(),
25939 text: "paste".to_owned(),
25940 },
25941 NodeRequest::Resume {
25947 session: address.clone(),
25948 terminal_size,
25949 initial_prompt: Some("resume".to_owned()),
25950 },
25951 ] {
25952 let error = process_request_inner(
25953 &shared,
25954 77,
25955 ClientRole::Operator,
25956 request,
25957 )
25958 .await
25959 .unwrap_err();
25960 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25961 }
25962
25963 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25964 assert_eq!(shared.handle.snapshot().revision, revision_before);
25965 assert_eq!(shared.current_sequence(), history_sequence_before);
25966 let bindings = shared
25967 .session_bindings
25968 .lock()
25969 .unwrap_or_else(|poisoned| poisoned.into_inner());
25970 let binding = bindings.get(&address.session.instance_id).unwrap();
25971 assert_eq!(binding.generation, address.session.generation);
25972 assert!(binding.pending_resume.is_none());
25973 assert!(binding.record_id.is_none());
25974 }
25975
25976 #[tokio::test]
25977 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25978 let catalog = active_registry().unwrap();
25979 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25980 let primary = WorkspaceConfig::new(
25981 WorkspaceId::new("primary").unwrap(),
25982 std::env::current_dir().unwrap(),
25983 )
25984 .unwrap();
25985 let shared = NodeShared::new(
25986 handle,
25987 "fixture-token".to_owned(),
25988 NodeId::new("node-1").unwrap(),
25989 vec![primary],
25990 vec![agent("claude")],
25991 );
25992 shared
25993 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25994 .unwrap();
25995 let before = shared.snapshot();
25996 let error = process_request_inner(
25997 &shared,
25998 77,
25999 ClientRole::Operator,
26000 NodeRequest::RegisterWorkspace {
26001 workspace_id: WorkspaceId::new("foreign").unwrap(),
26002 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26003 },
26004 )
26005 .await
26006 .unwrap_err();
26007 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26008 assert_eq!(error.message, "invalid-request");
26009 assert_eq!(shared.snapshot().workspaces, before.workspaces);
26010 }
26011
26012 #[tokio::test]
26013 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26014 let catalog = active_registry().unwrap();
26015 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26016 let primary_id = WorkspaceId::new("primary").unwrap();
26017 let primary = WorkspaceConfig::new(
26018 primary_id.clone(),
26019 std::env::current_dir().unwrap(),
26020 )
26021 .unwrap();
26022 let shared = NodeShared::new(
26023 handle,
26024 "fixture-token".to_owned(),
26025 NodeId::new("node-1").unwrap(),
26026 vec![primary.clone()],
26027 vec![agent("claude")],
26028 );
26029 let secondary_id = WorkspaceId::new("secondary").unwrap();
26030 let secondary = shared
26031 .register_workspace(
26032 secondary_id.clone(),
26033 std::env::temp_dir().to_string_lossy().into_owned(),
26034 )
26035 .await
26036 .unwrap();
26037 assert_eq!(secondary.workspace_id, secondary_id);
26038 assert!(secondary.sessions.is_empty());
26039 assert_eq!(shared.snapshot().workspaces.len(), 2);
26040
26041 let duplicate_id = shared
26042 .register_workspace(
26043 secondary_id.clone(),
26044 primary.canonical_root().to_owned(),
26045 )
26046 .await
26047 .unwrap_err();
26048 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26049 let duplicate_root = shared
26050 .register_workspace(
26051 WorkspaceId::new("duplicate-root").unwrap(),
26052 windows_path_text(&secondary.canonical_root).to_owned(),
26053 )
26054 .await
26055 .unwrap_err();
26056 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26057
26058 let address = SessionAddress {
26059 workspace_id: secondary_id.clone(),
26060 session: SessionKey {
26061 instance_id: AgentInstanceId(41),
26062 generation: SessionGeneration(1),
26063 },
26064 };
26065 shared.bind_session(
26066 address.session.instance_id,
26067 secondary_id.clone(),
26068 address.session.generation,
26069 );
26070 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26071 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26072 shared.remove_binding(&address);
26073 shared.unregister_workspace(&secondary_id).unwrap();
26074 assert_eq!(shared.snapshot().workspaces.len(), 1);
26075
26076 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26077 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26078 let history = shared
26079 .history
26080 .lock()
26081 .unwrap_or_else(|poisoned| poisoned.into_inner());
26082 assert!(matches!(
26083 &history.events[0],
26084 NodeEventEnvelope {
26085 sequence: 1,
26086 event: NodeEvent::WorkspaceAdded { workspace },
26087 } if workspace.workspace_id == secondary_id
26088 ));
26089 assert!(matches!(
26090 &history.events[1],
26091 NodeEventEnvelope {
26092 sequence: 2,
26093 event: NodeEvent::WorkspaceRemoved { workspace_id },
26094 } if workspace_id == &secondary_id
26095 ));
26096 }
26097
26098 #[tokio::test]
26099 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26100 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26101 std::fs::create_dir_all(&secondary_root).unwrap();
26102 let catalog = active_registry().unwrap();
26103 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26104 let primary_id = WorkspaceId::new("primary").unwrap();
26105 let secondary_id = WorkspaceId::new("secondary").unwrap();
26106 let shared = NodeShared::new(
26107 handle,
26108 "fixture-token".to_owned(),
26109 NodeId::new("node-session-reference-index").unwrap(),
26110 vec![
26111 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26112 .unwrap(),
26113 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26114 ],
26115 vec![agent("claude"), agent("codex")],
26116 );
26117 let identity = ProviderSessionIdentity {
26118 key: gate4agent_types::ProviderSessionKey::SessionId,
26119 id: "provider-session-42".to_owned(),
26120 transcript_path: None,
26121 };
26122 let index_request = |workspace_id: WorkspaceId,
26123 provider: AgentId,
26124 identity: ProviderSessionIdentity,
26125 display_name: &str| {
26126 NodeRequest::IndexProviderSession {
26127 workspace_id,
26128 provider,
26129 identity,
26130 display_name: display_name.to_owned(),
26131 }
26132 };
26133 let request = index_request(
26134 primary_id.clone(),
26135 agent("claude"),
26136 identity.clone(),
26137 "release shepherd",
26138 );
26139 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26140 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26141 }));
26142 let observer = process_request_inner(
26143 &shared,
26144 77,
26145 ClientRole::Observer,
26146 request.clone(),
26147 )
26148 .await
26149 .unwrap_err();
26150 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26151 let uncontrolled = process_request_inner(
26152 &shared,
26153 77,
26154 ClientRole::Operator,
26155 request.clone(),
26156 )
26157 .await
26158 .unwrap_err();
26159 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26160 shared
26161 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26162 .unwrap();
26163
26164 let indexed = process_request_inner(
26165 &shared,
26166 77,
26167 ClientRole::Operator,
26168 request.clone(),
26169 )
26170 .await
26171 .unwrap();
26172 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26173 panic!("provider session reference index returned another response");
26174 };
26175 assert_eq!(record.state, ManagedSessionState::Dormant);
26176 assert_eq!(record.mode, SessionMode::Pty);
26177 assert_eq!(record.workspace_id, primary_id);
26178 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26179 assert!(record.active_session.is_none());
26180 assert!(record.environment_profile.is_none());
26181 assert!(record.bundle.is_none());
26182 assert!(record.context.is_none());
26183
26184 let repeated = process_request_inner(
26185 &shared,
26186 77,
26187 ClientRole::Operator,
26188 index_request(
26189 primary_id.clone(),
26190 agent("claude"),
26191 identity.clone(),
26192 "ignored replacement",
26193 ),
26194 )
26195 .await
26196 .unwrap();
26197 assert!(matches!(
26198 repeated,
26199 NodeResponse::ProviderSessionIndexed { record: ref existing }
26200 if existing.record_id == record.record_id
26201 && existing.display_name == "release shepherd"
26202 ));
26203 let conflicting_workspace = process_request_inner(
26204 &shared,
26205 77,
26206 ClientRole::Operator,
26207 index_request(
26208 secondary_id,
26209 agent("claude"),
26210 identity.clone(),
26211 "release shepherd",
26212 ),
26213 )
26214 .await
26215 .unwrap_err();
26216 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26217 let conflicting_provider = process_request_inner(
26218 &shared,
26219 77,
26220 ClientRole::Operator,
26221 index_request(
26222 primary_id.clone(),
26223 agent("codex"),
26224 identity.clone(),
26225 "release shepherd",
26226 ),
26227 )
26228 .await
26229 .unwrap_err();
26230 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26231 let transcript_rejected = process_request_inner(
26232 &shared,
26233 77,
26234 ClientRole::Operator,
26235 index_request(
26236 primary_id,
26237 agent("claude"),
26238 ProviderSessionIdentity {
26239 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26240 ..identity
26241 },
26242 "release shepherd",
26243 ),
26244 )
26245 .await
26246 .unwrap_err();
26247 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26248 let upserts = shared
26249 .history
26250 .lock()
26251 .unwrap_or_else(|poisoned| poisoned.into_inner())
26252 .events
26253 .iter()
26254 .filter(|event| matches!(
26255 &event.event,
26256 NodeEvent::SessionRecordUpserted { record: published }
26257 if published.record_id == record.record_id
26258 ))
26259 .count();
26260 assert_eq!(upserts, 1);
26261 std::fs::remove_dir_all(secondary_root).unwrap();
26262 }
26263
26264 #[tokio::test]
26265 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26266 let fixture_root = temporary_workspace_root("standalone-independent");
26267 let target = fixture_root.join("independent");
26268 std::fs::create_dir_all(&fixture_root).unwrap();
26269 let catalog = active_registry().unwrap();
26270 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26271 let selected = WorkspaceConfig::new(
26272 WorkspaceId::new("primary").unwrap(),
26273 std::env::current_dir().unwrap(),
26274 )
26275 .unwrap();
26276 let selected_root = selected.canonical_root().to_owned();
26277 let shared = NodeShared::new(
26278 handle,
26279 "fixture-token".to_owned(),
26280 NodeId::new("node-standalone").unwrap(),
26281 vec![selected],
26282 vec![agent("claude")],
26283 );
26284 let workspace_id = WorkspaceId::new("independent").unwrap();
26285 let request = NodeRequest::CreateStandaloneWorkspace {
26286 workspace_id: workspace_id.clone(),
26287 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26288 initial_branch: Some("main".to_owned()),
26289 };
26290 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26291 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26292 }));
26293 assert!(!request_uses_unnegotiated_capability(
26294 &request,
26295 &baseline_capabilities().unwrap(),
26296 ));
26297 assert!(request_uses_unnegotiated_capability(&request, &[]));
26298
26299 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26300 .await
26301 .unwrap_err();
26302 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26303 assert!(!target.exists());
26304 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26305 .await
26306 .unwrap_err();
26307 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26308 assert!(!target.exists());
26309 shared
26310 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26311 .unwrap();
26312
26313 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26314 .await
26315 .unwrap();
26316 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26317 panic!("standalone workspace creation returned another response");
26318 };
26319 assert_eq!(workspace.workspace_id, workspace_id);
26320 assert!(target.join(".git").is_dir());
26321 let branch = run_git_bounded(
26322 target.to_str().unwrap(),
26323 &["branch", "--show-current"],
26324 GIT_OUTPUT_MAX_BYTES,
26325 )
26326 .await
26327 .unwrap();
26328 assert!(branch.success);
26329 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26330 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26331 assert_eq!(worktrees.len(), 1);
26332 let listed_root = WorkspaceConfig::new(
26333 WorkspaceId::new("listed-independent").unwrap(),
26334 &worktrees[0].path,
26335 )
26336 .unwrap();
26337 assert!(platform::roots_equal(
26338 listed_root.canonical_root(),
26339 windows_path_text(&workspace.canonical_root),
26340 ));
26341 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26342 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26343 snapshot.workspace_id == workspace_id
26344 && snapshot.canonical_root == workspace.canonical_root
26345 }));
26346 assert!(shared
26347 .history
26348 .lock()
26349 .unwrap_or_else(|poisoned| poisoned.into_inner())
26350 .events
26351 .iter()
26352 .any(|event| matches!(
26353 &event.event,
26354 NodeEvent::WorkspaceAdded { workspace }
26355 if workspace.workspace_id == workspace_id
26356 )));
26357 std::fs::remove_dir_all(fixture_root).unwrap();
26358 }
26359
26360 #[tokio::test]
26361 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26362 let fixture_root = temporary_workspace_root("standalone-compensation");
26363 let created_target = fixture_root.join("created-target");
26364 let existing_target = fixture_root.join("existing-target");
26365 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26366 let invalid_state_path = invalid_state_parent.join("state.json");
26367 std::fs::create_dir_all(&existing_target).unwrap();
26368 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26369 let catalog = active_registry().unwrap();
26370 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26371 let primary = WorkspaceConfig::new(
26372 WorkspaceId::new("primary").unwrap(),
26373 std::env::current_dir().unwrap(),
26374 )
26375 .unwrap();
26376 let mut shared = NodeShared::new(
26377 handle,
26378 "fixture-token".to_owned(),
26379 NodeId::new("node-standalone-compensation").unwrap(),
26380 vec![primary],
26381 vec![agent("claude")],
26382 );
26383 shared.state_path = Some(invalid_state_path);
26384
26385 let created_recovery = shared
26386 .create_standalone_workspace(
26387 WorkspaceId::new("compensated").unwrap(),
26388 created_target.to_string_lossy().into_owned(),
26389 Some("main".to_owned()),
26390 )
26391 .await
26392 .unwrap_err();
26393 assert_eq!(
26394 created_recovery.code,
26395 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26396 );
26397 assert!(created_target.join(".git").is_dir());
26398 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26399 workspace.workspace_id.as_str() != "compensated"
26400 }));
26401
26402 let existing_recovery = shared
26403 .create_standalone_workspace(
26404 WorkspaceId::new("restored").unwrap(),
26405 existing_target.to_string_lossy().into_owned(),
26406 None,
26407 )
26408 .await
26409 .unwrap_err();
26410 assert_eq!(
26411 existing_recovery.code,
26412 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26413 );
26414 assert!(existing_target.is_dir());
26415 assert!(existing_target.join(".git").is_dir());
26416 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26417 workspace.workspace_id.as_str() != "restored"
26418 }));
26419
26420 let registered_empty = fixture_root.join("registered-empty");
26421 std::fs::create_dir(®istered_empty).unwrap();
26422 let catalog = active_registry().unwrap();
26423 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26424 let registered = WorkspaceConfig::new(
26425 WorkspaceId::new("registered-empty").unwrap(),
26426 ®istered_empty,
26427 )
26428 .unwrap();
26429 let duplicate_shared = NodeShared::new(
26430 handle,
26431 "fixture-token".to_owned(),
26432 NodeId::new("node-standalone-duplicate-root").unwrap(),
26433 vec![registered],
26434 vec![agent("claude")],
26435 );
26436 let duplicate = duplicate_shared
26437 .create_standalone_workspace(
26438 WorkspaceId::new("duplicate-root").unwrap(),
26439 registered_empty.to_string_lossy().into_owned(),
26440 None,
26441 )
26442 .await
26443 .unwrap_err();
26444 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26445 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26446 std::fs::remove_dir_all(fixture_root).unwrap();
26447 }
26448
26449 #[test]
26450 fn managed_record_id_collision_preserves_the_existing_record() {
26451 let catalog = active_registry().unwrap();
26452 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26453 let workspace = WorkspaceConfig::new(
26454 WorkspaceId::new("primary").unwrap(),
26455 std::env::current_dir().unwrap(),
26456 )
26457 .unwrap();
26458 let shared = NodeShared::new(
26459 handle,
26460 "fixture-token".to_owned(),
26461 NodeId::new("node-1").unwrap(),
26462 vec![workspace.clone()],
26463 vec![agent("claude")],
26464 );
26465 let record_id = SessionRecordId::new("sr-collision").unwrap();
26466 let original = ManagedSessionRecord {
26467 record_id: record_id.clone(),
26468 display_name: "original".to_owned(),
26469 provider: agent("claude"),
26470 mode: SessionMode::Pty,
26471 state: ManagedSessionState::Dormant,
26472 workspace_id: workspace.workspace_id,
26473 canonical_root: opaque_windows_path(workspace.canonical_root),
26474 provider_session: None,
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 shared.insert_record(original.clone()).unwrap();
26487 let mut collision = original.clone();
26488 collision.display_name = "replacement".to_owned();
26489 let error = shared.insert_record(collision).unwrap_err();
26490 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26491 assert_eq!(shared.record(&record_id).unwrap(), original);
26492 }
26493
26494 #[test]
26495 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26496 let catalog = active_registry().unwrap();
26497 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26498 let workspace = WorkspaceConfig::new(
26499 WorkspaceId::new("primary").unwrap(),
26500 std::env::current_dir().unwrap(),
26501 )
26502 .unwrap();
26503 let shared = NodeShared::new(
26504 handle,
26505 "fixture-token".to_owned(),
26506 NodeId::new("node-1").unwrap(),
26507 vec![workspace.clone()],
26508 vec![agent("claude")],
26509 );
26510 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26511 shared
26512 .insert_record(ManagedSessionRecord {
26513 record_id: record_id.clone(),
26514 display_name: "runtime error".to_owned(),
26515 provider: agent("claude"),
26516 mode: SessionMode::Pty,
26517 state: ManagedSessionState::IdentityPending,
26518 workspace_id: workspace.workspace_id,
26519 canonical_root: opaque_windows_path(workspace.canonical_root),
26520 provider_session: None,
26521 active_session: None,
26522 environment_profile: None,
26523 bundle: None,
26524 context_id: None,
26525 context: None,
26526 exported_context: None,
26527 task_binding: None,
26528 created_at_unix_ms: 1,
26529 updated_at_unix_ms: 1,
26530 last_error: None,
26531 })
26532 .unwrap();
26533
26534 shared
26535 .mark_record_error(
26536 &record_id,
26537 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26538 )
26539 .unwrap();
26540 let record = shared.record(&record_id).unwrap();
26541 assert_eq!(record.state, ManagedSessionState::Unavailable);
26542 assert_eq!(
26543 record.last_error.as_deref(),
26544 Some("provider-runtime-failed"),
26545 );
26546 let snapshot = shared.snapshot();
26547 assert_eq!(snapshot.session_records, vec![record]);
26548 }
26549
26550 #[test]
26559 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26560 let catalog = active_registry().unwrap();
26561 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26562 let workspace = WorkspaceConfig::new(
26563 WorkspaceId::new("primary").unwrap(),
26564 std::env::current_dir().unwrap(),
26565 )
26566 .unwrap();
26567 let shared = NodeShared::new(
26568 handle,
26569 "fixture-token".to_owned(),
26570 NodeId::new("node-1").unwrap(),
26571 vec![workspace],
26572 vec![agent("claude")],
26573 );
26574
26575 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26576
26577 shared.record_rejected_command(
26578 CommandId(1),
26579 "agent 'claude' does not support transport Acp".to_owned(),
26580 true,
26581 );
26582 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26584
26585 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26586 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26587 assert!(reason.unsupported_transport);
26588
26589 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26591
26592 shared.record_rejected_command(
26597 CommandId(3),
26598 "backend busy".to_owned(),
26599 false,
26600 );
26601 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26602 assert!(!unclassified.unsupported_transport);
26603 }
26604
26605 #[test]
26606 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26607 let catalog = active_registry().unwrap();
26608 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26609 let workspace = WorkspaceConfig::new(
26610 WorkspaceId::new("primary").unwrap(),
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-identity-policy").unwrap(),
26618 vec![workspace.clone()],
26619 vec![agent("claude")],
26620 );
26621 let address = SessionAddress {
26622 workspace_id: workspace.workspace_id.clone(),
26623 session: SessionKey {
26624 instance_id: AgentInstanceId(90),
26625 generation: SessionGeneration(1),
26626 },
26627 };
26628 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26629 shared
26630 .insert_record(ManagedSessionRecord {
26631 record_id: record_id.clone(),
26632 display_name: "policy denied".to_owned(),
26633 provider: agent("claude"),
26634 mode: SessionMode::Pty,
26635 state: ManagedSessionState::IdentityPending,
26636 workspace_id: workspace.workspace_id,
26637 canonical_root: opaque_windows_path(workspace.canonical_root),
26638 provider_session: None,
26639 active_session: Some(address.clone()),
26640 environment_profile: None,
26641 bundle: None,
26642 context_id: None,
26643 context: None,
26644 exported_context: None,
26645 task_binding: None,
26646 created_at_unix_ms: 1,
26647 updated_at_unix_ms: 1,
26648 last_error: None,
26649 })
26650 .unwrap();
26651 let event = ControlEvent {
26652 sequence: 1,
26653 command_id: None,
26654 instance_id: address.session.instance_id,
26655 generation: address.session.generation,
26656 event: ControlEventKind::ProviderEvent {
26657 sequence: 1,
26658 source: gate4agent_types::ProviderSource {
26659 family: AdapterFamily::PtySemantic,
26660 binding: AdapterBinding::new(
26661 gate4agent_types::AdapterId::new("claude").unwrap(),
26662 "fixture/v1",
26663 gate4agent_types::AdapterVerification::SyntheticFixture,
26664 )
26665 .unwrap(),
26666 },
26667 source_sequence: 1,
26668 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26669 identity: gate4agent_types::ProviderSessionIdentity {
26670 key: gate4agent_types::ProviderSessionKey::SessionId,
26671 id: "provider-session".to_owned(),
26672 transcript_path: None,
26673 },
26674 },
26675 },
26676 };
26677
26678 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26679
26680 let record = shared.record(&record_id).unwrap();
26681 assert!(record.provider_session.is_none());
26682 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26683 assert_eq!(record.active_session.as_ref(), Some(&address));
26684 }
26685
26686 #[test]
26687 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26688 let secondary_root = temporary_workspace_root("identity-scope");
26689 std::fs::create_dir_all(&secondary_root).unwrap();
26690 let catalog = active_registry().unwrap();
26691 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26692 let primary = WorkspaceConfig::new(
26693 WorkspaceId::new("primary").unwrap(),
26694 std::env::current_dir().unwrap(),
26695 )
26696 .unwrap();
26697 let secondary = WorkspaceConfig::new(
26698 WorkspaceId::new("secondary").unwrap(),
26699 &secondary_root,
26700 )
26701 .unwrap();
26702 let shared = NodeShared::new(
26703 handle,
26704 "fixture-token".to_owned(),
26705 NodeId::new("node-1").unwrap(),
26706 vec![primary.clone(), secondary.clone()],
26707 vec![agent("claude")],
26708 );
26709 let identity = gate4agent_types::ProviderSessionIdentity {
26710 key: gate4agent_types::ProviderSessionKey::SessionId,
26711 id: "provider-session".to_owned(),
26712 transcript_path: None,
26713 };
26714 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26715 shared
26716 .insert_record(ManagedSessionRecord {
26717 record_id: dormant_id.clone(),
26718 display_name: "dormant".to_owned(),
26719 provider: agent("claude"),
26720 mode: SessionMode::Pty,
26721 state: ManagedSessionState::Dormant,
26722 workspace_id: primary.workspace_id,
26723 canonical_root: opaque_windows_path(primary.canonical_root),
26724 provider_session: Some(identity.clone()),
26725 active_session: None,
26726 environment_profile: None,
26727 bundle: None,
26728 context_id: None,
26729 context: None,
26730 exported_context: None,
26731 task_binding: None,
26732 created_at_unix_ms: 1,
26733 updated_at_unix_ms: 1,
26734 last_error: None,
26735 })
26736 .unwrap();
26737 let address = SessionAddress {
26738 workspace_id: secondary.workspace_id.clone(),
26739 session: SessionKey {
26740 instance_id: AgentInstanceId(91),
26741 generation: SessionGeneration(1),
26742 },
26743 };
26744 let current_id = SessionRecordId::new("sr-current").unwrap();
26745 shared
26746 .insert_record(ManagedSessionRecord {
26747 record_id: current_id.clone(),
26748 display_name: "current".to_owned(),
26749 provider: agent("claude"),
26750 mode: SessionMode::Pty,
26751 state: ManagedSessionState::IdentityPending,
26752 workspace_id: secondary.workspace_id,
26753 canonical_root: opaque_windows_path(secondary.canonical_root),
26754 provider_session: None,
26755 active_session: Some(address.clone()),
26756 environment_profile: None,
26757 bundle: None,
26758 context_id: None,
26759 context: None,
26760 exported_context: None,
26761 task_binding: None,
26762 created_at_unix_ms: 2,
26763 updated_at_unix_ms: 2,
26764 last_error: None,
26765 })
26766 .unwrap();
26767 shared.bind_managed_session(
26768 &address,
26769 current_id.clone(),
26770 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26771 None,
26772 );
26773 let event = ControlEvent {
26774 sequence: 1,
26775 command_id: None,
26776 instance_id: address.session.instance_id,
26777 generation: address.session.generation,
26778 event: ControlEventKind::ProviderEvent {
26779 sequence: 1,
26780 source: gate4agent_types::ProviderSource {
26781 family: gate4agent_types::AdapterFamily::PtySemantic,
26782 binding: gate4agent_types::AdapterBinding::new(
26783 gate4agent_types::AdapterId::new("claude").unwrap(),
26784 "fixture/v1",
26785 gate4agent_types::AdapterVerification::SyntheticFixture,
26786 )
26787 .unwrap(),
26788 },
26789 source_sequence: 1,
26790 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26791 identity,
26792 },
26793 },
26794 };
26795 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26796
26797 let binding = shared
26798 .session_bindings
26799 .lock()
26800 .unwrap_or_else(|poisoned| poisoned.into_inner())
26801 .get(&address.session.instance_id)
26802 .cloned()
26803 .unwrap();
26804 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26805 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26806 let current = shared.record(¤t_id).unwrap();
26807 assert_eq!(current.state, ManagedSessionState::Unavailable);
26808 assert_eq!(current.active_session.as_ref(), Some(&address));
26809 assert_eq!(
26810 current.last_error.as_deref(),
26811 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26812 );
26813 std::fs::remove_dir_all(secondary_root).unwrap();
26814 }
26815
26816 #[tokio::test]
26817 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26818 let root = temporary_workspace_root("semantic-provider-session");
26819 std::fs::create_dir_all(&root).unwrap();
26820 let state_path = root.join("state-v1.json");
26821 let catalog = active_registry().unwrap();
26822 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26823 let workspace = WorkspaceConfig::new(
26824 WorkspaceId::new("primary").unwrap(),
26825 &root,
26826 )
26827 .unwrap();
26828 let node_id = NodeId::new("node-semantic-identity").unwrap();
26829 let shared = NodeShared::new_with_incarnation(
26830 handle,
26831 "fixture-token".to_owned(),
26832 node_id.clone(),
26833 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26834 vec![workspace.clone()],
26835 vec![agent("claude")],
26836 ProviderRuntimeStatuses::default(),
26837 None,
26838 Vec::new(),
26839 Vec::new(),
26840 SpawnProfileRegistry::default(),
26841 None,
26842 None,
26843 None,
26844 Some(state_path.clone()),
26845 Vec::new(),
26846 Vec::new(),
26847 Vec::new(),
26848 Vec::new(),
26849 None,
26850 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26851 );
26852 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26853 key: gate4agent_types::ProviderSessionKey::SessionId,
26854 id: "provider-session".to_owned(),
26855 transcript_path: None,
26856 };
26857 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26858 shared
26859 .insert_record(ManagedSessionRecord {
26860 record_id: durable_id.clone(),
26861 display_name: "durable".to_owned(),
26862 provider: agent("claude"),
26863 mode: SessionMode::Pty,
26864 state: ManagedSessionState::Dormant,
26865 workspace_id: workspace.workspace_id.clone(),
26866 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26867 provider_session: Some(persisted_identity.clone()),
26868 active_session: None,
26869 environment_profile: None,
26870 bundle: None,
26871 context_id: None,
26872 context: None,
26873 exported_context: None,
26874 task_binding: None,
26875 created_at_unix_ms: 1,
26876 updated_at_unix_ms: 1,
26877 last_error: None,
26878 })
26879 .unwrap();
26880 let address = SessionAddress {
26881 workspace_id: workspace.workspace_id.clone(),
26882 session: SessionKey {
26883 instance_id: AgentInstanceId(92),
26884 generation: SessionGeneration(2),
26885 },
26886 };
26887 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26888 shared
26889 .insert_record(ManagedSessionRecord {
26890 record_id: pending_id.clone(),
26891 display_name: "pending".to_owned(),
26892 provider: agent("claude"),
26893 mode: SessionMode::Pty,
26894 state: ManagedSessionState::IdentityPending,
26895 workspace_id: workspace.workspace_id.clone(),
26896 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26897 provider_session: None,
26898 active_session: Some(address.clone()),
26899 environment_profile: None,
26900 bundle: None,
26901 context_id: None,
26902 context: None,
26903 exported_context: None,
26904 task_binding: None,
26905 created_at_unix_ms: 2,
26906 updated_at_unix_ms: 2,
26907 last_error: None,
26908 })
26909 .unwrap();
26910 shared.bind_managed_session(
26911 &address,
26912 pending_id.clone(),
26913 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26914 None,
26915 );
26916 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26917 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26918 ..persisted_identity
26919 };
26920 let event = ControlEvent {
26921 sequence: 1,
26922 command_id: None,
26923 instance_id: address.session.instance_id,
26924 generation: address.session.generation,
26925 event: ControlEventKind::ProviderEvent {
26926 sequence: 1,
26927 source: gate4agent_types::ProviderSource {
26928 family: gate4agent_types::AdapterFamily::PtySemantic,
26929 binding: gate4agent_types::AdapterBinding::new(
26930 gate4agent_types::AdapterId::new("claude").unwrap(),
26931 "fixture/v1",
26932 gate4agent_types::AdapterVerification::SyntheticFixture,
26933 )
26934 .unwrap(),
26935 },
26936 source_sequence: 1,
26937 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26938 identity: observed_identity.clone(),
26939 },
26940 },
26941 };
26942 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26943
26944 let snapshot = shared.snapshot();
26945 assert_eq!(snapshot.session_records.len(), 1);
26946 let live = &snapshot.session_records[0];
26947 assert_eq!(live.record_id, durable_id);
26948 assert_eq!(live.state, ManagedSessionState::Live);
26949 assert_eq!(live.active_session.as_ref(), Some(&address));
26950 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26951 let binding = shared
26952 .session_bindings
26953 .lock()
26954 .unwrap_or_else(|poisoned| poisoned.into_inner())
26955 .get(&address.session.instance_id)
26956 .cloned()
26957 .unwrap();
26958 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26959
26960 let mut duplicate = live.clone();
26961 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26962 duplicate.display_name = "duplicate".to_owned();
26963 duplicate.state = ManagedSessionState::Dormant;
26964 duplicate.active_session = None;
26965 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26966 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26967 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26968 assert_eq!(shared.snapshot().session_records.len(), 1);
26969
26970 let terminal_size = gate4agent_types::TerminalSize {
26971 rows: 24,
26972 columns: 80,
26973 };
26974 let (first_busy, second_busy) = tokio::join!(
26975 shared.resume_session_record(&durable_id, terminal_size, None),
26976 shared.resume_session_record(&durable_id, terminal_size, None),
26977 );
26978 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26979 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26980 assert_eq!(busy.message, "session-record-busy");
26981 }
26982
26983 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26984 assert_eq!(loaded.records.len(), 1);
26985 assert_eq!(loaded.records[0].record_id, durable_id);
26986 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26987 assert_eq!(
26988 loaded.records[0]
26989 .provider_session
26990 .as_ref()
26991 .unwrap()
26992 .transcript_path,
26993 None,
26994 );
26995 let catalog = active_registry().unwrap();
26996 let (reloaded_handle, _reloaded_runtime) =
26997 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26998 let reloaded = NodeShared::new_with_incarnation(
26999 reloaded_handle,
27000 "fixture-token".to_owned(),
27001 node_id,
27002 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27003 vec![workspace],
27004 vec![agent("claude")],
27005 ProviderRuntimeStatuses::default(),
27006 None,
27007 Vec::new(),
27008 Vec::new(),
27009 SpawnProfileRegistry::default(),
27010 None,
27011 None,
27012 None,
27013 None,
27014 loaded.records,
27015 loaded.managed_worktrees,
27016 loaded.managed_worktree_tombstones,
27017 loaded.materializations,
27018 loaded.warning,
27019 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27020 );
27021 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27022 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27023 std::fs::remove_dir_all(root).unwrap();
27024 }
27025
27026 #[tokio::test]
27027 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27028 let secondary_root = temporary_workspace_root("workspace-restore");
27029 let other_root = temporary_workspace_root("workspace-conflict");
27030 std::fs::create_dir_all(&secondary_root).unwrap();
27031 std::fs::create_dir_all(&other_root).unwrap();
27032 let catalog = active_registry().unwrap();
27033 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27034 let primary = WorkspaceConfig::new(
27035 WorkspaceId::new("primary").unwrap(),
27036 std::env::current_dir().unwrap(),
27037 )
27038 .unwrap();
27039 let secondary = WorkspaceConfig::new(
27040 WorkspaceId::new("secondary").unwrap(),
27041 &secondary_root,
27042 )
27043 .unwrap();
27044 let shared = NodeShared::new(
27045 handle,
27046 "fixture-token".to_owned(),
27047 NodeId::new("node-1").unwrap(),
27048 vec![primary, secondary.clone()],
27049 vec![agent("claude")],
27050 );
27051 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27052 shared
27053 .insert_record(ManagedSessionRecord {
27054 record_id: record_id.clone(),
27055 display_name: "workspace record".to_owned(),
27056 provider: agent("claude"),
27057 mode: SessionMode::Pty,
27058 state: ManagedSessionState::Dormant,
27059 workspace_id: secondary.workspace_id.clone(),
27060 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27061 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27062 key: gate4agent_types::ProviderSessionKey::SessionId,
27063 id: "provider-session".to_owned(),
27064 transcript_path: None,
27065 }),
27066 active_session: None,
27067 environment_profile: None,
27068 bundle: None,
27069 context_id: None,
27070 context: None,
27071 exported_context: None,
27072 task_binding: None,
27073 created_at_unix_ms: 1,
27074 updated_at_unix_ms: 1,
27075 last_error: None,
27076 })
27077 .unwrap();
27078 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27079 let unavailable = shared.record(&record_id).unwrap();
27080 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27081 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27082
27083 let conflict = shared
27084 .register_workspace(
27085 secondary.workspace_id.clone(),
27086 other_root.to_string_lossy().into_owned(),
27087 )
27088 .await
27089 .unwrap_err();
27090 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27091 shared
27092 .register_workspace(
27093 secondary.workspace_id,
27094 secondary_root.to_string_lossy().into_owned(),
27095 )
27096 .await
27097 .unwrap();
27098 let restored = shared.record(&record_id).unwrap();
27099 assert_eq!(restored.state, ManagedSessionState::Dormant);
27100 assert!(restored.last_error.is_none());
27101 std::fs::remove_dir_all(secondary_root).unwrap();
27102 std::fs::remove_dir_all(other_root).unwrap();
27103 }
27104
27105 #[test]
27106 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27107 let error = validate_workspace_request_root(
27108 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27109 )
27110 .unwrap_err();
27111 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27112 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27113 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27114 }
27115
27116 #[test]
27117 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27118 let catalog = active_registry().unwrap();
27119 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27120 let workspace = WorkspaceConfig::new(
27121 WorkspaceId::new("primary").unwrap(),
27122 std::env::current_dir().unwrap(),
27123 )
27124 .unwrap();
27125 let shared = NodeShared::new(
27126 handle,
27127 "fixture-token".to_owned(),
27128 NodeId::new("node-1").unwrap(),
27129 vec![workspace],
27130 vec![agent("claude")],
27131 );
27132 let publishers = 4_u64;
27133 let events_per_publisher = 256_u64;
27134 let start = std::sync::Barrier::new(publishers as usize + 1);
27135 let finished = AtomicU64::new(0);
27136
27137 std::thread::scope(|scope| {
27138 for _ in 0..publishers {
27139 scope.spawn(|| {
27140 start.wait();
27141 for _ in 0..events_per_publisher {
27142 shared.publish(NodeEvent::ControllerChanged { controller: None });
27143 }
27144 finished.fetch_add(1, Ordering::Release);
27145 });
27146 }
27147 start.wait();
27148 while finished.load(Ordering::Acquire) != publishers {
27149 assert_resync_history_is_linearized(shared.resync(0));
27150 std::thread::yield_now();
27151 }
27152 });
27153 let response = shared.resync(0);
27154 assert_resync_history_is_linearized(response.clone());
27155 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27156 unreachable!("resync helper returned another response");
27157 };
27158 assert_eq!(event_sequence, publishers * events_per_publisher);
27159 assert_eq!(events.len() as u64, event_sequence);
27160 }
27161
27162 fn assert_resync_history_is_linearized(response: NodeResponse) {
27163 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27164 unreachable!("resync helper returned another response");
27165 };
27166 assert!(events.windows(2).all(|pair| {
27167 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27168 }));
27169 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27170 }
27171
27172 #[test]
27173 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27174 assert_eq!(
27175 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27176 r"C:\repo\worktree",
27177 );
27178 assert_eq!(
27179 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27180 r"\\server\share\repo",
27181 );
27182 assert!(validate_workspace_root(
27183 &WorkspaceId::new("network").unwrap(),
27184 r"\\server\share\repo",
27185 )
27186 .is_err());
27187 let workspace = WorkspaceConfig::new(
27188 WorkspaceId::new("primary").unwrap(),
27189 std::env::current_dir().unwrap(),
27190 )
27191 .unwrap();
27192 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27193 }
27194
27195 #[test]
27196 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27197 let catalog = active_registry().unwrap();
27198 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27199 let workspace_id = WorkspaceId::new("primary").unwrap();
27200 let workspace = WorkspaceConfig::new(
27201 workspace_id.clone(),
27202 std::env::current_dir().unwrap(),
27203 )
27204 .unwrap();
27205 let shared = NodeShared::new(
27206 handle,
27207 "fixture-token".to_owned(),
27208 NodeId::new("node-1").unwrap(),
27209 vec![workspace],
27210 vec![agent("claude")],
27211 );
27212 let instance_id = AgentInstanceId(17);
27213 let original = SessionAddress {
27214 workspace_id,
27215 session: SessionKey {
27216 instance_id,
27217 generation: SessionGeneration(3),
27218 },
27219 };
27220 shared.bind_session(
27221 instance_id,
27222 original.workspace_id.clone(),
27223 original.session.generation,
27224 );
27225 shared
27226 .arm_resume(
27227 &original,
27228 CommandId(41),
27229 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27230 )
27231 .unwrap();
27232
27233 shared.publish_control(ControlEvent {
27234 sequence: 1,
27235 command_id: None,
27236 instance_id,
27237 generation: SessionGeneration(4),
27238 event: ControlEventKind::Running { process_id: None },
27239 });
27240 assert_eq!(
27241 shared.address_for(instance_id, SessionGeneration(3)),
27242 Some(original.clone()),
27243 );
27244 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27245
27246 shared.publish_control(ControlEvent {
27247 sequence: 2,
27248 command_id: Some(CommandId(42)),
27249 instance_id,
27250 generation: SessionGeneration(3),
27251 event: ControlEventKind::CommandRejected {
27252 message: "unrelated command".to_owned(),
27253 },
27254 });
27255
27256 shared.publish_control(ControlEvent {
27257 sequence: 3,
27258 command_id: None,
27259 instance_id,
27260 generation: SessionGeneration(4),
27261 event: ControlEventKind::ResumeAuthorized {
27262 session: gate4agent_types::ResumeSessionSummary {
27263 key: gate4agent_types::ProviderSessionKey::SessionId,
27264 id: "fixture-session".to_owned(),
27265 },
27266 },
27267 });
27268 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27269 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27270 assert_eq!(rebound.workspace_id, original.workspace_id);
27271 let history = shared
27272 .history
27273 .lock()
27274 .unwrap_or_else(|poisoned| poisoned.into_inner());
27275 assert!(history.events.iter().any(|envelope| matches!(
27276 &envelope.event,
27277 NodeEvent::Control { address, event }
27278 if address == &rebound
27279 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27280 )));
27281 }
27282
27283 #[test]
27284 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27285 for agent in ["claude", "codex", "kimi"] {
27286 assert_eq!(
27287 prompt_framing(&AgentId::new(agent).unwrap()),
27288 PromptFraming::BracketedPaste
27289 );
27290 }
27291 }
27292
27293 fn generous_walk_deadline() -> Instant {
27294 Instant::now() + Duration::from_secs(30)
27295 }
27296
27297 #[test]
27298 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27299 let root = temporary_workspace_root("tree");
27300 std::fs::create_dir_all(root.join("src")).unwrap();
27301 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27302 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27303 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27304 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27305 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27306 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27312 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27313 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27317 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27318
27319 let (entries, truncated, _budget) =
27320 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27321 assert!(!truncated);
27322 assert!(entries.iter().any(|entry| {
27323 entry.relative_path.as_utf8() == Some("src")
27324 && entry.kind == WorkspaceEntryKind::Directory
27325 }));
27326 assert!(entries.iter().any(|entry| {
27327 entry.relative_path.as_utf8() == Some("src/lib.rs")
27328 && entry.kind == WorkspaceEntryKind::File
27329 }));
27330 assert!(entries.iter().any(|entry| {
27331 entry.relative_path.as_utf8() == Some("target-notes")
27332 && entry.kind == WorkspaceEntryKind::Directory
27333 }));
27334 assert!(entries.iter().any(|entry| {
27335 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27336 && entry.kind == WorkspaceEntryKind::File
27337 }));
27338 assert!(entries.iter().all(|entry| {
27339 let path = entry.relative_path.as_utf8().unwrap();
27340 !path.starts_with(".git")
27341 && !(path == "target" || path.starts_with("target/"))
27342 && !(path == "target-a2" || path.starts_with("target-a2/"))
27343 && !path.starts_with("node_modules")
27344 }));
27345
27346 std::fs::remove_dir_all(&root).unwrap();
27347 }
27348
27349 #[test]
27350 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27351 let root = temporary_workspace_root("tree-entry-cap");
27352 std::fs::create_dir_all(&root).unwrap();
27353 for index in 0..20 {
27354 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27355 }
27356
27357 let (entries, truncated, budget) =
27358 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27359 assert!(truncated);
27360 assert!(budget.entry_cap_exceeded);
27361 assert!(!budget.time_budget_exceeded);
27362 assert!(entries.len() <= 5);
27363
27364 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27365 .expect("an entry-cap-truncated walk must report a truncation marker");
27366 assert!(inspection_truncation.walk_entry_cap_exceeded);
27367 assert!(!inspection_truncation.walk_time_budget_exceeded);
27368 assert!(!inspection_truncation.git_time_budget_exceeded);
27369 assert!(inspection_truncation.entries_visited >= 5);
27370
27371 std::fs::remove_dir_all(&root).unwrap();
27372 }
27373
27374 #[test]
27375 fn workspace_walk_within_budget_reports_no_truncation() {
27376 let root = temporary_workspace_root("tree-no-truncation");
27377 std::fs::create_dir_all(&root).unwrap();
27378 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27379
27380 let (_entries, truncated, budget) =
27381 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27382 assert!(!truncated);
27383 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27384
27385 std::fs::remove_dir_all(&root).unwrap();
27386 }
27387
27388 #[test]
27389 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27390 let root = temporary_workspace_root("tree-prefix-siblings");
27391 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27392 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27393 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27394 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27395
27396 let (entries, truncated, _budget) =
27397 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27398 assert!(!truncated);
27399 let paths = entries
27400 .iter()
27401 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27402 .collect::<Vec<_>>();
27403 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27404 assert_eq!(
27405 paths,
27406 vec![
27407 "gate4agent-c2".to_owned(),
27408 "gate4agent-c2-client".to_owned(),
27409 "gate4agent-c2-client/lib.rs".to_owned(),
27410 "gate4agent-c2/src".to_owned(),
27411 "gate4agent-c2/src/lib.rs".to_owned(),
27412 ],
27413 );
27414
27415 std::fs::remove_dir_all(&root).unwrap();
27416 }
27417
27418 #[test]
27419 fn git_parsers_cap_status_and_commit_results() {
27420 let mut status_output = Vec::new();
27421 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27422 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27423 }
27424 let mut snapshot = git_snapshot_for_parser();
27425 parse_git_status(&status_output, &mut snapshot);
27426 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27427 assert!(snapshot.truncated);
27428 assert_eq!(snapshot.status[0].index_status, " ");
27429 assert_eq!(snapshot.status[0].worktree_status, "M");
27430
27431 snapshot.truncated = false;
27432 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27433 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27434 .collect::<String>();
27435 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27436 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27437 assert!(snapshot.truncated);
27438 }
27439
27440 #[test]
27441 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27442 let mut snapshot = git_snapshot_for_parser();
27443 parse_git_status(
27444 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27445 &mut snapshot,
27446 );
27447
27448 assert!(!snapshot.truncated);
27449 assert_eq!(snapshot.status.len(), 3);
27450 assert_eq!(snapshot.status[0].index_status, "R");
27451 assert_eq!(snapshot.status[0].worktree_status, " ");
27452 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27453 assert_eq!(
27454 snapshot.status[0]
27455 .previous_path
27456 .as_ref()
27457 .and_then(RepositoryPath::as_utf8),
27458 Some("src/previous name.rs"),
27459 );
27460 assert_eq!(snapshot.status[1].index_status, " ");
27461 assert_eq!(snapshot.status[1].worktree_status, "C");
27462 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27463 assert_eq!(
27464 snapshot.status[1]
27465 .previous_path
27466 .as_ref()
27467 .and_then(RepositoryPath::as_utf8),
27468 Some("src/original.rs"),
27469 );
27470 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27471 assert!(snapshot.status[2].previous_path.is_none());
27472 }
27473
27474 #[test]
27475 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27476 let mut snapshot = git_snapshot_for_parser();
27477 parse_git_status(
27478 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27479 &mut snapshot,
27480 );
27481
27482 assert!(snapshot.truncated);
27483 assert_eq!(snapshot.status.len(), 1);
27484 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27485 assert!(snapshot.status[0].previous_path.is_none());
27486 }
27487
27488 #[tokio::test]
27489 async fn observer_can_browse_host_directories_without_controller() {
27490 let root = temporary_workspace_root("host-directory-observer");
27491 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27492 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27493 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27494 let workspace_id = WorkspaceId::new("primary").unwrap();
27495 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27496 let catalog = active_registry().unwrap();
27497 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27498 let shared = NodeShared::new(
27499 handle,
27500 "fixture-token".to_owned(),
27501 NodeId::new("node-1").unwrap(),
27502 vec![workspace],
27503 vec![agent("claude")],
27504 );
27505 let request = NodeRequest::BrowseHostDirectories {
27506 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27507 after: None,
27508 };
27509 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27510 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27511 }));
27512 assert!(!request_uses_unnegotiated_capability(
27513 &request,
27514 &baseline_capabilities().unwrap(),
27515 ));
27516 assert!(request_uses_unnegotiated_capability(&request, &[]));
27517
27518 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27519 .await
27520 .unwrap();
27521 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27522 panic!("host directory browse returned another response");
27523 };
27524 assert_eq!(
27525 listing
27526 .entries
27527 .iter()
27528 .map(|entry| entry.display_name.as_str())
27529 .collect::<Vec<_>>(),
27530 vec!["directory-a", "directory-b"],
27531 );
27532 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27533 assert!(!listing.incomplete);
27534 std::fs::remove_dir_all(root).unwrap();
27535 }
27536
27537 #[tokio::test]
27538 async fn observer_can_inspect_only_a_registered_workspace() {
27539 let root = temporary_workspace_root("observer");
27540 std::fs::create_dir_all(root.join("src")).unwrap();
27541 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27542 let workspace_id = WorkspaceId::new("primary").unwrap();
27543 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27544 let catalog = active_registry().unwrap();
27545 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27546 let shared = NodeShared::new(
27547 handle,
27548 "fixture-token".to_owned(),
27549 NodeId::new("node-1").unwrap(),
27550 vec![workspace],
27551 vec![agent("claude")],
27552 );
27553
27554 let response = process_request_inner(
27555 &shared,
27556 77,
27557 ClientRole::Observer,
27558 NodeRequest::InspectWorkspace {
27559 workspace_id: workspace_id.clone(),
27560 },
27561 )
27562 .await
27563 .unwrap();
27564 let NodeResponse::WorkspaceInspected { inspection } = response else {
27565 panic!("workspace inspection returned another response");
27566 };
27567 assert_eq!(inspection.workspace_id, workspace_id);
27568 assert!(inspection.entries.iter().any(|entry| {
27569 entry.relative_path.as_utf8() == Some("src/main.rs")
27570 }));
27571
27572 let unknown = process_request_inner(
27573 &shared,
27574 77,
27575 ClientRole::Observer,
27576 NodeRequest::InspectWorkspace {
27577 workspace_id: WorkspaceId::new("unknown").unwrap(),
27578 },
27579 )
27580 .await
27581 .unwrap_err();
27582 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27583 std::fs::remove_dir_all(&root).unwrap();
27584 }
27585
27586 #[test]
27587 fn context_pack_source_accepts_only_running_or_successful_exit() {
27588 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27589 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27590 exit_code: Some(0),
27591 }));
27592 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27593 exit_code: Some(1),
27594 }));
27595 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27596 exit_code: None,
27597 }));
27598 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27599 message: "provider failed".to_owned(),
27600 }));
27601 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27602 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27603 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27604 }
27605
27606 #[test]
27607 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27608 let git = git_snapshot_for_parser();
27609 let first = ContextPackRepositoryObservation {
27610 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27611 git: git.clone(),
27612 files: Vec::new(),
27613 };
27614 let second = ContextPackRepositoryObservation {
27615 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27616 git,
27617 files: Vec::new(),
27618 };
27619
27620 let error = stable_context_pack_repository(first, second).unwrap_err();
27621
27622 assert_eq!(
27623 error.code,
27624 NodeFailureCode::ContextPackMaterializationFailed,
27625 );
27626 }
27627
27628 #[test]
27629 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27630 let git = git_snapshot_for_parser();
27631 let first = ContextPackRepositoryObservation {
27632 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27633 git: git.clone(),
27634 files: vec![ContextPackRepositoryFileSource::utf8(
27635 "README.md",
27636 "first\n".to_owned(),
27637 6,
27638 )],
27639 };
27640 let second = ContextPackRepositoryObservation {
27641 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27642 git,
27643 files: vec![ContextPackRepositoryFileSource::utf8(
27644 "README.md",
27645 "other\n".to_owned(),
27646 6,
27647 )],
27648 };
27649
27650 let error = stable_context_pack_repository(first, second).unwrap_err();
27651
27652 assert_eq!(
27653 error.code,
27654 NodeFailureCode::ContextPackMaterializationFailed,
27655 );
27656 }
27657
27658 #[tokio::test]
27659 async fn native_session_catalog_zero_active_session_fixture() {
27660 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27661 let history_root = temporary_workspace_root("native-catalog-history");
27662 let outside_root = temporary_workspace_root("native-catalog-outside");
27663 std::fs::create_dir_all(&workspace_root).unwrap();
27664 std::fs::create_dir_all(&history_root).unwrap();
27665 std::fs::create_dir_all(&outside_root).unwrap();
27666 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27667 [
27668 serde_json::json!({
27669 "type": "user",
27670 "sessionId": session_id,
27671 "cwd": cwd.to_str().unwrap(),
27672 "message": { "content": "raw user text must not cross the catalog wire" }
27673 }),
27674 serde_json::json!({
27675 "type": "assistant",
27676 "sessionId": session_id,
27677 "cwd": cwd.to_str().unwrap(),
27678 "message": { "model": "claude-sonnet", "content": "raw answer" }
27679 }),
27680 serde_json::json!({
27681 "type": "ai-title",
27682 "sessionId": session_id,
27683 "aiTitle": title
27684 }),
27685 ]
27686 .into_iter()
27687 .map(|record| record.to_string())
27688 .collect::<Vec<_>>()
27689 .join("\n")
27690 };
27691 std::fs::write(
27692 history_root.join("stable-session.jsonl"),
27693 transcript("stable-session", &workspace_root, "Workspace review"),
27694 )
27695 .unwrap();
27696 std::fs::write(
27697 history_root.join("outside-session.jsonl"),
27698 transcript("outside-session", &outside_root, "Outside review"),
27699 )
27700 .unwrap();
27701 let history = NativeHistoryConfig::new(vec![
27702 NativeHistoryRoot::new(
27703 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27704 HistorySourceLayout::SingleNdjson,
27705 history_root.clone(),
27706 )
27707 .unwrap(),
27708 ])
27709 .unwrap();
27710 let catalog = active_registry().unwrap();
27711 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27712 assert!(handle.snapshot().sessions.is_empty());
27713 let workspace_id = WorkspaceId::new("primary").unwrap();
27714 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27715 let mut shared = NodeShared::new(
27716 handle,
27717 "fixture-token".to_owned(),
27718 NodeId::new("node-native-catalog").unwrap(),
27719 vec![workspace],
27720 vec![agent("claude")],
27721 );
27722 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27723 NativeSessionCatalogAuthority::new(history),
27724 )));
27725 let route = NativeSessionCatalogRoute::workspace(
27726 workspace_id.clone(),
27727 agent("claude"),
27728 );
27729 let request = NodeRequest::CatalogNativeSessions {
27730 route: route.clone(),
27731 limit: 2,
27732 };
27733 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27734 .await
27735 .unwrap_err();
27736 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27737 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27738 .await
27739 .unwrap();
27740 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27741 panic!("native catalog returned a different response");
27742 };
27743 let summary = summary.expect("node must emit native session catalog summary metadata");
27744 assert_eq!(entries.len(), 1);
27745 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27746 assert_eq!(entries[0].external_group, None);
27747 assert_eq!(entries[0].record_id, None);
27748 assert_eq!(entries[0].title, None);
27749 assert_eq!(entries[0].model, None);
27750 assert_eq!(entries[0].message_count, 0);
27751 let encoded_entries = serde_json::to_string(&entries).unwrap();
27752 assert!(!encoded_entries.contains("stable-session"));
27753 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27754 let page_request = NodeRequest::PageNativeSessions {
27755 route: route.clone(),
27756 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27757 catalog_revision: summary.catalog_revision,
27758 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27759 after_selection_id: None,
27760 limit: 2,
27761 };
27762 let observer = process_request_inner(
27763 &shared,
27764 7,
27765 ClientRole::Observer,
27766 page_request.clone(),
27767 )
27768 .await
27769 .unwrap_err();
27770 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27771 let page_response = process_request_inner(
27772 &shared,
27773 7,
27774 ClientRole::Operator,
27775 page_request.clone(),
27776 )
27777 .await
27778 .unwrap();
27779 assert!(matches!(
27780 page_response,
27781 NodeResponse::NativeSessionsPaged { page, .. }
27782 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27783 ));
27784 let stale = process_request_inner(
27785 &shared,
27786 7,
27787 ClientRole::Operator,
27788 NodeRequest::PageNativeSessions {
27789 route: route.clone(),
27790 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27791 catalog_revision: summary.catalog_revision.wrapping_add(1),
27792 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27793 after_selection_id: None,
27794 limit: 2,
27795 },
27796 )
27797 .await
27798 .unwrap_err();
27799 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27800 let selection = NativeSessionSelection {
27801 route: route.clone(),
27802 catalog_revision: summary.catalog_revision,
27803 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27804 selection_id: entries[0].selection_id.clone(),
27805 };
27806 let preview_response = process_request_inner(
27807 &shared,
27808 7,
27809 ClientRole::Operator,
27810 NodeRequest::PreviewNativeSession {
27811 selection: selection.clone(),
27812 message_limit: 1,
27813 },
27814 )
27815 .await
27816 .unwrap();
27817 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27818 panic!("native preview returned a different response");
27819 };
27820 assert_eq!(preview.messages.len(), 1);
27821 assert!(preview.message_count_exact);
27822 assert_eq!(preview.messages[0].text, "raw answer");
27823 assert!(preview.truncated);
27824
27825 let index_request = NodeRequest::IndexNativeSession {
27826 selection: selection.clone(),
27827 display_name: "Saved Claude".to_owned(),
27828 };
27829 let observer = process_request_inner(
27830 &shared,
27831 7,
27832 ClientRole::Observer,
27833 index_request.clone(),
27834 )
27835 .await
27836 .unwrap_err();
27837 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27838 let without_controller = process_request_inner(
27839 &shared,
27840 7,
27841 ClientRole::Operator,
27842 index_request.clone(),
27843 )
27844 .await
27845 .unwrap_err();
27846 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27847 let acquired = process_request_inner(
27848 &shared,
27849 7,
27850 ClientRole::Operator,
27851 NodeRequest::AcquireController { lease_ms: 5_000 },
27852 )
27853 .await
27854 .unwrap();
27855 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27856 let indexed = process_request_inner(
27857 &shared,
27858 7,
27859 ClientRole::Operator,
27860 index_request,
27861 )
27862 .await
27863 .unwrap();
27864 assert!(indexed.native_session_index_contract_is_valid());
27865 let NodeResponse::NativeSessionIndexed {
27866 selection: echoed_selection,
27867 record,
27868 } = indexed else {
27869 panic!("native session index returned a different response");
27870 };
27871 assert_eq!(echoed_selection, selection);
27872 let recataloged = process_request_inner(
27873 &shared,
27874 7,
27875 ClientRole::Operator,
27876 NodeRequest::CatalogNativeSessions {
27877 route,
27878 limit: 2,
27879 },
27880 )
27881 .await
27882 .unwrap();
27883 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27884 panic!("native catalog returned a different response after indexing");
27885 };
27886 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27887
27888 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27889 let external_catalog = process_request_inner(
27890 &shared,
27891 7,
27892 ClientRole::Operator,
27893 NodeRequest::CatalogNativeSessions {
27894 route: unregistered_route.clone(),
27895 limit: 2,
27896 },
27897 )
27898 .await
27899 .unwrap();
27900 let external_json = serde_json::to_string(&external_catalog).unwrap();
27901 assert!(!external_json.contains("outside-session"));
27902 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27903 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27904 assert!(!external_json.contains("\"session_id\""));
27905 assert!(!external_json.contains("\"cwd\""));
27906 assert!(!external_json.contains("\"path\""));
27907 let NodeResponse::NativeSessionsCataloged {
27908 route: echoed_route,
27909 entries: external_entries,
27910 summary: Some(external_summary),
27911 } = external_catalog
27912 else {
27913 panic!("unregistered native catalog returned a different response");
27914 };
27915 assert_eq!(echoed_route, unregistered_route);
27916 assert_eq!(external_entries.len(), 1);
27917 assert_eq!(external_entries[0].record_id, None);
27918 let external_group = external_entries[0]
27919 .external_group
27920 .as_ref()
27921 .expect("unregistered session must have an external group");
27922 assert_eq!(
27923 external_group.kind,
27924 gate4agent_types::NativeSessionExternalGroupKind::Project,
27925 );
27926 assert!(external_group.group_id.starts_with("external-"));
27927 assert_eq!(
27928 external_group.display_name,
27929 outside_root.file_name().unwrap().to_string_lossy(),
27930 );
27931 let external_selection = NativeSessionSelection {
27932 route: unregistered_route,
27933 catalog_revision: external_summary.catalog_revision,
27934 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27935 selection_id: external_entries[0].selection_id.clone(),
27936 };
27937 let external_preview = process_request_inner(
27938 &shared,
27939 7,
27940 ClientRole::Operator,
27941 NodeRequest::PreviewNativeSession {
27942 selection: external_selection.clone(),
27943 message_limit: 1,
27944 },
27945 )
27946 .await
27947 .unwrap();
27948 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27949 assert!(!external_preview_json.contains("outside-session"));
27950 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27951 assert!(!external_preview_json.contains("\"session_id\""));
27952 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27953 panic!("unregistered native preview returned a different response");
27954 };
27955 assert_eq!(preview.messages.len(), 1);
27956 let external_index = process_request_inner(
27957 &shared,
27958 7,
27959 ClientRole::Operator,
27960 NodeRequest::IndexNativeSession {
27961 selection: external_selection,
27962 display_name: "External Claude".to_owned(),
27963 },
27964 )
27965 .await
27966 .unwrap_err();
27967 assert_eq!(
27968 external_index.code,
27969 NodeFailureCode::WorkspaceRegistrationRequired,
27970 );
27971 let record_response = process_request_inner(
27972 &shared,
27973 7,
27974 ClientRole::Operator,
27975 NodeRequest::PreviewSessionRecord {
27976 record_id: record.record_id.clone(),
27977 message_limit: 2,
27978 },
27979 )
27980 .await
27981 .unwrap();
27982 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27983 panic!("record preview returned a different response");
27984 };
27985 assert_eq!(record_id, record.record_id);
27986 assert_eq!(preview.messages.len(), 2);
27987 let encoded = serde_json::to_string(&preview).unwrap();
27988 assert!(!encoded.contains("stable-session"));
27989 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27990 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27991 panic!("record preview history summary returned a different response");
27992 };
27993 let summaries = events
27994 .iter()
27995 .filter_map(|envelope| match &envelope.event {
27996 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27997 if record_id == &record.record_id =>
27998 {
27999 Some(*summary)
28000 }
28001 _ => None,
28002 })
28003 .collect::<Vec<_>>();
28004 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28005 let summary_json = serde_json::to_string(&summaries).unwrap();
28006 for private in [
28007 "stable-session",
28008 "raw user text must not cross the catalog wire",
28009 "raw answer",
28010 "Workspace review",
28011 "claude-sonnet",
28012 ] {
28013 assert!(!summary_json.contains(private), "{private}");
28014 }
28015 let identity = record.provider_session.clone().unwrap();
28016 assert_eq!(
28017 shared.revalidate_session_record_preview(&record, &identity),
28018 Ok(())
28019 );
28020 shared.remove_record_memory(&record.record_id);
28021 assert_eq!(
28022 shared
28023 .revalidate_session_record_preview(&record, &identity)
28024 .unwrap_err()
28025 .code,
28026 NodeFailureCode::UnknownSessionRecord
28027 );
28028 let released = process_request_inner(
28029 &shared,
28030 7,
28031 ClientRole::Operator,
28032 NodeRequest::ReleaseController,
28033 )
28034 .await
28035 .unwrap();
28036 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28037 assert!(shared.controller_state().is_none());
28038 std::fs::remove_dir_all(workspace_root).unwrap();
28039 std::fs::remove_dir_all(history_root).unwrap();
28040 std::fs::remove_dir_all(outside_root).unwrap();
28041 }
28042
28043 #[tokio::test]
28044 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28045 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28046 std::fs::create_dir_all(&history_root).unwrap();
28047 let history = NativeHistoryConfig::new(vec![
28048 NativeHistoryRoot::new(
28049 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28050 HistorySourceLayout::SingleNdjson,
28051 history_root.clone(),
28052 )
28053 .unwrap(),
28054 ])
28055 .unwrap();
28056 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28057 let held = catalog.lock().unwrap();
28058 let result = timeout(
28059 Duration::from_secs(1),
28060 run_native_session_catalog_operation(
28061 Arc::clone(&catalog),
28062 "native session catalog",
28063 |_| Ok::<(), std::convert::Infallible>(()),
28064 ),
28065 )
28066 .await
28067 .expect("concurrent native catalog request must not wait for the operation deadline")
28068 .unwrap_err();
28069 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28070 drop(held);
28071 std::fs::remove_dir_all(history_root).unwrap();
28072 }
28073
28074 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28075 let unique = std::time::SystemTime::now()
28076 .duration_since(std::time::UNIX_EPOCH)
28077 .unwrap()
28078 .as_nanos();
28079 std::env::temp_dir().join(format!(
28080 "gate4agent-node-{label}-{}-{unique}",
28081 std::process::id(),
28082 ))
28083 }
28084
28085 fn git_snapshot_for_parser() -> GitSnapshot {
28086 GitSnapshot {
28087 is_repository: true,
28088 branch: Some("main".to_owned()),
28089 status: Vec::new(),
28090 recent_commits: Vec::new(),
28091 worktrees: Vec::new(),
28092 managed_worktree: None,
28093 truncated: false,
28094 diagnostic: None,
28095 }
28096 }
28097
28098 #[tokio::test]
28099 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28100 let root = temporary_workspace_root("managed-git-scope");
28101 std::fs::create_dir_all(&root).unwrap();
28102 run_test_git(&root, &["init"]);
28103 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28104 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28105 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28106 run_test_git(&root, &["add", "seed.txt"]);
28107 run_test_git(&root, &["commit", "-m", "seed"]);
28108 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28109
28110 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28111 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28112 let catalog = active_registry().unwrap();
28113 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28114 let shared = NodeShared::new(
28115 handle,
28116 "fixture-token".to_owned(),
28117 NodeId::new("node-1").unwrap(),
28118 vec![workspace],
28119 vec![agent("claude")],
28120 );
28121 let mut lease = ManagedWorktreeLeaseRecord {
28122 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28123 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28124 workspace_id: workspace_id.clone(),
28125 profile_id: WorktreeProfileId::new("default").unwrap(),
28126 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28127 target_root: root.to_string_lossy().into_owned(),
28128 branch: "gate4agent/a".to_owned(),
28129 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28130 expected_head: None,
28131 retention: ManagedWorktreeRetention::Retain,
28132 state: ManagedWorktreeLeaseState::InUse,
28133 session_holders: vec![ManagedWorktreeSessionHolder {
28134 incarnation_id: shared.incarnation_id,
28135 instance_id: AgentInstanceId(7),
28136 generation: SessionGeneration(1),
28137 }],
28138 record_holders: Vec::new(),
28139 cleanup_failure: None,
28140 created_at_unix_ms: 1,
28141 updated_at_unix_ms: 1,
28142 };
28143 *shared
28144 .managed_worktrees
28145 .lock()
28146 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28147 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28148
28149 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28150 let scope = inspection.git.managed_worktree.unwrap();
28151 assert_eq!(scope.lease_id, lease.lease_id);
28152 assert_eq!(scope.branch, "gate4agent/a");
28153 assert_eq!(scope.active_session_count, 1);
28154
28155 lease.branch = "gate4agent/b".to_owned();
28156 *shared
28157 .managed_worktrees
28158 .lock()
28159 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28160 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28161 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28162 assert!(mismatch.git.managed_worktree.is_none());
28163 std::fs::remove_dir_all(root).unwrap();
28164 }
28165
28166 #[tokio::test]
28167 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28168 let root = temporary_workspace_root("workspace-file-write-controller");
28169 std::fs::create_dir_all(root.join("src")).unwrap();
28170 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28171 let workspace_id = WorkspaceId::new("primary").unwrap();
28172 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28173 let catalog = active_registry().unwrap();
28174 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28175 let shared = NodeShared::new(
28176 handle,
28177 "fixture-token".to_owned(),
28178 NodeId::new("node-1").unwrap(),
28179 vec![workspace],
28180 vec![agent("claude")],
28181 );
28182 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28183 let expected_revision = workspace_file_revision(b"old\n");
28184 let request = NodeRequest::WriteWorkspaceFile {
28185 workspace_id: workspace_id.clone(),
28186 path: path.clone(),
28187 expected_revision: expected_revision.clone(),
28188 text: "new\n".to_owned(),
28189 };
28190 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28191 .await
28192 .unwrap_err();
28193 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28194 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28195 .await
28196 .unwrap_err();
28197 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28198 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28199 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28200 let NodeResponse::WorkspaceFileWritten { file } = response else {
28201 panic!("workspace file write returned another response");
28202 };
28203 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28204 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28205 let stale = process_request_inner(
28206 &shared,
28207 77,
28208 ClientRole::Operator,
28209 NodeRequest::WriteWorkspaceFile {
28210 workspace_id,
28211 path,
28212 expected_revision,
28213 text: "lost update\n".to_owned(),
28214 },
28215 )
28216 .await
28217 .unwrap_err();
28218 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28219 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28220 std::fs::remove_dir_all(root).unwrap();
28221 }
28222
28223 #[tokio::test]
28224 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28225 let root = temporary_workspace_root("workspace-entry-create-controller");
28226 std::fs::create_dir_all(root.join("src")).unwrap();
28227 let workspace_id = WorkspaceId::new("primary").unwrap();
28228 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28229 let catalog = active_registry().unwrap();
28230 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28231 let shared = NodeShared::new(
28232 handle,
28233 "fixture-token".to_owned(),
28234 NodeId::new("node-1").unwrap(),
28235 vec![workspace],
28236 vec![agent("claude")],
28237 );
28238 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28239 let request = NodeRequest::CreateWorkspaceFile {
28240 workspace_id: workspace_id.clone(),
28241 path: path.clone(),
28242 };
28243 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28244 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28245 }));
28246 assert!(!request_uses_unnegotiated_capability(
28247 &request,
28248 &baseline_capabilities().unwrap(),
28249 ));
28250 assert!(request_uses_unnegotiated_capability(&request, &[]));
28251 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28252 .await
28253 .unwrap_err();
28254 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28255 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28256 .await
28257 .unwrap_err();
28258 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28259 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28260
28261 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28262 .await
28263 .unwrap();
28264 let NodeResponse::WorkspaceFileCreated { file } = response else {
28265 panic!("workspace file create returned another response");
28266 };
28267 assert_eq!(file.workspace_id, workspace_id);
28268 assert_eq!(file.path, path);
28269 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28270 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28271
28272 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28273 .await
28274 .unwrap_err();
28275 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28276
28277 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28278 let response = process_request_inner(
28279 &shared,
28280 77,
28281 ClientRole::Operator,
28282 NodeRequest::CreateWorkspaceDirectory {
28283 workspace_id: workspace_id.clone(),
28284 path: directory_path.clone(),
28285 },
28286 )
28287 .await
28288 .unwrap();
28289 assert_eq!(
28290 response,
28291 NodeResponse::WorkspaceDirectoryCreated {
28292 workspace_id: workspace_id.clone(),
28293 entry: WorkspaceEntry {
28294 relative_path: directory_path,
28295 kind: WorkspaceEntryKind::Directory,
28296 },
28297 },
28298 );
28299 assert!(root.join("src/nested").is_dir());
28300
28301 let missing_parent = process_request_inner(
28302 &shared,
28303 77,
28304 ClientRole::Operator,
28305 NodeRequest::CreateWorkspaceFile {
28306 workspace_id: workspace_id.clone(),
28307 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28308 },
28309 )
28310 .await
28311 .unwrap_err();
28312 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28313 let non_directory_parent = process_request_inner(
28314 &shared,
28315 77,
28316 ClientRole::Operator,
28317 NodeRequest::CreateWorkspaceFile {
28318 workspace_id,
28319 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28320 },
28321 )
28322 .await
28323 .unwrap_err();
28324 assert_eq!(
28325 non_directory_parent.code,
28326 NodeFailureCode::RepositoryParentNotDirectory,
28327 );
28328 std::fs::remove_dir_all(root).unwrap();
28329 }
28330
28331 #[tokio::test]
28332 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28333 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28334 std::fs::create_dir_all(root.join("src")).unwrap();
28335 #[cfg(windows)]
28336 let root = std::fs::canonicalize(root).unwrap();
28337 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28338 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28339 let barrier = Arc::new(std::sync::Barrier::new(2));
28340 let done = Arc::new(AtomicBool::new(false));
28341 let task_commit_state = Arc::clone(&commit_state);
28342 let task_barrier = Arc::clone(&barrier);
28343 let task_done = Arc::clone(&done);
28344 let task_root = root.clone();
28345 let task = tokio::task::spawn_blocking(move || {
28346 task_barrier.wait();
28347 let result = create_workspace_file_on_disk(
28348 &task_root,
28349 &path,
28350 &task_commit_state,
28351 );
28352 task_done.store(true, Ordering::Release);
28353 result
28354 });
28355
28356 let error = settle_workspace_entry_create(
28357 task,
28358 Arc::clone(&commit_state),
28359 Duration::from_millis(10),
28360 "fixture deadline",
28361 "fixture task failed",
28362 )
28363 .await
28364 .unwrap_err();
28365 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28366 assert_eq!(
28367 commit_state.load(Ordering::Acquire),
28368 WORKSPACE_ENTRY_CREATE_CANCELED,
28369 );
28370
28371 barrier.wait();
28372 timeout(Duration::from_secs(1), async {
28373 while !done.load(Ordering::Acquire) {
28374 tokio::task::yield_now().await;
28375 }
28376 })
28377 .await
28378 .expect("canceled blocking create did not settle");
28379 assert!(!root.join("src/late.rs").exists());
28380 std::fs::remove_dir_all(root).unwrap();
28381 }
28382}
28383
28384#[cfg(test)]
28385mod provisional_spawn_tests {
28386 use super::*;
28387 use std::sync::Arc;
28388
28389 fn shared() -> NodeShared {
28390 let catalog = active_registry().unwrap();
28391 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28392 let workspace = WorkspaceConfig::new(
28393 WorkspaceId::new("primary").unwrap(),
28394 std::env::current_dir().unwrap(),
28395 )
28396 .unwrap()
28397 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28398 NodeShared::new(
28399 handle,
28400 "fixture-token".to_owned(),
28401 NodeId::new("node-provisional-test").unwrap(),
28402 vec![workspace],
28403 vec![AgentId::new("claude").unwrap()],
28404 )
28405 }
28406
28407 #[test]
28410 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28411 let shared = shared();
28412 let ws = WorkspaceId::new("primary").unwrap();
28413 let mut ids = Vec::with_capacity(50);
28414 for i in 1u64..=50 {
28415 let id = AgentInstanceId(i);
28416 ids.push(id);
28417 let address = SessionAddress {
28418 workspace_id: ws.clone(),
28419 session: SessionKey {
28420 instance_id: id,
28421 generation: SessionGeneration(1),
28422 },
28423 };
28424 shared.mark_spawn_provisional(address);
28425 }
28426 assert_eq!(
28427 shared
28428 .provisional_spawns
28429 .lock()
28430 .unwrap_or_else(|p| p.into_inner())
28431 .len(),
28432 50
28433 );
28434 for id in ids.iter().step_by(2) {
28435 shared.sessionize_spawn(*id);
28436 }
28437 assert_eq!(
28438 shared
28439 .provisional_spawns
28440 .lock()
28441 .unwrap_or_else(|p| p.into_inner())
28442 .len(),
28443 25
28444 );
28445 assert!(shared.take_due_provisional_spawns().is_empty());
28446 {
28447 let mut map = shared
28448 .provisional_spawns
28449 .lock()
28450 .unwrap_or_else(|p| p.into_inner());
28451 let past = Instant::now() - Duration::from_secs(91);
28452 for (_, (_, at)) in map.iter_mut() {
28453 *at = past;
28454 }
28455 }
28456 let due = shared.take_due_provisional_spawns();
28457 assert_eq!(due.len(), 25);
28458 let due_ids: std::collections::BTreeSet<_> =
28459 due.iter().map(|a| a.session.instance_id.0).collect();
28460 let expected: std::collections::BTreeSet<_> =
28461 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28462 assert_eq!(due_ids, expected);
28463 assert!(shared
28464 .provisional_spawns
28465 .lock()
28466 .unwrap_or_else(|p| p.into_inner())
28467 .is_empty());
28468 }
28469
28470 #[test]
28473 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28474 let shared = Arc::new(shared());
28475 let ws = WorkspaceId::new("primary").unwrap();
28476 let mut handles = Vec::new();
28477 for i in 1u64..=20 {
28478 let shared = Arc::clone(&shared);
28479 let ws = ws.clone();
28480 handles.push(std::thread::spawn(move || {
28481 let id = AgentInstanceId(i);
28482 let address = SessionAddress {
28483 workspace_id: ws,
28484 session: SessionKey {
28485 instance_id: id,
28486 generation: SessionGeneration(1),
28487 },
28488 };
28489 shared.mark_spawn_provisional(address);
28490 id
28491 }));
28492 }
28493 let mut ids = Vec::new();
28494 for h in handles {
28495 ids.push(h.join().unwrap());
28496 }
28497 ids.sort_by_key(|id| id.0);
28498 assert_eq!(ids.len(), 20);
28499 ids.dedup();
28500 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28501 assert_eq!(
28502 shared
28503 .provisional_spawns
28504 .lock()
28505 .unwrap_or_else(|p| p.into_inner())
28506 .len(),
28507 20
28508 );
28509 shared.sessionize_spawn(AgentInstanceId(7));
28510 let left: Vec<_> = shared
28511 .provisional_spawns
28512 .lock()
28513 .unwrap_or_else(|p| p.into_inner())
28514 .keys()
28515 .map(|id| id.0)
28516 .collect();
28517 assert!(!left.contains(&7));
28518 assert_eq!(left.len(), 19);
28519 }
28520}
28521
28522
28523#[cfg(unix)]
28524extern "C" {
28525 #[link_name = "geteuid"]
28526 fn libc_geteuid() -> u32;
28527}
28528
28529#[cfg(test)]
28530mod call_home_link_tests {
28531 use super::*;
28532
28533 #[test]
28534 fn call_home_backoff_is_capped_and_monotonic() {
28535 let mut last = Duration::ZERO;
28536 for attempt in 1..20 {
28537 let next = call_home_backoff(attempt);
28538 assert!(next >= last);
28539 assert!(next <= Duration::from_secs(8));
28540 last = next;
28541 }
28542 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28543 }
28544
28545 #[tokio::test]
28546 async fn call_home_socket_gets_keepalive() {
28547 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28548 let addr = listener.local_addr().unwrap();
28549 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28550 let client = client.unwrap();
28551 enable_call_home_keepalive(&client).unwrap();
28552 let sock = socket2::SockRef::from(&client);
28553 assert!(sock.keepalive().unwrap());
28554 #[cfg(any(target_os = "linux", target_os = "macos"))]
28555 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28556 }
28557}
28558
28559fn provider_session_exists(provider: &str, id: &str) -> bool {
28561 if provider != "claude" { return false; }
28562 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28563 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28564 let name = format!("{id}.jsonl");
28565 homes.iter().any(|h| std::fs::read_dir(h.join(".claude/projects")).map_or(false, |rd| rd.flatten().any(|d| d.path().join(&name).is_file())))
28566}
28567
28568fn overlay_with_extra_args(
28570 provider: &AgentId,
28571 mode: SessionMode,
28572 overlay: Option<PreparedNativeLaunchOverlay>,
28573 args: Vec<OsString>,
28574) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28575 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28576 Ok(match (overlay, args) {
28577 (prepared, args) if args.is_empty() => prepared,
28578 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28579 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28580 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28581 provider.clone(),
28582 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28583 Vec::new(),
28584 args,
28585 ).map_err(|_| bad())?)),
28586 })
28587}