1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219 static RESUME_PROVIDER_SESSION: Option<String>;
221}
222
223fn spawn_selection(
225 options: &gate4agent_node_protocol::SpawnSessionOptions,
226) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
227 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
228 match (&options.model, &options.effort) {
229 (None, None) => Ok(None),
230 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
231 (Some(model), effort) => {
232 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
233 if let Some(e) = effort {
234 sel = sel.with_value("effort", e.clone());
235 }
236 Ok(Some(sel))
237 }
238 }
239}
240
241mod browser_bridge;
242
243use gate4agent_node_core::drive_loop_profile;
244
245#[path = "bundle_delivery.rs"]
246mod bundle_delivery;
247
248#[path = "context_pack_store.rs"]
249mod context_pack_store;
250
251#[cfg(windows)]
252pub use crate::platform::DEFAULT_NODE_ENDPOINT;
253
254const NODE_EVENT_HISTORY_MAX: usize = 4_096;
255const NODE_BROADCAST_CAPACITY: usize = 1_024;
256const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
257const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
263const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
270const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
271const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
272const MAX_PREAUTH_CONNECTIONS: usize = 32;
273const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
274const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
275const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
276const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
277const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
278const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
284const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
292const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
300const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
307const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
312const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
313const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
314const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
315const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
316const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
317const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
318const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
319const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
320const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
321const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
322const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
323const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
324const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
327const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
334const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
337const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
338const GIT_STATUS_MAX_ENTRIES: usize = 128;
339const GIT_COMMIT_MAX_ENTRIES: usize = 12;
340const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
341const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
342const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
343const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
344const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
345const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
346const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
347const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
348const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
349const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
350 "provider-session-identity-allocation-failed";
351const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
352const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
353const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
354const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
355const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
356const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
357const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
358 "durable-state-path-semantics-unsupported";
359
360fn opaque_windows_path(value: String) -> OpaqueHostPath {
361 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
362}
363
364fn agent_progress_from_provider_snapshot(
365 address: SessionAddress,
366 provider: &ProviderSnapshot,
367) -> Option<SessionAgentProgress> {
368 let mut truncated = false;
369 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
370 let mut active_tool_labels = Vec::with_capacity(
371 provider
372 .active_tools
373 .len()
374 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
375 );
376 for tool in &provider.active_tools {
377 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
378 truncated = true;
379 break;
380 }
381 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
382 active_tool_labels.push(label);
383 }
384 }
385 let attention = provider
386 .interactions
387 .iter()
388 .find(|interaction| {
389 matches!(
390 interaction.status,
391 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
392 )
393 })
394 .map(|interaction| AgentProgressAttentionV1 {
395 kind: match interaction.interaction_kind {
396 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
397 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
398 },
399 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
400 });
401 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
402 let usage = (provider.completed_turns > 0
403 || provider.usage.input_tokens > 0
404 || provider.usage.output_tokens > 0
405 || provider.usage.cache_read_tokens > 0
406 || provider.usage.cache_write_tokens > 0
407 || provider.usage.reasoning_tokens > 0)
408 .then_some(AgentProgressUsageV1 {
409 input_tokens: provider.usage.input_tokens,
410 output_tokens: provider.usage.output_tokens,
411 cache_read_tokens: provider.usage.cache_read_tokens,
412 cache_write_tokens: provider.usage.cache_write_tokens,
413 reasoning_tokens: provider.usage.reasoning_tokens,
414 });
415 let progress = AgentProgressV1 {
416 provider_sequence: provider.sequence,
417 activity: provider.activity,
418 completed_turns: provider.completed_turns,
419 usage,
420 current: AgentProgressCurrentV1::from(provider.activity),
421 active_tool_labels,
422 active_tool_count,
423 attention,
424 subagent_count,
425 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
426 gap_count: provider.gap_count,
427 stale: provider.stale,
428 truncated,
429 };
430 let entry = SessionAgentProgress { address, progress };
431 serde_json::to_vec(&entry)
432 .ok()
433 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
434 .map(|_| entry)
435}
436
437fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
438 match u32::try_from(count) {
439 Ok(count) => count,
440 Err(_) => {
441 *truncated = true;
442 u32::MAX
443 }
444 }
445}
446
447fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
448 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
449 return None;
450 }
451 Some(match event {
452 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
453 ProviderEvent::SessionIdentityObserved { .. } => {
454 AgentProgressEventKindV1::SessionIdentityObserved
455 }
456 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
457 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
458 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
459 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
460 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
461 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
462 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
463 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
464 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
465 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
466 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
467 ProviderEvent::InteractionRequested { .. } => {
468 AgentProgressEventKindV1::InteractionRequested
469 }
470 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
471 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
472 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
473 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
474 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
475 ProviderEvent::UnrecognizedNotification { .. } => {
476 AgentProgressEventKindV1::UnrecognizedNotification
477 }
478 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
479 ProviderEvent::UserMessage { .. }
490 | ProviderEvent::Plan { .. }
491 | ProviderEvent::AvailableCommandsUpdated { .. }
492 | ProviderEvent::ModeChanged { .. }
493 | ProviderEvent::SessionInfoUpdated { .. }
494 | ProviderEvent::UsageUpdated { .. }
495 | ProviderEvent::ConfigOptionsUpdated { .. } => {
496 AgentProgressEventKindV1::UnrecognizedNotification
497 }
498 })
499}
500
501fn provider_family_is_live(family: AdapterFamily) -> bool {
514 match family {
515 AdapterFamily::PtySemantic
516 | AdapterFamily::Hook
517 | AdapterFamily::ManagedHook
518 | AdapterFamily::Pipe
519 | AdapterFamily::OneShot
520 | AdapterFamily::Acp => true,
521 AdapterFamily::History
522 | AdapterFamily::Resume
523 | AdapterFamily::SessionOptions
524 | AdapterFamily::CapabilityProbe => false,
525 }
526}
527
528fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
529 match by {
530 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
531 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
532 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
533 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
534 }
535}
536
537fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
549 match authority {
550 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
551 BlockAuthorityV1::HostPolicy => "denied by host policy",
552 BlockAuthorityV1::HostDeadline => {
553 "denied by host policy after the operator deadline expired"
554 }
555 BlockAuthorityV1::Operator => "declined by the operator",
556 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
557 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
558 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
559 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
560 BlockAuthorityV1::UserRejected => "rejected by the user",
561 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
562 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
563 }
564}
565
566fn host_request_denied_block(
569 method: &str,
570 by: ProviderHostDecisionAuthority,
571 reason: Option<&str>,
572) -> (String, BlockAuthorityV1, String) {
573 let authority = block_authority(by);
574 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
575 let (reason_text, _) =
579 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
580 (tool_class_label(method), authority, reason_text)
581}
582
583fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
602 match kind? {
603 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
604 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
605 _ => None,
606 }
607}
608
609fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
621 let trimmed = text.trim();
622 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
623 return (trimmed.to_owned(), None);
624 };
625 let tail = after_reason.trim_start();
626 let Some((reason, help)) = tail.split_once(". ") else {
627 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
631 return (reason.to_owned(), None);
632 };
633 let help = help.trim();
634 if help.is_empty() {
635 (reason.trim().to_owned(), None)
636 } else {
637 (reason.trim().to_owned(), Some(help.to_owned()))
638 }
639}
640
641fn tool_class_from_denial_help(help: Option<&str>) -> String {
651 help.and_then(|text| text.split("add a ").nth(1))
652 .and_then(|tail| tail.split(' ').next())
653 .filter(|word| !word.is_empty())
654 .map(str::to_owned)
655 .unwrap_or_else(|| "Tool".to_owned())
656}
657
658fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
666 fn names_quota(s: &str) -> bool {
667 let lower = s.to_ascii_lowercase();
668 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
669 }
670 vendor_code.is_some_and(names_quota) || names_quota(message)
671}
672
673fn provider_session_end_block(
689 stop_reason: &ProviderStopReason,
690) -> Option<(BlockAuthorityV1, String, String)> {
691 match stop_reason {
692 ProviderStopReason::Refusal => Some((
693 BlockAuthorityV1::ProviderRefusal,
694 "refusal".to_owned(),
695 "the provider ended the turn with stopReason: refusal".to_owned(),
696 )),
697 ProviderStopReason::ProviderError { message, vendor_code, .. }
698 if provider_error_is_quota(vendor_code.as_deref(), message) =>
699 {
700 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
701 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
702 }
703 _ => None,
704 }
705}
706
707fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
748 let ControlEventKind::ProviderEvent {
749 sequence: provider_sequence,
750 source,
751 source_sequence,
752 event: provider_event,
753 } = &event.event
754 else {
755 return None;
756 };
757 if !provider_family_is_live(source.family) {
758 return None;
759 }
760 let kind = match provider_event {
761 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
762 text: text.clone(),
763 is_delta: *is_delta,
764 },
765 ProviderEvent::Thinking { text } => {
766 AgentStreamChunkKindV1::Thinking { text: text.clone() }
767 }
768 ProviderEvent::InteractionRequested {
769 interaction_kind,
770 tool_name,
771 title,
772 prompt,
773 options,
774 ..
775 } => AgentStreamChunkKindV1::InteractionPrompt {
776 correlation_id: interaction_correlation(
777 event.instance_id,
778 event.generation,
779 *provider_sequence,
780 ),
781 interaction_kind: *interaction_kind,
782 tool_name: tool_name.clone(),
783 title: title.clone(),
792 prompt: prompt.clone(),
793 options: options
794 .iter()
795 .map(|option| AgentStreamInteractionOptionV1 {
796 option_id: option.option_id.clone(),
797 name: option.name.clone(),
798 kind: option.kind.clone(),
799 })
800 .collect(),
801 },
802 ProviderEvent::ConfigOptionsUpdated { options } => {
803 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
804 }
805 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
816 current: Some(mode_id.clone()),
817 available: available
818 .iter()
819 .map(|mode| AgentStreamNamedIdV1 {
820 id: mode.id.clone(),
821 name: mode.name.clone(),
822 description: mode.description.clone(),
823 })
824 .collect(),
825 },
826 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
830 let ProviderHostRequestDecision::Denied { by } = decision else {
831 return None;
832 };
833 let (tool_class, authority, reason_text) =
834 host_request_denied_block(method, *by, reason.as_deref());
835 AgentStreamChunkKindV1::Blocked {
836 correlation_id: None,
837 tool_class,
838 authority,
839 reason_kind: None,
840 reason: reason_text,
841 help: None,
842 }
843 }
844 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
848 let (authority, reason_kind) =
849 provider_tool_block_authority(non_execution_kind.as_deref())?;
850 let (reason, help) = split_provider_denial_text(output);
851 let (reason, _) =
852 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
853 let help = help.map(|help| {
854 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
855 });
856 AgentStreamChunkKindV1::Blocked {
857 correlation_id: Some(tool_correlation(event, source, id)),
858 tool_class: tool_class_from_denial_help(help.as_deref()),
859 authority,
860 reason_kind: Some(reason_kind.to_owned()),
861 reason,
862 help,
863 }
864 }
865 ProviderEvent::SessionEnded { stop_reason, .. } => {
869 let (authority, reason_kind, reason) =
870 stop_reason.as_ref().and_then(provider_session_end_block)?;
871 let (reason, _) =
872 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
873 AgentStreamChunkKindV1::Blocked {
874 correlation_id: None,
875 tool_class: "Session".to_owned(),
876 authority,
877 reason_kind: Some(reason_kind),
878 reason,
879 help: None,
880 }
881 }
882 ProviderEvent::SessionStarted { .. }
883 | ProviderEvent::SessionIdentityObserved { .. }
884 | ProviderEvent::TurnStarted { .. }
885 | ProviderEvent::WorkingObserved
886 | ProviderEvent::ToolStarted { .. }
887 | ProviderEvent::TurnCompleted { .. }
888 | ProviderEvent::ContextWindowUsage { .. }
889 | ProviderEvent::TurnInterrupted
890 | ProviderEvent::Error { .. }
891 | ProviderEvent::Ready
892 | ProviderEvent::InteractionResolved { .. }
893 | ProviderEvent::SubagentStarted { .. }
894 | ProviderEvent::SubagentStopped { .. }
895 | ProviderEvent::RateLimited { .. }
896 | ProviderEvent::UnrecognizedNotification { .. }
897 | ProviderEvent::UserMessage { .. }
898 | ProviderEvent::Plan { .. }
899 | ProviderEvent::AvailableCommandsUpdated { .. }
900 | ProviderEvent::SessionInfoUpdated { .. }
901 | ProviderEvent::UsageUpdated { .. } => return None,
902 };
903 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
904}
905
906async fn run_native_session_catalog_operation<T, E, F>(
907 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
908 operation: &'static str,
909 execute: F,
910) -> Result<T, NodeFailure>
911where
912 T: Send + 'static,
913 E: std::fmt::Display + Send + 'static,
914 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
915{
916 let task = tokio::task::spawn_blocking(move || {
917 let mut catalog = match catalog.try_lock() {
918 Ok(catalog) => catalog,
919 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
920 Err(std::sync::TryLockError::WouldBlock) => {
921 return Err(failure(
922 NodeFailureCode::BackendBusy,
923 "native session catalog operation is already running",
924 ));
925 }
926 };
927 execute(&mut catalog).map_err(|error| {
928 failure(
929 NodeFailureCode::BackendOperationFailed,
930 &format!("{operation} failed: {error}"),
931 )
932 })
933 });
934 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
935 .await
936 .map_err(|_| {
937 failure(
938 NodeFailureCode::BackendBusy,
939 &format!("{operation} exceeded its bounded deadline"),
940 )
941 })?
942 .map_err(|_| {
943 failure(
944 NodeFailureCode::BackendOperationFailed,
945 &format!("{operation} task failed"),
946 )
947 })?
948}
949
950fn windows_path_text(path: &OpaqueHostPath) -> &str {
951 path.as_utf8()
952 .expect("Windows node only creates UTF-8 opaque host paths")
953}
954
955fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
956 path.as_utf8()
957 .map(str::to_owned)
958 .ok_or_else(|| failure(
959 NodeFailureCode::InvalidRequest,
960 "this node requires UTF-8 host paths",
961 ))
962}
963
964fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
965 GitWorktreeSnapshot {
966 path: opaque_windows_path(worktree.path),
967 head: worktree.head,
968 branch: worktree.branch,
969 is_bare: worktree.is_bare,
970 is_main: worktree.is_main,
971 locked: worktree.locked,
972 lock_reason: worktree.lock_reason,
973 prunable: worktree.prunable,
974 prunable_reason: worktree.prunable_reason,
975 workspace_id: worktree.workspace_id.take(),
976 }
977}
978
979#[derive(Clone, Debug, Eq, PartialEq)]
980pub struct WorkspaceConfig {
981 workspace_id: WorkspaceId,
982 canonical_root: String,
983 worktree_service_mode: WorktreeServiceMode,
984 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
985}
986
987impl WorkspaceConfig {
988 pub fn new(
989 workspace_id: WorkspaceId,
990 root: impl AsRef<Path>,
991 ) -> Result<Self, NodeServerError> {
992 let root = root.as_ref();
993 if !root.is_absolute() {
994 return Err(NodeServerError::InvalidWorkspaceRoot {
995 workspace_id,
996 path: root.to_string_lossy().into_owned(),
997 message: "path is not absolute".to_owned(),
998 });
999 }
1000 let canonical = std::fs::canonicalize(root).map_err(|error| {
1001 NodeServerError::InvalidWorkspaceRoot {
1002 workspace_id: workspace_id.clone(),
1003 path: root.to_string_lossy().into_owned(),
1004 message: error.to_string(),
1005 }
1006 })?;
1007 if !canonical.is_dir() {
1008 return Err(NodeServerError::InvalidWorkspaceRoot {
1009 workspace_id,
1010 path: root.to_string_lossy().into_owned(),
1011 message: "path is not a directory".to_owned(),
1012 });
1013 }
1014 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1015 NodeServerError::InvalidWorkspaceRoot {
1016 workspace_id: workspace_id.clone(),
1017 path: path.to_string_lossy().into_owned(),
1018 message: "canonical path is not valid Unicode".to_owned(),
1019 }
1020 })?;
1021 let canonical_root = platform::normalize_canonical_root(canonical_root);
1022 validate_workspace_root(&workspace_id, &canonical_root)?;
1023 Ok(Self {
1024 workspace_id,
1025 canonical_root,
1026 worktree_service_mode: WorktreeServiceMode::Manual,
1027 managed_worktree_profiles: BTreeMap::new(),
1028 })
1029 }
1030
1031 pub fn workspace_id(&self) -> &WorkspaceId {
1032 &self.workspace_id
1033 }
1034
1035 pub fn canonical_root(&self) -> &str {
1036 &self.canonical_root
1037 }
1038
1039 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1040 self.worktree_service_mode = mode;
1041 self
1042 }
1043
1044 pub fn with_managed_worktree_profile(
1045 mut self,
1046 profile: ManagedWorktreeProfile,
1047 ) -> Result<Self, NodeServerError> {
1048 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1049 NodeServerError::InvalidManagedWorktreeProfile {
1050 workspace_id: self.workspace_id.clone(),
1051 profile_id: profile.profile_id().clone(),
1052 message,
1053 }
1054 })?;
1055 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1056 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1057 workspace_id: self.workspace_id,
1058 profile_id: profile.profile_id().clone(),
1059 });
1060 }
1061 if self.managed_worktree_profiles.len()
1062 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1063 {
1064 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1065 workspace_id: self.workspace_id,
1066 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1067 });
1068 }
1069 self.managed_worktree_profiles
1070 .insert(profile.profile_id().clone(), profile);
1071 Ok(self)
1072 }
1073}
1074
1075#[derive(Clone)]
1076pub struct NodeServerConfig {
1077 pub endpoint: String,
1078 api_listen: Option<std::net::SocketAddr>,
1079 bridge_listen: Option<std::net::SocketAddr>,
1082 bridge_token: Option<String>,
1085 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1088 call_home: Option<std::net::SocketAddr>,
1091 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1095 pub node_id: NodeId,
1096 pub workspaces: Vec<WorkspaceConfig>,
1097 access_token: String,
1098 pub runtime: NativeRuntimeConfig,
1099 session_record_retention: SessionRecordRetentionConfig,
1100 state_path: Option<PathBuf>,
1101 spawn_profiles: SpawnProfileRegistry,
1102 session_environment: Option<NodeSessionEnvironmentConfig>,
1103 history: Option<NativeHistoryConfig>,
1104 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1105 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1109 #[cfg(feature = "fixture")]
1110 fixture_raw_pty_runtime: bool,
1111}
1112
1113#[derive(Clone)]
1114struct NodeSessionEnvironmentConfig {
1115 root: PathBuf,
1116 resolver: Arc<dyn NodeSecretResolver>,
1117}
1118
1119impl NodeServerConfig {
1120 pub fn new(
1121 endpoint: impl Into<String>,
1122 access_token: impl Into<String>,
1123 node_id: NodeId,
1124 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1125 ) -> Result<Self, NodeServerError> {
1126 let endpoint = endpoint.into();
1127 let access_token = access_token.into();
1128 if !platform::validate_endpoint(&endpoint) {
1129 return Err(NodeServerError::InvalidEndpoint);
1130 }
1131 if access_token.is_empty() || access_token.len() > 4_096 {
1132 return Err(NodeServerError::InvalidAccessToken);
1133 }
1134 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1135 if workspaces.is_empty() {
1136 return Err(NodeServerError::NoWorkspaces);
1137 }
1138 let mut workspace_ids = BTreeMap::new();
1139 let mut workspace_roots = BTreeMap::new();
1140 for workspace in &workspaces {
1141 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1142 && workspace.managed_worktree_profiles.is_empty()
1143 {
1144 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1145 workspace.workspace_id.clone(),
1146 ));
1147 }
1148 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1149 && !workspace.managed_worktree_profiles.is_empty()
1150 {
1151 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1152 workspace.workspace_id.clone(),
1153 ));
1154 }
1155 if workspace_ids
1156 .insert(workspace.workspace_id.clone(), ())
1157 .is_some()
1158 {
1159 return Err(NodeServerError::DuplicateWorkspaceId(
1160 workspace.workspace_id.clone(),
1161 ));
1162 }
1163 let root_key = platform::root_identity(&workspace.canonical_root);
1164 if let Some(existing) = workspace_roots.insert(
1165 root_key,
1166 workspace.workspace_id.clone(),
1167 ) {
1168 return Err(NodeServerError::DuplicateWorkspaceRoot {
1169 first: existing,
1170 second: workspace.workspace_id.clone(),
1171 root: workspace.canonical_root.clone(),
1172 });
1173 }
1174 }
1175 Ok(Self {
1176 endpoint,
1177 api_listen: None,
1178 bridge_listen: None,
1179 bridge_token: None,
1180 bridge_underlay: None,
1181 call_home: None,
1182 call_home_identity: None,
1183 node_id,
1184 workspaces,
1185 access_token,
1186 runtime: NativeRuntimeConfig {
1189 worker_poll_interval_ms: 4,
1190 ..NativeRuntimeConfig::default()
1191 },
1192 session_record_retention: SessionRecordRetentionConfig::default(),
1193 state_path: None,
1194 spawn_profiles: SpawnProfileRegistry::default(),
1195 session_environment: None,
1196 history: None,
1197 harness_mcp_helper: None,
1198 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1199 #[cfg(feature = "fixture")]
1200 fixture_raw_pty_runtime: false,
1201 })
1202 }
1203
1204 pub fn with_api_listen(
1205 mut self,
1206 api_listen: std::net::SocketAddr,
1207 ) -> Result<Self, NodeServerError> {
1208 if !api_listen.ip().is_loopback() {
1209 return Err(NodeServerError::InvalidApiListen(api_listen));
1210 }
1211 self.api_listen = Some(api_listen);
1212 Ok(self)
1213 }
1214
1215 pub fn with_bridge_listen(
1219 mut self,
1220 bridge_listen: std::net::SocketAddr,
1221 ) -> Result<Self, NodeServerError> {
1222 if !bridge_listen.ip().is_loopback() {
1223 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1224 }
1225 self.bridge_listen = Some(bridge_listen);
1226 Ok(self)
1227 }
1228
1229 pub fn with_bridge_token(
1232 mut self,
1233 bridge_token: impl Into<String>,
1234 ) -> Result<Self, NodeServerError> {
1235 let bridge_token = bridge_token.into();
1236 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1237 return Err(NodeServerError::InvalidBridgeToken);
1238 }
1239 self.bridge_token = Some(bridge_token);
1240 Ok(self)
1241 }
1242
1243 pub fn with_bridge_underlay(
1248 mut self,
1249 bind: std::net::SocketAddr,
1250 transport_key: [u8; 32],
1251 underlay_token: impl Into<String>,
1252 ) -> Result<Self, NodeServerError> {
1253 if self.bridge_listen.is_none() {
1254 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1255 }
1256 if self.bridge_token.is_none() {
1257 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1258 }
1259 let underlay_token = underlay_token.into();
1260 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1261 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1262 }
1263 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1265 bind,
1266 transport_key,
1267 underlay_token,
1268 });
1269 Ok(self)
1270 }
1271
1272 pub fn with_call_home(
1293 mut self,
1294 relay: std::net::SocketAddr,
1295 ) -> Result<Self, NodeServerError> {
1296 if relay.port() == 0 || relay.ip().is_unspecified() {
1299 return Err(NodeServerError::InvalidCallHome(relay));
1300 }
1301 self.call_home = Some(relay);
1302 Ok(self)
1303 }
1304
1305 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1307 self.call_home_identity = Some(identity);
1308 self
1309 }
1310
1311 pub fn with_state_path(
1312 mut self,
1313 state_path: impl Into<PathBuf>,
1314 ) -> Result<Self, NodeServerError> {
1315 let state_path = state_path.into();
1316 if !state_path.is_absolute() || state_path.file_name().is_none() {
1317 return Err(NodeServerError::InvalidStatePath(
1318 state_path.to_string_lossy().into_owned(),
1319 ));
1320 }
1321 self.state_path = Some(state_path);
1322 Ok(self)
1323 }
1324
1325 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1326 self.spawn_profiles = spawn_profiles;
1327 self
1328 }
1329
1330 pub fn with_session_record_retention(
1336 mut self,
1337 retention: SessionRecordRetentionConfig,
1338 ) -> Self {
1339 self.session_record_retention = retention;
1340 self
1341 }
1342
1343 pub fn with_session_environment_materialization(
1344 mut self,
1345 root: impl Into<PathBuf>,
1346 resolver: Arc<dyn NodeSecretResolver>,
1347 ) -> Result<Self, NodeServerError> {
1348 let root = root.into();
1349 if !root.is_absolute() {
1350 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1351 }
1352 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1353 Ok(self)
1354 }
1355
1356 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1359 self.history = Some(history);
1360 self
1361 }
1362
1363 pub fn with_harness_mcp_helper(
1369 mut self,
1370 helper_program: impl Into<PathBuf>,
1371 ) -> Result<Self, NodeServerError> {
1372 self.harness_mcp_helper = Some(
1373 ReviewedHarnessMcpProgram::review(helper_program.into())
1374 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1375 );
1376 Ok(self)
1377 }
1378
1379 pub fn with_network_allowlist_catalog(
1384 mut self,
1385 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1386 ) -> Result<Self, NodeServerError> {
1387 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1388 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1389 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1390 });
1391 }
1392 self.network_allowlist_catalog = catalog;
1393 Ok(self)
1394 }
1395
1396 pub fn with_network_allowlist_catalog_file(
1400 self,
1401 path: impl AsRef<std::path::Path>,
1402 ) -> Result<Self, NodeServerError> {
1403 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1404 .map_err(NodeServerError::from)?;
1405 self.with_network_allowlist_catalog(catalog)
1406 }
1407}
1408
1409pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1410 platform::default_state_path(node_id.as_str())
1411 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1412}
1413
1414pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1415 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1416}
1417
1418fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1419 let parent = state_path.parent()?;
1420 let mut name = state_path.file_name()?.to_os_string();
1421 name.push(".delivery-store");
1422 Some(parent.join(name))
1423}
1424
1425fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1426 let parent = state_path.parent()?;
1427 let mut name = state_path.file_name()?.to_os_string();
1428 name.push(".context-pack-store");
1429 Some(parent.join(name))
1430}
1431
1432pub struct NodeServer {
1433 config: NodeServerConfig,
1434 runtime: NativeRuntime,
1435 shared: Arc<NodeShared>,
1436 events: EventSubscription,
1437 state_path_lock: Option<session_registry::StatePathLock>,
1438}
1439
1440#[cfg(feature = "fixture")]
1441#[derive(Clone, Default)]
1442pub struct SpawnManagedWorktreeV2FailureProbe {
1443 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1444}
1445
1446#[cfg(feature = "fixture")]
1447impl SpawnManagedWorktreeV2FailureProbe {
1448 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1449 *self
1450 .latest_code
1451 .lock()
1452 .unwrap_or_else(|poisoned| poisoned.into_inner())
1453 }
1454
1455 fn record(&self, code: Option<NodeFailureCode>) {
1456 *self
1457 .latest_code
1458 .lock()
1459 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1460 }
1461}
1462
1463#[cfg(all(feature = "fixture", windows))]
1464struct IsolatedCodexHomeFixtureEnvironment;
1465
1466#[cfg(all(feature = "fixture", windows))]
1467impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1468 for IsolatedCodexHomeFixtureEnvironment
1469{
1470 fn resolve_child_environment(
1471 &self,
1472 ) -> Result<
1473 Vec<gate4agent_catalog::EnvMutation>,
1474 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1475 > {
1476 Ok(vec![gate4agent_catalog::EnvMutation {
1477 key: OsString::from("USERPROFILE"),
1478 value: None,
1479 }])
1480 }
1481}
1482
1483impl NodeServer {
1484 #[cfg(unix)]
1488 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1489 self.shared.signer_proxy.set(proxy).is_ok()
1490 }
1491
1492 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1493 self.shared.m4a_accounts.as_ref()?;
1494 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1495 }
1496
1497 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1498 let catalog = active_registry()?;
1499 Self::new_with_registry(config, catalog)
1500 }
1501
1502 #[cfg(feature = "fixture")]
1503 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1504 let mut spec = gate4agent_testkit::interactive_agent_spec();
1505 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1506 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1507 Self::new_fixture_with_spec(config, spec)
1508 }
1509
1510 #[cfg(feature = "fixture")]
1511 pub fn new_clean_exit_fixture(
1512 config: NodeServerConfig,
1513 fixture_root: PathBuf,
1514 started_marker: PathBuf,
1515 release_signal: PathBuf,
1516 ) -> Result<Self, NodeServerError> {
1517 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1518 &fixture_root,
1519 &started_marker,
1520 &release_signal,
1521 )
1522 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523 spec.id = AgentId::new("claude")
1524 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1525 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1526 Self::new_fixture_with_spec(config, spec)
1527 }
1528
1529 #[cfg(all(feature = "fixture", windows))]
1539 pub fn new_durable_context_pack_clean_exit_fixture(
1540 config: NodeServerConfig,
1541 fixture_root: PathBuf,
1542 started_marker: PathBuf,
1543 release_signal: PathBuf,
1544 session_id: &str,
1545 ) -> Result<Self, NodeServerError> {
1546 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1547 &fixture_root,
1548 &started_marker,
1549 &release_signal,
1550 session_id,
1551 )
1552 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553 let source_id = AgentId::new("claude")
1554 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1555 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1556 NodeServerError::Registry(
1557 "durable context pack clean-exit fixture source adapter is unavailable"
1558 .to_owned(),
1559 )
1560 })?;
1561 source_spec.capabilities.adapters.history =
1562 source_provider.capabilities.adapters.history.clone();
1563 source_spec.id = source_id;
1564 source_spec.display_name =
1565 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1566 source_spec.detection.command =
1572 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1573 source_spec.detection.aliases.clear();
1574
1575 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1576 target_spec.id = AgentId::new("codex")
1577 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1578 target_spec.display_name =
1579 "Controlled Codex durable context-pack target fixture".to_owned();
1580 target_spec.detection.command =
1581 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1582 target_spec.detection.aliases.clear();
1583
1584 let catalog = AgentRegistry::new([source_spec, target_spec])
1585 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1586 let mut server = Self::new_with_registry(config, catalog)?;
1587 Arc::get_mut(&mut server.shared)
1588 .expect("fixture Node shared state must remain uniquely owned before run")
1589 .fixture_semantic_hook_policy = true;
1590 Ok(server)
1591 }
1592
1593 #[cfg(feature = "fixture")]
1594 pub fn new_monitoring_hook_fixture(
1595 config: NodeServerConfig,
1596 ) -> Result<Self, NodeServerError> {
1597 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1598 spec.id = AgentId::new("claude")
1599 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1600 let mut server = Self::new_fixture_with_spec(config, spec)?;
1601 Arc::get_mut(&mut server.shared)
1602 .expect("fixture Node shared state must remain uniquely owned before run")
1603 .fixture_semantic_hook_policy = true;
1604 Ok(server)
1605 }
1606
1607 #[cfg(all(feature = "fixture", windows))]
1608 pub fn new_provider_bundle_argv_fixture(
1609 config: NodeServerConfig,
1610 agent_id: AgentId,
1611 proof_path: PathBuf,
1612 ) -> Result<Self, NodeServerError> {
1613 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1614 Self::new_fixture_with_spec(config, spec)
1615 }
1616
1617 #[cfg(all(feature = "fixture", windows))]
1618 pub fn new_provider_bundle_argv_hold_fixture(
1619 config: NodeServerConfig,
1620 agent_id: AgentId,
1621 proof_path: PathBuf,
1622 release_signal: PathBuf,
1623 ) -> Result<Self, NodeServerError> {
1624 let spec = Self::provider_bundle_argv_fixture_spec(
1625 agent_id,
1626 proof_path,
1627 Some(release_signal),
1628 )?;
1629 Self::new_fixture_with_spec(config, spec)
1630 }
1631
1632 #[cfg(all(feature = "fixture", windows))]
1633 fn provider_bundle_argv_fixture_spec(
1634 agent_id: AgentId,
1635 proof_path: PathBuf,
1636 release_signal: Option<PathBuf>,
1637 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1638 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1639 return Err(NodeServerError::Registry(
1640 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1641 ));
1642 }
1643 let mut spec = gate4agent_testkit::interactive_agent_spec();
1644 spec.id = agent_id.clone();
1645 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1646 let provider = builtin_registry()
1647 .get(&agent_id)
1648 .ok_or_else(|| {
1649 NodeServerError::Registry(
1650 "provider bundle argv fixture adapter is unavailable".to_owned(),
1651 )
1652 })?;
1653 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1654 if !proof_path.is_absolute()
1655 || !proof_path
1656 .parent()
1657 .is_some_and(|parent| parent.is_dir())
1658 {
1659 return Err(NodeServerError::Registry(
1660 "provider bundle argv proof path requires an existing absolute parent"
1661 .to_owned(),
1662 ));
1663 }
1664 if let Some(release_signal) = release_signal.as_ref() {
1665 if !release_signal.is_absolute()
1666 || !release_signal
1667 .parent()
1668 .is_some_and(|parent| parent.is_dir())
1669 {
1670 return Err(NodeServerError::Registry(
1671 "provider bundle argv release path requires an existing absolute parent"
1672 .to_owned(),
1673 ));
1674 }
1675 if release_signal.exists() {
1676 return Err(NodeServerError::Registry(
1677 "provider bundle argv release path must not exist".to_owned(),
1678 ));
1679 }
1680 let proof_parent = std::fs::canonicalize(
1681 proof_path
1682 .parent()
1683 .expect("validated provider bundle proof parent"),
1684 )
1685 .map_err(|_| {
1686 NodeServerError::Registry(
1687 "provider bundle argv proof parent is unavailable".to_owned(),
1688 )
1689 })?;
1690 let release_parent = std::fs::canonicalize(
1691 release_signal
1692 .parent()
1693 .expect("validated provider bundle release parent"),
1694 )
1695 .map_err(|_| {
1696 NodeServerError::Registry(
1697 "provider bundle argv release parent is unavailable".to_owned(),
1698 )
1699 })?;
1700 if proof_parent != release_parent {
1701 return Err(NodeServerError::Registry(
1702 "provider bundle argv release path must share the proof parent".to_owned(),
1703 ));
1704 }
1705 }
1706 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1707 NodeServerError::Registry(
1708 "provider bundle argv proof path is not valid Unicode".to_owned(),
1709 )
1710 })?;
1711 let release_signal = release_signal.map(|release_signal| {
1712 release_signal.into_os_string().into_string().map_err(|_| {
1713 NodeServerError::Registry(
1714 "provider bundle argv release path is not valid Unicode".to_owned(),
1715 )
1716 })
1717 }).transpose()?;
1718 let script = spec
1719 .launch
1720 .fixed_args
1721 .pop()
1722 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1723 let bundle_validation = if agent_id.as_str() == "codex" {
1724 "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') }"
1725 } else if agent_id.as_str() == "claude" {
1726 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--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 }"
1727 } else {
1728 "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 }"
1729 };
1730 if let Some(release_signal) = release_signal {
1731 spec.launch.fixed_args.push(format!(
1732 "& {{ 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} }}",
1733 ));
1734 spec.launch.fixed_args.push(proof_path);
1735 spec.launch.fixed_args.push(release_signal);
1736 } else {
1737 spec.launch.fixed_args.push(format!(
1738 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1739 ));
1740 spec.launch.fixed_args.push(proof_path);
1741 }
1742 Ok(spec)
1743 }
1744
1745 #[cfg(all(feature = "fixture", windows))]
1746 pub fn new_context_pack_fixture(
1747 config: NodeServerConfig,
1748 proof_path: PathBuf,
1749 ) -> Result<Self, NodeServerError> {
1750 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1751 Self::new_with_registry(config, catalog)
1752 }
1753
1754 #[cfg(all(feature = "fixture", windows))]
1755 pub fn new_monitoring_context_pack_fixture(
1756 config: NodeServerConfig,
1757 proof_path: PathBuf,
1758 ) -> Result<Self, NodeServerError> {
1759 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1760 let mut server = Self::new_with_registry(config, catalog)?;
1761 Arc::get_mut(&mut server.shared)
1762 .expect("fixture Node shared state must remain uniquely owned before run")
1763 .fixture_semantic_hook_policy = true;
1764 Ok(server)
1765 }
1766
1767 #[cfg(all(feature = "fixture", windows))]
1768 pub fn install_isolated_codex_home_environment_fixture(
1769 &mut self,
1770 profile_id: SpawnEnvironmentProfileId,
1771 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1772 ) -> Result<(), NodeServerError> {
1773 let provider = AgentId::new("codex")
1774 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1775 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1776 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1777 "{}-pty",
1778 profile_id.as_str(),
1779 ))
1780 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1781 provider.clone(),
1782 TransportKind::Pty,
1783 vec![OsString::from("USERPROFILE")],
1784 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1785 )
1786 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787 let materialization = NodeSessionMaterializationProfile::new(
1788 Vec::new(),
1789 vec![crate::session_environment::NodeSessionPathBinding::new(
1790 "CODEX_HOME",
1791 crate::session_environment::NodeSessionPathClass::ProviderHome,
1792 )
1793 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1794 Vec::new(),
1795 )
1796 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1797 self.install_environment_profile(
1798 NodeEnvironmentProfile::new_with_materialization(
1799 profile_id,
1800 profile_revision,
1801 provider,
1802 [native_profile],
1803 Some(materialization),
1804 )
1805 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1806 )
1807 }
1808
1809 #[cfg(all(feature = "fixture", windows))]
1810 pub fn new_context_only_proof_fixture(
1811 config: NodeServerConfig,
1812 target_provider: AgentId,
1813 proof_path: PathBuf,
1814 ) -> Result<Self, NodeServerError> {
1815 if target_provider.as_str() != "kimi" {
1816 return Err(NodeServerError::Registry(
1817 "context-only proof fixture requires Kimi".to_owned(),
1818 ));
1819 }
1820 let catalog = Self::context_pack_fixture_catalog(
1821 proof_path,
1822 Some(&target_provider),
1823 false,
1824 )?;
1825 Self::new_with_registry(config, catalog)
1826 }
1827
1828 #[cfg(all(feature = "fixture", windows))]
1829 fn context_pack_fixture_catalog(
1830 proof_path: PathBuf,
1831 context_only_target: Option<&AgentId>,
1832 monitoring_claude_source: bool,
1833 ) -> Result<AgentRegistry, NodeServerError> {
1834 if !proof_path.is_absolute()
1835 || !proof_path
1836 .parent()
1837 .is_some_and(|parent| parent.is_dir())
1838 {
1839 return Err(NodeServerError::Registry(
1840 "context pack proof path requires an existing absolute parent".to_owned(),
1841 ));
1842 }
1843 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1844 NodeServerError::Registry(
1845 "context pack proof path is not valid Unicode".to_owned(),
1846 )
1847 })?;
1848 let providers = ["claude", "codex", "grok", "kimi"];
1849 let builtins = builtin_registry();
1850 let mut specs = Vec::with_capacity(providers.len());
1851 for provider_id in providers {
1852 let agent_id = AgentId::new(provider_id)
1853 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1854 let provider = builtins.get(&agent_id).ok_or_else(|| {
1855 NodeServerError::Registry(
1856 "context pack fixture provider adapter is unavailable".to_owned(),
1857 )
1858 })?;
1859 let monitored_provider =
1860 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1861 let mut spec = if monitored_provider {
1862 gate4agent_testkit::monitoring_hook_agent_spec()
1863 } else {
1864 gate4agent_testkit::interactive_agent_spec()
1865 };
1866 spec.id = agent_id.clone();
1867 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1868 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1869 spec.detection.aliases.clear();
1870 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1871 if provider_id == "codex" {
1872 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1873 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1874 })?;
1875 let validation = r#"
1876$bundleArgs = @($bundleArgs)
1877if ($bundleArgs.Count -ne 0) { exit 91 }
1878$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1879$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1880$cwd = (Get-Location).ProviderPath
1881if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1882$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1883if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1884$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1885if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1886$sha = [Security.Cryptography.SHA256]::Create()
1887try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1888if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1889if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1890$contextPath = Join-Path $contextRoot 'context-pack.json'
1891if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1892$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1893if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1894$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1896$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1897$separator = [IO.Path]::DirectorySeparatorChar
1898if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1899if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1900$rawContext = [IO.File]::ReadAllText($contextPath)
1901if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1902try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1903$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1904$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1905foreach ($property in @($document.PSObject.Properties.Name)) {
1906 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1907}
1908foreach ($property in $requiredTopLevel) {
1909 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1910}
1911if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1912if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1913$messages = @($document.retained_messages)
1914if ($messages.Count -eq 0) { exit 107 }
1915foreach ($message in $messages) {
1916 $messageProperties = @($message.PSObject.Properties.Name)
1917 foreach ($property in $messageProperties) {
1918 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1919 }
1920 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1921}
1922$sha = [Security.Cryptography.SHA256]::Create()
1923try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1924[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))
1925$rawProof = [IO.File]::ReadAllText($proofPath)
1926if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1927[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1928"#;
1929 spec.launch.fixed_args.push(format!(
1930 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1931 ));
1932 spec.launch.fixed_args.push(proof_path.clone());
1933 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1934 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1935 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1936 })?;
1937 let validation = r#"
1938$bundleArgs = @($bundleArgs)
1939if ($bundleArgs.Count -ne 0) { exit 91 }
1940$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1941$cwd = (Get-Location).ProviderPath
1942if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1943$contextPath = Join-Path $contextRoot 'context-pack.json'
1944if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1945$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1946if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1947$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1948$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1949$separator = [IO.Path]::DirectorySeparatorChar
1950if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1951try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1952if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1953if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1954$messages = @($document.retained_messages)
1955if ($messages.Count -eq 0) { exit 99 }
1956$hasUser = $false
1957$hasAssistant = $false
1958foreach ($message in $messages) {
1959 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1960 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1961 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1962 else { exit 101 }
1963}
1964if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1965$sha = [Security.Cryptography.SHA256]::Create()
1966try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1967[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1968[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1969"#;
1970 spec.launch.fixed_args.push(format!(
1971 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1972 ));
1973 spec.launch.fixed_args.push(proof_path.clone());
1974 }
1975 specs.push(spec);
1976 }
1977 let catalog = AgentRegistry::new(specs)
1978 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1979 Ok(catalog)
1980 }
1981
1982 #[cfg(feature = "fixture")]
1983 pub fn new_exact_launcher_fixture(
1984 config: NodeServerConfig,
1985 agent_id: AgentId,
1986 launcher: String,
1987 ) -> Result<Self, NodeServerError> {
1988 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1989 return Err(NodeServerError::Registry(
1990 "exact launcher fixture requires a supported provider".to_owned(),
1991 ));
1992 }
1993 if launcher.contains('\0') {
1994 return Err(NodeServerError::Registry(
1995 "exact launcher fixture path contains NUL".to_owned(),
1996 ));
1997 }
1998 let launcher_path = Path::new(&launcher);
1999 if !launcher_path.is_absolute() {
2000 return Err(NodeServerError::Registry(
2001 "exact launcher fixture path must be absolute".to_owned(),
2002 ));
2003 }
2004 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2005 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2006 })?;
2007 if !metadata.is_file() {
2008 return Err(NodeServerError::Registry(
2009 "exact launcher fixture path must be a regular file".to_owned(),
2010 ));
2011 }
2012 let mut spec = builtin_registry()
2013 .get(&agent_id)
2014 .ok_or_else(|| {
2015 NodeServerError::Registry(
2016 "exact launcher fixture provider is unavailable".to_owned(),
2017 )
2018 })?
2019 .clone();
2020 spec.launch.program = launcher;
2021 Self::new_fixture_with_spec(config, spec)
2022 }
2023
2024 #[cfg(feature = "fixture")]
2033 fn h3b_provider_spec_with_program_and_args(
2034 agent_id: AgentId,
2035 provider_program: PathBuf,
2036 fixed_args: Vec<OsString>,
2037 ) -> Result<AgentSpec, NodeServerError> {
2038 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2039 return Err(NodeServerError::Registry(
2040 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2041 ));
2042 }
2043 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2044 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2045 })?;
2046 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2047 return Err(NodeServerError::Registry(
2048 "H3B fixture provider must be an exact regular file".to_owned(),
2049 ));
2050 }
2051 let program = provider_program.into_os_string().into_string().map_err(|_| {
2052 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2053 })?;
2054 let fixed_args = fixed_args.into_iter().map(|argument| {
2055 argument.into_string().map_err(|_| NodeServerError::Registry(
2056 "H3B fixture argument is not Unicode".to_owned(),
2057 ))
2058 }).collect::<Result<Vec<_>, _>>()?;
2059 let argument_bytes = fixed_args.iter()
2060 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2061 if program.contains('\0')
2062 || fixed_args.iter().any(|argument| argument.contains('\0'))
2063 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2064 {
2065 return Err(NodeServerError::Registry(
2066 "H3B fixture provider or arguments are invalid".to_owned(),
2067 ));
2068 }
2069 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2070 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2071 })?.clone();
2072 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2073 .ok_or_else(|| NodeServerError::Registry(
2074 "H3B fixture provider basename is unavailable".to_owned(),
2075 ))?.to_owned();
2076 spec.detection.command = process_name.clone();
2077 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2078 name: process_name,
2079 }];
2080 spec.launch.program = program;
2081 spec.launch.fixed_args = fixed_args;
2082 Ok(spec)
2083 }
2084
2085 #[cfg(feature = "fixture")]
2086 pub fn new_harness_mcp_proxy_fixture(
2087 config: NodeServerConfig,
2088 provider_program: PathBuf,
2089 fixed_args: Vec<OsString>,
2090 harness_mcp_program: PathBuf,
2091 ) -> Result<Self, NodeServerError> {
2092 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2093 let agent_id = AgentId::new("codex")
2094 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2095 let mut spec =
2096 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2097 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2098 config.fixture_raw_pty_runtime = true;
2099 Self::new_fixture_with_spec(config, spec)
2100 }
2101
2102 #[cfg(feature = "fixture")]
2126 pub fn new_harness_mcp_acp_launcher_fixture(
2127 config: NodeServerConfig,
2128 helper_program: PathBuf,
2129 provider: AgentId,
2130 program: PathBuf,
2131 args: Vec<OsString>,
2132 ) -> Result<Self, NodeServerError> {
2133 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2134 return Err(NodeServerError::Registry(
2135 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2136 ));
2137 }
2138 let config = config.with_harness_mcp_helper(helper_program)?;
2139 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2140 let launch = spec.launch.clone();
2141 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2142 NodeServerError::Registry(
2143 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2144 .to_owned(),
2145 )
2146 })?;
2147 acp.launch_override = Some(launch);
2155 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2156 Self::new_fixture_with_spec(config, spec)
2157 }
2158
2159 #[cfg(feature = "fixture")]
2160 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2161 let mut spec = gate4agent_testkit::interactive_agent_spec();
2162 let claude_id = AgentId::new("claude")
2163 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2164 let claude = builtin_registry()
2165 .get(&claude_id)
2166 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2167 spec.id = claude_id;
2168 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2169 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2170 name: "claude".to_owned(),
2171 }];
2172 spec.capabilities.transports.pty_adapter = claude
2173 .capabilities
2174 .transports
2175 .pty_adapter
2176 .clone();
2177 spec.capabilities.adapters.resume = claude
2178 .capabilities
2179 .adapters
2180 .resume
2181 .clone();
2182 let script = spec
2183 .launch
2184 .fixed_args
2185 .pop()
2186 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2187 spec.launch.fixed_args.push(format!(
2188 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2189 ));
2190 Self::new_fixture_with_spec(config, spec)
2191 }
2192
2193 #[cfg(feature = "fixture")]
2194 pub fn new_cmd_cwd_fixture(
2195 config: NodeServerConfig,
2196 command: String,
2197 ) -> Result<Self, NodeServerError> {
2198 let mut spec = gate4agent_testkit::interactive_agent_spec();
2199 spec.id = AgentId::new("claude")
2200 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2201 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2202 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2203 spec.launch.program = command;
2204 spec.launch.fixed_args.clear();
2205 Self::new_fixture_with_spec(config, spec)
2206 }
2207
2208 #[cfg(feature = "fixture")]
2209 fn new_fixture_with_spec(
2210 config: NodeServerConfig,
2211 spec: gate4agent_types::AgentSpec,
2212 ) -> Result<Self, NodeServerError> {
2213 let catalog = AgentRegistry::new([spec])
2214 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2215 Self::new_with_registry(config, catalog)
2216 }
2217
2218 fn new_with_registry(
2219 config: NodeServerConfig,
2220 catalog: AgentRegistry,
2221 ) -> Result<Self, NodeServerError> {
2222 for profile in config.spawn_profiles.iter() {
2223 if catalog.get(&profile.provider).is_none() {
2224 return Err(NodeServerError::Registry(
2225 "spawn profile references an unavailable provider".to_owned(),
2226 ));
2227 }
2228 }
2229 let input_settle_timeout_ms = catalog
2230 .iter()
2231 .map(|spec| spec.readiness.timeout_ms)
2232 .max()
2233 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2234 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2235 .max(MUTATION_SETTLE_TIMEOUT_MS);
2236 let (provider_contracts, provider_adapter_contracts) =
2237 provider_contract_manifest(&catalog)?;
2238 let compatibility_support = node_compatibility_support_for_manifest(
2239 &provider_contracts,
2240 &provider_adapter_contracts,
2241 )?;
2242 validate_node_negotiated_handshake_capacity(&compatibility_support)
2243 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2244 let enabled_providers = provider_contracts
2245 .iter()
2246 .map(|contract| contract.provider.clone())
2247 .collect();
2248 #[cfg(feature = "fixture")]
2249 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2250 #[cfg(not(feature = "fixture"))]
2251 let fixture_raw_pty_runtime = false;
2252 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2253 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2254 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2255 })).expect("fixture provider catalog remains bounded");
2256 (None, statuses)
2257 } else {
2258 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2259 let statuses = monitor.collect();
2260 (Some(monitor), statuses)
2261 };
2262 let state_path_lock =
2263 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2264 .map_err(|error| {
2265 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2266 })?;
2267 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2268 Some(state_path) => {
2269 let delivery_root = delivery_store_root_for_state_path(state_path)
2270 .ok_or_else(|| {
2271 NodeServerError::DeliveryStore(
2272 "delivery store root could not be derived from state path".to_owned(),
2273 )
2274 })?;
2275 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2276 let detail = match &error {
2277 DeliveryStoreError::Storage(io_error) => {
2278 format!("{error}: {io_error}")
2279 }
2280 _ => error.to_string(),
2281 };
2282 NodeServerError::DeliveryStore(detail)
2283 })?;
2284 (Some(store), bundles)
2285 }
2286 None => (None, Vec::new()),
2287 };
2288 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2289 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2290 })?;
2291 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2292 Some(state_path) => {
2293 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2294 .ok_or(NodeServerError::ContextPackStore)?;
2295 let (store, packs) = ContextPackStore::open(context_pack_root)
2296 .map_err(|_| NodeServerError::ContextPackStore)?;
2297 (Some(store), packs)
2298 }
2299 None => (None, Vec::new()),
2300 };
2301 let mut durable_context_catalog = ContextPackCatalog::default();
2302 for pack in durable_context_packs {
2303 durable_context_catalog
2304 .insert(pack)
2305 .map_err(|_| NodeServerError::ContextPackStore)?;
2306 }
2307 let session_environment_materializer = config
2308 .session_environment
2309 .as_ref()
2310 .map(|environment| {
2311 SessionEnvironmentMaterializer::new(
2312 environment.root.clone(),
2313 Arc::clone(&environment.resolver),
2314 )
2315 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2316 })
2317 .transpose()?;
2318 #[cfg(unix)]
2321 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2322 let native_session_catalog = config.history.clone().map(|history| {
2323 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2324 });
2325 let (handle, runtime) = match config.history.clone() {
2326 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2327 None => NativeRuntime::new(catalog, config.runtime),
2328 };
2329 let native_launch_profile_control = runtime.native_launch_profile_control();
2330 let harness_mcp_registry = config
2331 .harness_mcp_helper
2332 .clone()
2333 .map(HarnessMcpProxyRegistry::new);
2334 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2335 let incarnation_id = random_incarnation_id()
2336 .map_err(NodeServerError::IncarnationIdentity)?;
2337 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2338 .map_err(durable_state_load_error)?;
2339 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2340 let managed_worktree_tombstones =
2341 std::mem::take(&mut loaded.managed_worktree_tombstones);
2342 let materializations = std::mem::take(&mut loaded.materializations);
2343 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2344 let (workspaces, records, persistence_warning) =
2345 merge_durable_state(&config.workspaces, loaded)?;
2346 let mut shared = NodeShared::new_with_incarnation(
2347 handle,
2348 config.access_token.clone(),
2349 config.node_id.clone(),
2350 incarnation_id,
2351 workspaces,
2352 enabled_providers,
2353 provider_runtime_statuses,
2354 provider_runtime_monitor,
2355 provider_contracts,
2356 provider_adapter_contracts,
2357 config.spawn_profiles.clone(),
2358 Some(native_launch_profile_control),
2359 native_session_catalog,
2360 session_environment_materializer,
2361 config.state_path.clone(),
2362 records,
2363 managed_worktrees,
2364 managed_worktree_tombstones,
2365 materializations,
2366 persistence_warning,
2367 input_settle_timeout_ms,
2368 );
2369 shared.harness_mcp_registry = harness_mcp_registry.clone();
2370 shared.session_record_retention = config.session_record_retention;
2371 shared.network_allowlist_catalog =
2372 RwLock::new(config.network_allowlist_catalog.clone());
2373 shared.bundle_catalog = RwLock::new(delivered_catalog);
2374 shared.delivery_store = Mutex::new(delivery_store);
2375 shared.context_catalog = RwLock::new(durable_context_catalog);
2376 shared.context_pack_store = Mutex::new(context_pack_store);
2377 shared.managed_spawn_replays = Mutex::new(
2378 managed_spawn_replays
2379 .into_iter()
2380 .map(|record| (record.idempotency_key.clone(), record))
2381 .collect(),
2382 );
2383 let shared = Arc::new(shared);
2384 if let Some(registry) = harness_mcp_registry {
2385 let weak = Arc::downgrade(&shared);
2386 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2387 Some(shared) => shared.publish_transient(event),
2388 None => false,
2389 }));
2390 }
2391 if shared.persistence_error().is_none() {
2392 shared.persist_state().map_err(|error| {
2393 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2394 })?;
2395 }
2396 Ok(Self {
2397 config,
2398 runtime,
2399 shared,
2400 events,
2401 state_path_lock,
2402 })
2403 }
2404
2405 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2406 NodeShutdownHandle {
2407 shared: Arc::clone(&self.shared),
2408 }
2409 }
2410
2411 #[cfg(feature = "fixture")]
2412 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2413 &self,
2414 ) -> SpawnManagedWorktreeV2FailureProbe {
2415 self.shared
2416 .fixture_spawn_managed_worktree_v2_failure_probe
2417 .clone()
2418 }
2419
2420 pub fn install_environment_profile(
2425 &mut self,
2426 profile: NodeEnvironmentProfile,
2427 ) -> Result<(), NodeServerError> {
2428 if !self.shared.enabled_providers.contains(profile.provider()) {
2429 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2430 profile.provider().clone(),
2431 ));
2432 }
2433 let (binding, native_profiles, materialization) = profile.into_parts();
2434 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2435 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2436 }
2437 let mut profiles = self
2438 .shared
2439 .environment_profiles
2440 .write()
2441 .unwrap_or_else(|poisoned| poisoned.into_inner());
2442 if profiles.contains_key(&binding.id) {
2443 return Err(NodeServerError::DuplicateEnvironmentProfile(
2444 binding.id.clone(),
2445 ));
2446 }
2447 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2448 return Err(NodeServerError::EnvironmentProfileCapacity {
2449 max: MAX_NODE_ENVIRONMENT_PROFILES,
2450 });
2451 }
2452 for native_id in binding.native_profile_ids() {
2453 if profiles.values().any(|current| {
2454 current.native_profile_ids().any(|current_id| current_id == native_id)
2455 }) {
2456 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2457 native_id.to_string(),
2458 ));
2459 }
2460 }
2461
2462 let mut installed = Vec::with_capacity(native_profiles.len());
2463 for native_profile in native_profiles {
2464 let native_id = native_profile.id().clone();
2465 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2466 for installed_id in installed.into_iter().rev() {
2467 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2468 }
2469 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2470 }
2471 installed.push(native_id);
2472 }
2473 if let Some(materialization) = materialization {
2474 self.shared
2475 .environment_materialization_profiles
2476 .write()
2477 .unwrap_or_else(|poisoned| poisoned.into_inner())
2478 .insert(binding.id.clone(), materialization);
2479 }
2480 profiles.insert(binding.id.clone(), binding);
2481 Ok(())
2482 }
2483
2484 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2486 if self.shared.session_environment_materializer.is_none() {
2487 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2488 }
2489 let mut catalog = self
2490 .shared
2491 .bundle_catalog
2492 .write()
2493 .unwrap_or_else(|poisoned| poisoned.into_inner());
2494 catalog
2495 .insert_idempotent(bundle)
2496 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2497 Ok(())
2498 }
2499
2500 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2501 let shutdown = self.shutdown_handle();
2502 let run = self.run();
2503 tokio::pin!(run);
2504 tokio::select! {
2505 result = &mut run => result,
2506 signal = wait_for_ctrl_signal() => {
2507 signal?;
2508 let shutdown_result = shutdown.request_shutdown().await;
2509 let run_result = run.await;
2510 shutdown_result.and(run_result)
2511 }
2512 }
2513 }
2514
2515 pub async fn run(self) -> Result<(), NodeServerError> {
2516 let NodeServer {
2517 config,
2518 mut runtime,
2519 shared,
2520 events,
2521 state_path_lock: _state_path_lock,
2522 } = self;
2523 shared.reconcile_materializations();
2524 shared.reconcile_managed_worktrees().await;
2525 shared.retire_unavailable_session_records().await;
2526 shared.reconcile_context_pack_exports().await;
2527 {
2528 let restore = Arc::clone(&shared);
2529 tokio::spawn(async move {
2530 sleep(Duration::from_secs(3)).await;
2531 restore.restore_journalled_sessions().await;
2532 loop {
2533 restore.refresh_session_journal();
2534 if restore.shutdown.load(Ordering::Acquire) { break; }
2535 sleep(Duration::from_secs(10)).await;
2536 }
2537 });
2538 }
2539 let endpoint = config.endpoint.clone();
2543 let call_home = config.call_home;
2544 let call_home_identity = config.call_home_identity.clone();
2545 let api_listen = config.api_listen;
2546 let bridge_listen = config.bridge_listen;
2547 let bridge_underlay = config.bridge_underlay;
2548 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2549 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2550 let accept_shared = Arc::clone(&shared);
2551 let api_shared = Arc::clone(&shared);
2552 let bridge_shared = Arc::clone(&shared);
2553 let shutdown_shared = Arc::clone(&shared);
2554 let shutdown_timeout = Duration::from_millis(
2555 config.runtime.provider_shutdown_timeout_ms.max(1),
2556 );
2557 let result = {
2558 let runtime_loop = drive_runtime_until_shutdown(
2559 &mut runtime,
2560 events,
2561 Arc::clone(&shared),
2562 shutdown_timeout,
2563 );
2564 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2565 let api_loop = http_api::run(api_listen, api_shared);
2566 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2567 tokio::pin!(runtime_loop);
2568 tokio::pin!(accept_loop);
2569 tokio::pin!(api_loop);
2570 tokio::pin!(bridge_loop);
2571 tokio::select! {
2572 runtime_result = &mut runtime_loop => {
2573 let shutdown_result = shutdown_shared
2574 .begin_shutdown()
2575 .await
2576 .map_err(NodeServerError::ShutdownDispatch);
2577 let (accept_result, api_result, bridge_result) =
2578 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2579 match runtime_result {
2580 Err(error) => Err(error),
2581 Ok(()) => shutdown_result
2582 .and(accept_result)
2583 .and(api_result.map_err(NodeServerError::HttpApi))
2584 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2585 }
2586 }
2587 accept_result = &mut accept_loop => {
2588 let shutdown_result = shutdown_shared
2589 .begin_shutdown()
2590 .await
2591 .map_err(NodeServerError::ShutdownDispatch);
2592 let (runtime_result, api_result, bridge_result) =
2593 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2594 match accept_result {
2595 Err(error) => Err(error),
2596 Ok(()) => shutdown_result
2597 .and(runtime_result)
2598 .and(api_result.map_err(NodeServerError::HttpApi))
2599 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2600 }
2601 }
2602 api_result = &mut api_loop => {
2603 let shutdown_result = shutdown_shared
2604 .begin_shutdown()
2605 .await
2606 .map_err(NodeServerError::ShutdownDispatch);
2607 let (runtime_result, accept_result, bridge_result) =
2608 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2609 match api_result {
2610 Err(error) => Err(NodeServerError::HttpApi(error)),
2611 Ok(()) => shutdown_result
2612 .and(runtime_result)
2613 .and(accept_result)
2614 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2615 }
2616 }
2617 bridge_result = &mut bridge_loop => {
2618 let shutdown_result = shutdown_shared
2619 .begin_shutdown()
2620 .await
2621 .map_err(NodeServerError::ShutdownDispatch);
2622 let (runtime_result, accept_result, api_result) =
2623 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2624 match bridge_result {
2625 Err(error) => Err(NodeServerError::BridgeApi(error)),
2626 Ok(()) => shutdown_result
2627 .and(runtime_result)
2628 .and(accept_result)
2629 .and(api_result.map_err(NodeServerError::HttpApi)),
2630 }
2631 }
2632 }
2633 };
2634 if let Ok(mut guard) = shared.delivery_store.lock() {
2641 *guard = None;
2642 }
2643 if let Ok(mut guard) = shared.context_pack_store.lock() {
2644 *guard = None;
2645 }
2646 result
2647 }
2648}
2649
2650async fn drive_runtime_until_shutdown(
2651 runtime: &mut NativeRuntime,
2652 events: EventSubscription,
2653 shared: Arc<NodeShared>,
2654 shutdown_timeout: Duration,
2655) -> Result<(), NodeServerError> {
2656 let _ = shared
2660 .shell_efficiency_profile
2661 .set(runtime.shell_efficiency_profile());
2662 let mut shutdown_started = None;
2663 let mut last_retention_sweep = Instant::now();
2664 let mut last_provisional_reap = Instant::now();
2665 loop {
2666 let runtime_tick_start = Instant::now();
2673 let tick_result = runtime.tick().await;
2674 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2675
2676 let event_drain_start = Instant::now();
2677 for outcome in &tick_result.command_outcomes {
2685 if let Err(error) = &outcome.result {
2686 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2687 tracing::warn!(
2688 command_id = outcome.command_id.0,
2689 cause = %error,
2690 "control command rejected"
2691 );
2692 shared.record_rejected_command(
2693 outcome.command_id,
2694 error.to_string(),
2695 error.is_unsupported_transport(),
2696 );
2697 }
2698 }
2699 let mut events_drained: u64 = 0;
2700 while let Ok(event) = events.try_recv() {
2701 events_drained += 1;
2702 let clean_exit = matches!(
2703 event.event,
2704 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2705 );
2706 let instance_id = event.instance_id;
2707 let generation = event.generation;
2708 if clean_exit {
2720 if let Some((record_id, session)) =
2721 shared.managed_record_export_target_for_instance(instance_id, generation)
2722 {
2723 let export_shared = Arc::clone(&shared);
2724 tokio::spawn(async move {
2725 export_shared
2726 .reconcile_context_pack_export_for_record(&record_id, session)
2727 .await;
2728 });
2729 }
2730 }
2731 shared.publish_control(event);
2732 }
2733 let event_drain_elapsed = event_drain_start.elapsed();
2734
2735 let publish_terminal_frames_start = Instant::now();
2736 shared.publish_terminal_frames();
2737 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2738
2739 shared
2745 .native_session_gauge
2746 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2747 *shared
2748 .runtime_tick_profile
2749 .lock()
2750 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2751
2752 let remainder_start = Instant::now();
2753 if shared.shutdown.load(Ordering::Acquire) {
2754 let started = *shutdown_started.get_or_insert_with(Instant::now);
2755 let snapshot = shared.handle.snapshot();
2756 let sessions_settled = snapshot.sessions.iter().all(|session| {
2757 !matches!(
2758 session.status,
2759 gate4agent_types::SessionStatus::Starting
2760 | gate4agent_types::SessionStatus::Running
2761 | gate4agent_types::SessionStatus::Stopping
2762 )
2763 });
2764 if runtime.active_native_sessions() == 0 && sessions_settled {
2765 return Ok(());
2766 }
2767 if started.elapsed() >= shutdown_timeout {
2768 return Err(NodeServerError::ShutdownTimedOut {
2769 active_native_sessions: runtime.active_native_sessions(),
2770 });
2771 }
2772 }
2773 let remainder_elapsed = remainder_start.elapsed();
2774
2775 {
2776 let mut profile = shared
2777 .drive_loop_profile
2778 .lock()
2779 .unwrap_or_else(|poisoned| poisoned.into_inner());
2780 profile.record_runtime_tick(runtime_tick_elapsed);
2781 profile.record_event_drain(event_drain_elapsed);
2782 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2783 profile.record_remainder(remainder_elapsed);
2784 profile.note_iteration(Instant::now());
2785 }
2786
2787 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2799 if tick_result.observations_applied == 0 && events_drained == 0 {
2800 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2801 }
2802
2803 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2808 shared.retire_unavailable_session_records().await;
2809 last_retention_sweep = Instant::now();
2810 }
2811 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2813 for address in shared.take_due_provisional_spawns() {
2814 shared.reap_provisional_spawn(address).await;
2815 }
2816 last_provisional_reap = Instant::now();
2817 }
2818
2819 let hot = shared
2822 .pty_input_marks
2823 .lock()
2824 .unwrap_or_else(|poisoned| poisoned.into_inner())
2825 .values()
2826 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2827 if hot {
2828 sleep(Duration::from_millis(1)).await;
2829 } else {
2830 tokio::select! {
2831 _ = sleep(Duration::from_millis(10)) => {}
2832 _ = shared.pty_input_wake.notified() => {}
2833 }
2834 }
2835 }
2836}
2837
2838#[derive(Clone)]
2839pub struct NodeShutdownHandle {
2840 shared: Arc<NodeShared>,
2841}
2842
2843impl NodeShutdownHandle {
2844 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2845 self.shared
2846 .begin_shutdown()
2847 .await
2848 .map_err(NodeServerError::ShutdownDispatch)
2849 }
2850}
2851
2852async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2853 #[cfg(windows)]
2854 {
2855 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2856 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2857 return tokio::select! {
2858 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2859 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2860 };
2861 }
2862 #[cfg(unix)]
2863 {
2864 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2865 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2866 tokio::select! {
2867 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2868 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2869 }
2870 }
2871}
2872
2873fn shell_host_spec() -> AgentSpec {
2887 #[cfg(windows)]
2891 let (detect_command, program, fixed_args, process_name) = {
2892 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2893 r"C:\Windows\System32\cmd.exe".to_owned()
2894 });
2895 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2896 };
2897 #[cfg(not(windows))]
2898 let (detect_command, program, fixed_args, process_name) = (
2899 "bash".to_owned(),
2900 "/bin/bash".to_owned(),
2901 vec!["-i".to_owned()],
2902 "bash".to_owned(),
2903 );
2904 AgentSpec {
2905 id: AgentId::new("shell").expect("shell agent id"),
2906 revision: "hatchery:shell-pty-1".to_owned(),
2907 display_name: "Host shell".to_owned(),
2908 detection: DetectionSpec {
2909 command: detect_command,
2910 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2911 required_commands: Vec::new(),
2912 unsupported_platforms: Vec::new(),
2913 },
2914 launch: LaunchSpec {
2915 program,
2916 fixed_args,
2917 },
2918 expected_processes: vec![ProcessMatcher::Exact {
2919 name: process_name,
2920 }],
2921 prompt: PromptSpec {
2922 initial: InitialPromptMode::None,
2923 native_draft: None,
2924 },
2925 readiness: AgentReadinessSpec::default(),
2926 capabilities: AgentCapabilities {
2927 agent_commands: None,
2928 transports: AgentTransportCapabilities {
2929 pty: true,
2930 pty_adapter: None,
2931 pipe: None,
2932 acp: None,
2933 },
2934 adapters: AgentAdapterCapabilities::default(),
2935 },
2936 verification: SpecVerification::Gate4AgentVerified,
2937 }
2938}
2939
2940fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2941 let mut specs = vec![shell_host_spec()];
2942 specs.extend(
2943 builtin_registry()
2944 .iter()
2945 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2946 .cloned(),
2947 );
2948 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2949}
2950
2951fn provider_contract_manifest(
2952 catalog: &AgentRegistry,
2953) -> Result<
2954 (
2955 Vec<ProviderContractSupport>,
2956 Vec<ProviderAdapterContractSupport>,
2957 ),
2958 NodeServerError,
2959> {
2960 let mut provider_contracts = Vec::new();
2961 let mut provider_adapter_contracts = Vec::new();
2962 for spec in catalog.iter() {
2963 let provider = spec.id.clone();
2964 let revision = ProviderContractRevision::new(spec.revision.clone())
2965 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2966 provider_contracts.push(ProviderContractSupport {
2967 provider: provider.clone(),
2968 revision,
2969 });
2970 for (family, binding) in declared_adapter_bindings(spec)? {
2971 let revision = AdapterContractRevision::new(binding.revision.clone())
2972 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2973 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2974 provider: provider.clone(),
2975 family,
2976 adapter_id: binding.id.clone(),
2977 revision,
2978 });
2979 }
2980 }
2981 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2982 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2983 Ok((provider_contracts, provider_adapter_contracts))
2984}
2985
2986fn declared_adapter_bindings(
2987 spec: &AgentSpec,
2988) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2989 let mut bindings = Vec::new();
2990 push_declared_binding(
2991 &mut bindings,
2992 AdapterFamily::PtySemantic,
2993 spec.capabilities.transports.pty_adapter.as_ref(),
2994 )?;
2995 push_declared_binding(
2996 &mut bindings,
2997 AdapterFamily::Pipe,
2998 spec.capabilities.adapters.pty_sidecar.as_ref(),
2999 )?;
3000 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3001 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3002 gate4agent_types::PipeProtocol::SemanticNdjson
3003 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3004 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3005 });
3006 push_declared_binding(
3007 &mut bindings,
3008 AdapterFamily::Pipe,
3009 pipe_transport
3010 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3011 .map(|transport| &transport.adapter),
3012 )?;
3013 push_declared_binding(
3014 &mut bindings,
3015 AdapterFamily::Acp,
3016 spec.capabilities
3017 .transports
3018 .acp
3019 .as_ref()
3020 .map(|transport| &transport.adapter),
3021 )?;
3022 push_declared_binding(
3023 &mut bindings,
3024 AdapterFamily::Hook,
3025 spec.capabilities.adapters.hook.as_ref(),
3026 )?;
3027 push_declared_binding(
3028 &mut bindings,
3029 AdapterFamily::ManagedHook,
3030 spec.capabilities.adapters.managed_hook.as_ref(),
3031 )?;
3032 push_declared_binding(
3033 &mut bindings,
3034 AdapterFamily::OneShot,
3035 pipe_transport
3036 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3037 .map(|transport| &transport.adapter),
3038 )?;
3039 push_declared_binding(
3040 &mut bindings,
3041 AdapterFamily::OneShot,
3042 spec.capabilities.adapters.one_shot.as_ref(),
3043 )?;
3044 push_declared_binding(
3045 &mut bindings,
3046 AdapterFamily::History,
3047 spec.capabilities.adapters.history.as_ref(),
3048 )?;
3049 push_declared_binding(
3050 &mut bindings,
3051 AdapterFamily::Resume,
3052 spec.capabilities.adapters.resume.as_ref(),
3053 )?;
3054 push_declared_binding(
3055 &mut bindings,
3056 AdapterFamily::SessionOptions,
3057 spec.capabilities.adapters.session_options.as_ref(),
3058 )?;
3059 push_declared_binding(
3060 &mut bindings,
3061 AdapterFamily::CapabilityProbe,
3062 spec.capabilities.adapters.capability_probe.as_ref(),
3063 )?;
3064 Ok(bindings)
3065}
3066
3067fn push_declared_binding<'a>(
3068 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3069 family: AdapterFamily,
3070 binding: Option<&'a AdapterBinding>,
3071) -> Result<(), NodeServerError> {
3072 let Some(binding) = binding else {
3073 return Ok(());
3074 };
3075 if let Some((_, existing)) = bindings
3076 .iter()
3077 .find(|(existing_family, _)| *existing_family == family)
3078 {
3079 if *existing == binding {
3080 return Ok(());
3081 }
3082 return Err(NodeServerError::ProviderContractManifest(format!(
3083 "provider declares conflicting {family:?} adapter bindings"
3084 )));
3085 }
3086 bindings.push((family, binding));
3087 Ok(())
3088}
3089
3090fn merge_durable_state(
3091 configured: &[WorkspaceConfig],
3092 mut loaded: LoadedNodeState,
3093) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3094 let mut workspaces = configured
3095 .iter()
3096 .cloned()
3097 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3098 .collect::<BTreeMap<_, _>>();
3099 let mut roots = workspaces
3100 .values()
3101 .map(|workspace| {
3102 (
3103 platform::root_identity(&workspace.canonical_root),
3104 workspace.workspace_id.clone(),
3105 )
3106 })
3107 .collect::<BTreeMap<_, _>>();
3108 let mut warning = loaded
3109 .warning
3110 .take()
3111 .map(|message| session_registry::sanitized_persistence_summary(&message));
3112
3113 for (workspace_id, canonical_root) in loaded.workspaces {
3114 if let Some(existing) = workspaces.get(&workspace_id) {
3115 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3116 return Err(NodeServerError::DurableState(io::Error::new(
3117 io::ErrorKind::InvalidData,
3118 DURABLE_STATE_CONFLICT_ERROR,
3119 )));
3120 }
3121 continue;
3122 }
3123 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3124 return Err(NodeServerError::DurableState(io::Error::new(
3125 io::ErrorKind::InvalidData,
3126 DURABLE_STATE_CONFLICT_ERROR,
3127 )));
3128 }
3129 if !Path::new(&canonical_root).is_dir() {
3130 warning.get_or_insert_with(|| {
3131 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3132 });
3133 continue;
3134 }
3135 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3136 NodeServerError::DurableState(io::Error::new(
3137 io::ErrorKind::InvalidData,
3138 DURABLE_STATE_CONFLICT_ERROR,
3139 ))
3140 })?;
3141 roots.insert(
3142 platform::root_identity(&workspace.canonical_root),
3143 workspace.workspace_id.clone(),
3144 );
3145 workspaces.insert(workspace_id, workspace);
3146 }
3147
3148 for record in &mut loaded.records {
3149 match workspaces.get(&record.workspace_id) {
3150 Some(workspace)
3151 if platform::roots_equal(
3152 &workspace.canonical_root,
3153 windows_path_text(&record.canonical_root),
3154 ) => {}
3155 Some(_) => {
3156 return Err(NodeServerError::DurableState(io::Error::new(
3157 io::ErrorKind::InvalidData,
3158 DURABLE_STATE_CONFLICT_ERROR,
3159 )));
3160 }
3161 None => {
3162 record.state = ManagedSessionState::Unavailable;
3163 record.active_session = None;
3164 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3165 }
3166 }
3167 }
3168 Ok((workspaces.into_values().collect(), loaded.records, warning))
3169}
3170
3171struct RejectedCommandReason {
3174 message: String,
3175 unsupported_transport: bool,
3182 expires_at: Instant,
3183}
3184
3185struct NodeShared {
3186 handle: Gate4AgentHandle,
3187 access_token: String,
3188 node_id: NodeId,
3189 incarnation_id: NodeIncarnationId,
3190 started_at_unix_ms: u64,
3191 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3192 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3193 managed_worktree_profiles:
3194 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3195 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3196 enabled_providers: Vec<AgentId>,
3197 provider_runtime_statuses: ProviderRuntimeStatuses,
3198 provider_runtime_status_updates:
3199 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3200 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3201 provider_contracts: Vec<ProviderContractSupport>,
3202 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3203 spawn_profiles: SpawnProfileRegistry,
3204 environment_profiles:
3205 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3206 environment_materialization_profiles:
3207 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3208 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3216 #[cfg(feature = "dig2-station-probe")]
3220 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3221 bundle_catalog: RwLock<BundleCatalog>,
3222 delivery_store: Mutex<Option<DeliveryStore>>,
3223 context_pack_store: Mutex<Option<ContextPackStore>>,
3224 context_catalog: RwLock<ContextPackCatalog>,
3225 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3226 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3227 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3228 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3229 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3231 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3233 m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3235 m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3237 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3239 #[cfg(unix)]
3242 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3243 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3244 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3245 managed_spawn_replays:
3246 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3247 controller: Mutex<Option<ControllerLease>>,
3248 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3251 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3253 history: Mutex<NodeEventHistory>,
3254 event_tx: broadcast::Sender<NodeEventEnvelope>,
3255 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3256 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3264 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3273 terminal_frame_watermarks:
3274 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3275 pty_input_wake: tokio::sync::Notify,
3277 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3280 next_connection_id: AtomicU64,
3281 next_instance_id: AtomicU64,
3282 next_command_id: AtomicU64,
3283 shutdown: AtomicBool,
3284 shutdown_notify: Notify,
3285 preauth_slots: Arc<Semaphore>,
3286 authenticated_slots: Arc<Semaphore>,
3287 inspection_slots: Arc<Semaphore>,
3288 mutation_gate: AsyncMutex<()>,
3289 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3290 session_records: Mutex<SessionRecords>,
3291 state_path: Option<PathBuf>,
3292 persistence_error: RwLock<Option<String>>,
3293 state_transaction: Mutex<()>,
3294 input_settle_timeout_ms: u64,
3295 drive_loop_profile: Mutex<DriveLoopProfiler>,
3299 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3305 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3317 native_session_gauge: AtomicUsize,
3324 connection_loop_iterations: AtomicU64,
3335 connection_events_sent: AtomicU64,
3336 rejected_commands_total: AtomicU64,
3345 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3365 drive_loop_iterations_total: AtomicU64,
3366 drive_loop_iterations_idle: AtomicU64,
3367 session_record_retention: SessionRecordRetentionConfig,
3371 retired_records_total: AtomicU64,
3377 #[cfg(feature = "fixture")]
3378 fixture_semantic_hook_policy: bool,
3379 #[cfg(feature = "fixture")]
3380 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3381}
3382
3383impl Drop for NodeShared {
3384 fn drop(&mut self) {
3385 SessionLifecycleHook::on_shutdown(self);
3386 }
3387}
3388
3389#[derive(Clone, Debug, Eq, PartialEq)]
3390struct SessionBinding {
3391 workspace_id: WorkspaceId,
3392 generation: SessionGeneration,
3393 runtime_policy: ProviderRuntimePolicy,
3394 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3395 record_id: Option<SessionRecordId>,
3396 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3397 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3398 bundle: Option<ResolvedBundleReceipt>,
3399 context: Option<ResolvedContextPackReceipt>,
3400 materialization_id: Option<MaterializationId>,
3401}
3402
3403#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3404enum SpawnRecordPolicy {
3405 ProviderIdentityOnly,
3406 Always,
3407}
3408
3409struct NativeEnvironmentSelectionGuard {
3410 control: Option<NativeLaunchProfileControl>,
3411 instance_id: AgentInstanceId,
3412}
3413
3414struct NativeInstanceOverlayGuard {
3415 control: Option<NativeLaunchProfileControl>,
3416 instance_id: AgentInstanceId,
3417}
3418
3419struct NativeHarnessMcpOverlayGuard {
3420 control: NativeLaunchProfileControl,
3421 instance_id: AgentInstanceId,
3422}
3423
3424impl NativeHarnessMcpOverlayGuard {
3425 fn retain(self) { std::mem::forget(self); }
3426}
3427
3428impl Drop for NativeHarnessMcpOverlayGuard {
3429 fn drop(&mut self) {
3430 self.control
3431 .clear_native_mcp_server_launch_overlay(self.instance_id);
3432 }
3433}
3434
3435impl NativeInstanceOverlayGuard {
3436 fn retain(mut self) {
3437 self.control = None;
3438 }
3439}
3440
3441impl Drop for NativeInstanceOverlayGuard {
3442 fn drop(&mut self) {
3443 if let Some(control) = self.control.as_ref() {
3444 control.clear_native_instance_launch_overlay(self.instance_id);
3445 }
3446 }
3447}
3448
3449enum PreparedNativeLaunchOverlay {
3450 Environment(NativeLaunchEnvironmentOverlay),
3451 Instance(NativeInstanceLaunchOverlay),
3452}
3453
3454impl NativeEnvironmentSelectionGuard {
3455 fn retain(mut self) {
3456 self.control = None;
3457 }
3458}
3459
3460impl Drop for NativeEnvironmentSelectionGuard {
3461 fn drop(&mut self) {
3462 if let Some(control) = self.control.as_ref() {
3463 control.clear_native_launch_profile_selection(self.instance_id);
3464 }
3465 }
3466}
3467
3468struct SessionMaterializationGuard<'a> {
3469 shared: &'a NodeShared,
3470 id: Option<MaterializationId>,
3471 provider_home: Option<PathBuf>,
3474}
3475
3476impl SessionMaterializationGuard<'_> {
3477 fn id(&self) -> Option<&MaterializationId> {
3478 self.id.as_ref()
3479 }
3480
3481 fn provider_home(&self) -> Option<&Path> {
3482 self.provider_home.as_deref()
3483 }
3484
3485 fn retain(mut self) {
3486 self.id = None;
3487 }
3488}
3489
3490impl Drop for SessionMaterializationGuard<'_> {
3491 fn drop(&mut self) {
3492 if let Some(id) = self.id.take() {
3493 let _ = self.shared.cleanup_materialization(&id);
3494 }
3495 }
3496}
3497
3498
3499trait SessionLifecycleHook {
3502 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3504 fn on_session_ended(&self, address: &SessionAddress);
3506 fn on_shutdown(&self);
3508 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3510 fn cleanup_session_materialization(
3512 &self,
3513 address: &SessionAddress,
3514 binding: &SessionBinding,
3515 ) -> Result<(), NodeFailure>;
3516}
3517
3518impl SessionLifecycleHook for NodeShared {
3519 fn on_session_ended(&self, address: &SessionAddress) {
3520 self.retire_session_grok_leader(address.session.instance_id);
3521 if !self.shutdown.load(Ordering::Acquire) {
3522 if let Some(file) = self.session_resume_journal() {
3523 crate::pty_resume::remove(&file, address.session.instance_id.0);
3524 }
3525 }
3526 if let Some(accounts) = self.m4a_accounts.as_ref() {
3527 let instance = address.session.instance_id;
3528 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3529 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3530 accounts.remove_session_file(nick);
3531 }
3532 }
3533 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3534 registry.revoke_session(address);
3535 }
3536 if let Some(control) = self.native_launch_profile_control.as_ref() {
3537 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3538 }
3539 }
3540
3541 fn on_shutdown(&self) {
3542 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3543 .into_values()
3544 .collect();
3545 for socket in leaders {
3546 crate::grok_leader::retire(&socket);
3547 }
3548 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3549 let instances = registry.shutdown();
3550 if let Some(control) = self.native_launch_profile_control.as_ref() {
3551 for instance_id in instances {
3552 control.clear_native_mcp_server_launch_overlay(instance_id);
3553 }
3554 }
3555 }
3556 }
3557
3558 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3559 if let Some(control) = self.native_launch_profile_control.as_ref() {
3560 control.clear_native_mcp_server_launch_overlay(instance_id);
3561 }
3562 }
3563
3564 fn cleanup_session_materialization(
3565 &self,
3566 address: &SessionAddress,
3567 binding: &SessionBinding,
3568 ) -> Result<(), NodeFailure> {
3569 self.cleanup_session_owned_materialization(address, binding)
3570 }
3571
3572 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3573 #[cfg(feature = "dig2-station-probe")]
3576 {
3577 crate::dig2_station_lease::release_lease_for_holder(
3578 &self.browser_station_leases,
3579 address.session.instance_id,
3580 );
3581 }
3582 if let Some(control) = self.native_launch_profile_control.as_ref() {
3583 control.clear_native_mcp_server_launch_overlay(
3584 address.session.instance_id,
3585 );
3586 }
3587 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3588 registry.revoke_session(address);
3589 }
3590 if removed
3591 .is_some_and(|binding| {
3592 binding.environment_profile.is_some()
3593 || binding.bundle.is_some()
3594 || binding.context.is_some()
3595 })
3596 {
3597 if let Some(control) = self.native_launch_profile_control.as_ref() {
3598 control.clear_native_launch_profile_selection(
3599 address.session.instance_id,
3600 );
3601 control.clear_native_instance_launch_overlay(
3602 address.session.instance_id,
3603 );
3604 }
3605 }
3606 }
3607}
3608
3609struct SpawnPrepareRequest<'r> {
3610 instance_id: AgentInstanceId,
3611 address: &'r SessionAddress,
3612 provider: &'r AgentId,
3613 mode: SessionMode,
3614 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3615 bundle: Option<&'r ResolvedBundleReceipt>,
3616 context: Option<&'r ResolvedContextPackReceipt>,
3617 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3618 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3619 approval_level: ApprovalLevel,
3620 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3621 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3622}
3623
3624struct PreparedSpawn<'a> {
3628 instance_overlay: Option<NativeInstanceOverlayGuard>,
3629 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3630 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3631 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3632 #[cfg(feature = "dig2-station-probe")]
3633 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3634 materialization_id: Option<MaterializationId>,
3635}
3636
3637impl PreparedSpawn<'_> {
3638 fn retain_session_guards(&mut self) {
3640 if let Some(selection) = self.environment_selection.take() {
3641 selection.retain();
3642 }
3643 if let Some(overlay) = self.instance_overlay.take() {
3644 overlay.retain();
3645 }
3646 if let Some(guard) = self.materialization_guard.take() {
3647 guard.retain();
3648 }
3649 }
3650
3651 fn retain_launch_guards(&mut self) {
3653 if let Some(overlay) = self.harness_mcp_overlay.take() {
3654 overlay.retain();
3655 }
3656 #[cfg(feature = "dig2-station-probe")]
3657 if let Some(guard) = self.browser_station_lease_guard.take() {
3658 guard.retain();
3659 }
3660 }
3661}
3662
3663trait SpawnPreparer {
3667 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3668 fn select_environment(
3670 &self,
3671 instance_id: AgentInstanceId,
3672 provider: &AgentId,
3673 mode: SessionMode,
3674 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3675 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3676 fn install_launch_overlay(
3678 &self,
3679 instance_id: AgentInstanceId,
3680 overlay: PreparedNativeLaunchOverlay,
3681 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3682}
3683
3684impl NodeShared {
3685 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3688 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3689 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3690 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3691 if let Some(file) = self.session_resume_journal() {
3692 crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3693 }
3694 key
3695 }
3696
3697 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3698 let accounts = self.m4a_accounts.as_ref()?;
3699 let nick = self.m4a_nick_of(instance_id)?;
3700 match accounts.session_workdir(root, provider.as_str(), &nick) {
3701 Ok(dir) => dir,
3702 Err(error) => {
3703 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3704 None
3705 }
3706 }
3707 }
3708
3709 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3710 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3711 }
3712
3713 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3716 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3717 for record in records.records.values_mut() {
3718 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3719 record.display_name = nick.to_owned();
3720 }
3721 }
3722 }
3723
3724 fn m4a_session_overlay(
3727 &self,
3728 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3729 m4a_provider: &str,
3730 provider: &AgentId,
3731 instance_id: AgentInstanceId,
3732 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3733 session_key: &str,
3734 mode: SessionMode,
3735 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3736 let session_key = session_key.to_owned();
3737 if mode == SessionMode::Acp {
3738 return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3739 }
3740 Ok( match accounts.provision(m4a_provider, &session_key) {
3741 Ok(account) => {
3742 crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3743 let mut identity = account.env_mutations();
3744 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3745 let merged = match environment_overlay {
3746 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3747 .map(PreparedNativeLaunchOverlay::Instance),
3748 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3749 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3750 }
3751 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3752 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3753 }
3754 }
3755 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3756 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3757 let mut merged = merged;
3758 match accounts.write_session_file(&account) {
3759 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3760 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3761 Ok(args) if !args.is_empty() => {
3762 merged = match merged {
3763 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3764 .append_extra_args(args)
3765 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3766 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3767 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3768 .into_instance_with_extra_args(args)
3769 .map(PreparedNativeLaunchOverlay::Instance)
3770 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3771 };
3772 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3773 }
3774 Ok(_) => {}
3775 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3776 },
3777 }
3778 self.m4a_live
3779 .lock()
3780 .unwrap_or_else(|p| p.into_inner())
3781 .insert(account.nick.clone(), (instance_id, provider.clone()));
3782 self.name_record_after_nick(instance_id, &account.nick);
3783 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3784 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3786 Some(merged)
3787 }
3788 Err(error) => {
3789 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3790 environment_overlay
3791 }
3792 })
3793 }
3794}
3795
3796impl NodeShared {
3797 fn m4a_acp_mcp_overlay(
3800 &self,
3801 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3802 m4a_provider: &str,
3803 provider: &AgentId,
3804 instance_id: AgentInstanceId,
3805 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3806 session_key: &str,
3807 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3808 let Some(control) = self.native_launch_profile_control.as_ref() else {
3809 tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3810 return Ok(environment_overlay);
3811 };
3812 let account = match accounts.provision(m4a_provider, session_key) {
3813 Ok(a) => a,
3814 Err(error) => {
3815 tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3816 return Ok(environment_overlay);
3817 }
3818 };
3819 let session_file = match accounts.write_session_file(&account) {
3820 Ok(p) => p,
3821 Err(error) => {
3822 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3823 return Ok(environment_overlay);
3824 }
3825 };
3826 let env_entries: Vec<(OsString, OsString)> = account
3827 .env_mutations()
3828 .into_iter()
3829 .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3830 .filter_map(|m| m.value.map(|v| (m.key, v)))
3831 .collect();
3832 let args: Vec<OsString> = vec![
3833 "--session-file".into(),
3834 session_file.into_os_string(),
3835 "mcp".into(),
3836 "--provider".into(),
3837 provider.as_str().into(),
3838 ];
3839 let overlay = NativeMcpServerLaunchOverlay::new(
3840 provider.clone(),
3841 crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3842 accounts.bin().as_os_str().to_os_string(),
3843 args,
3844 env_entries,
3845 Vec::new(),
3846 );
3847 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())) {
3848 Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3849 Err(error) => {
3850 tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3851 return Ok(environment_overlay);
3852 }
3853 }
3854 self.m4a_live
3855 .lock()
3856 .unwrap_or_else(|p| p.into_inner())
3857 .insert(account.nick.clone(), (instance_id, provider.clone()));
3858 self.name_record_after_nick(instance_id, &account.nick);
3859 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3860 Ok(environment_overlay)
3861 }
3862}
3863
3864impl SpawnPreparer for NodeShared {
3865 fn select_environment(
3866 &self,
3867 instance_id: AgentInstanceId,
3868 provider: &AgentId,
3869 mode: SessionMode,
3870 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3871 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3872 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3873 }
3874
3875 fn install_launch_overlay(
3876 &self,
3877 instance_id: AgentInstanceId,
3878 overlay: PreparedNativeLaunchOverlay,
3879 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3880 self.install_prepared_launch_overlay(instance_id, overlay)
3881 }
3882
3883 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3884 let SpawnPrepareRequest {
3885 instance_id,
3886 address,
3887 provider,
3888 mode,
3889 environment_profile,
3890 bundle,
3891 context,
3892 managed_authority,
3893 harness_mcp,
3894 approval_level,
3895 network_allowlist,
3896 browser_profile_id,
3897 } = request;
3898 #[cfg(feature = "dig2-station-probe")]
3903 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3904 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3905 match acquire_lease(
3906 &self.browser_station_leases,
3907 profile_id,
3908 instance_id,
3909 unix_time_ms(),
3910 ) {
3911 Ok(guard) => Some(guard),
3912 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3913 return Err(failure(
3914 NodeFailureCode::BrowserStationProfileBusy,
3915 "dig2browser station profile is already leased by another session",
3916 ));
3917 }
3918 Err(BrowserStationLeaseError::CapacityExceeded) => {
3919 return Err(failure(
3920 NodeFailureCode::BrowserStationProfileBusy,
3921 "dig2browser station profile lease capacity exhausted",
3922 ));
3923 }
3924 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3925 return Err(failure(
3926 NodeFailureCode::BrowserStationProfileBusy,
3927 "session already holds a dig2browser station profile lease",
3928 ));
3929 }
3930 }
3931 } else {
3932 None
3933 };
3934 #[cfg(not(feature = "dig2-station-probe"))]
3935 let _ = browser_profile_id;
3936 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3937 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3946 {
3947 return Err(failure(
3948 NodeFailureCode::BindingMismatch,
3949 "harness MCP reservation does not match the exact PTY/ACP provider",
3950 ));
3951 }
3952 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3953 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3954 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3955 })?;
3956 let launch = prepared.launch();
3961 let mut env_entries = vec![
3962 (
3963 OsString::from(launch.endpoint_env.as_str()),
3964 prepared.endpoint().as_os_str().to_os_string(),
3965 ),
3966 (
3967 OsString::from(launch.token_env.as_str()),
3968 prepared.token().expose().into(),
3969 ),
3970 (
3971 OsString::from(launch.program_env.as_str()),
3972 prepared.helper_program().as_os_str().to_os_string(),
3973 ),
3974 ];
3975 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3976 env_entries.push(trace_entry);
3977 }
3978 if let Some(door) = crate::door::door_listen_addr() {
3979 env_entries.push((
3980 OsString::from("HATCHERY_NODE_DOOR"),
3981 OsString::from(door.to_string()),
3982 ));
3983 let session_key = format!(
3987 "pty-{}-{}-{}",
3988 self.incarnation_id,
3989 address.workspace_id.as_str(),
3990 address.session.instance_id.0
3991 );
3992 env_entries.push((
3993 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3994 OsString::from(session_key),
3995 ));
3996 }
3997 let overlay = NativeMcpServerLaunchOverlay::new(
3998 provider.clone(),
3999 launch.server_name.as_str(),
4000 prepared.helper_program().as_os_str().to_os_string(),
4001 launch.args.iter().map(OsString::from).collect(),
4002 env_entries,
4003 launch.scrub_env.iter().map(OsString::from).collect(),
4004 ).map_err(|_| failure(
4005 NodeFailureCode::HarnessMcpUnavailable,
4006 "harness MCP launch overlay is unavailable",
4007 ))?;
4008 control
4009 .install_native_mcp_server_launch_overlay(instance_id, overlay)
4010 .map_err(|_| failure(
4011 NodeFailureCode::HarnessMcpUnavailable,
4012 "harness MCP launch overlay could not be installed",
4013 ))?;
4014 tracing::info!(
4020 node_id = %self.node_id,
4021 instance_id = ?instance_id,
4022 provider = %provider,
4023 mode = ?mode,
4024 "harness MCP overlay installed for spawn",
4025 );
4026 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4027 } else {
4028 None
4029 };
4030 let prepared_materialization = self.prepare_session_materialization(
4031 &address,
4032 &provider,
4033 mode,
4034 environment_profile,
4035 bundle,
4036 context,
4037 managed_authority.cloned(),
4038 )?;
4039 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4040 Some((overlay, guard)) => (Some(overlay), Some(guard)),
4041 None => (None, None),
4042 };
4043 let materialization_id = materialization_guard
4044 .as_ref()
4045 .and_then(SessionMaterializationGuard::id)
4046 .cloned();
4047 let mut environment_selection = SpawnPreparer::select_environment(self,
4048 instance_id,
4049 &provider,
4050 mode,
4051 environment_profile,
4052 )?;
4053 let provider_home = materialization_guard
4056 .as_ref()
4057 .and_then(SessionMaterializationGuard::provider_home);
4058 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4059 &provider,
4060 mode,
4061 approval_level,
4062 network_allowlist,
4063 provider_home,
4064 )?;
4065 let mut network_overlay_args = network_overlay_args;
4066 {
4068 let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4069 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4070 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4071 homes.extend(std::env::split_paths(&extra));
4072 }
4073 if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4074 crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4075 network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4076 }
4077 if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4078 crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4079 }
4080 if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4082 if let Some(file) = self.session_resume_journal() {
4083 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4084 instance_id: instance_id.0,
4085 provider: provider.as_str().to_owned(),
4086 workspace_id: address.workspace_id.as_str().to_owned(),
4087 provider_session_id: String::new(),
4088 session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4089 .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4090 .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4091 cwd: String::new(),
4092 acp: mode == SessionMode::Acp,
4093 options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4094 cols: 0,
4095 rows: 0,
4096 });
4097 tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4098 }
4099 }
4100 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4101 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4102 }
4103 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4104 (prepared, args) if args.is_empty() => prepared,
4105 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4106 overlay.append_extra_args(args).map_err(|_| {
4107 failure(
4108 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4109 "network allowlist overlay could not merge with instance launch args",
4110 )
4111 })?;
4112 Some(PreparedNativeLaunchOverlay::Instance(overlay))
4113 }
4114 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4115 Some(PreparedNativeLaunchOverlay::Instance(
4116 overlay
4117 .into_instance_with_extra_args(args)
4118 .map_err(|_| {
4119 failure(
4120 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4121 "network allowlist overlay could not merge with environment overlay",
4122 )
4123 })?,
4124 ))
4125 }
4126 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4127 NativeInstanceLaunchOverlay::new(
4128 provider.clone(),
4129 match mode {
4130 SessionMode::Pty => TransportKind::Pty,
4131 SessionMode::Acp => TransportKind::Acp,
4132 SessionMode::Inline => TransportKind::Pipe,
4133 },
4134 Vec::new(),
4135 args,
4136 )
4137 .map_err(|_| {
4138 failure(
4139 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4140 "network allowlist overlay is invalid for this transport",
4141 )
4142 })?,
4143 )),
4144 };
4145 #[cfg(unix)]
4148 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4150 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4151 });
4152 #[cfg(unix)]
4153 let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4156 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4157 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4158 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4159 let uid = unsafe { libc_geteuid() };
4160 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4161 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4162 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4163 Some(vec![
4164 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4165 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4166 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4167 ])
4168 }
4169 _ => None,
4170 };
4171 #[cfg(not(unix))]
4172 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4173 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4174 (SessionMode::Pty, _, _, Some(identity)) => {
4175 let merged = match environment_overlay {
4176 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4177 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4178 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4179 }
4180 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4181 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4182 }
4183 }
4184 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4185 Some(merged)
4186 }
4187 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4188 let session_key = self.m4a_session_key(instance_id);
4189 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4190 }
4191 (SessionMode::Pty, Some(_), None, _) => {
4192 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4193 environment_overlay
4194 }
4195 _ => environment_overlay,
4196 };
4197 let mut instance_overlay = environment_overlay
4198 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4199 .transpose()?
4200 .flatten();
4201 Ok(PreparedSpawn {
4202 instance_overlay,
4203 environment_selection,
4204 materialization_guard,
4205 harness_mcp_overlay,
4206 #[cfg(feature = "dig2-station-probe")]
4207 browser_station_lease_guard,
4208 materialization_id,
4209 })
4210 }
4211}
4212
4213struct SessionRecords {
4214 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4215}
4216
4217struct SpawnIdempotencyCache {
4218 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4219}
4220
4221struct SpawnIdempotencyEntry {
4222 value: SpawnIdempotencyValue,
4223 expires_at: Instant,
4224}
4225
4226enum SpawnIdempotencyValue {
4227 Standard {
4228 spec: SpawnSpec,
4229 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4230 },
4231 Managed {
4232 request: ManagedWorktreeSpawnRequest,
4233 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4234 },
4235 HarnessMcp,
4236}
4237
4238enum DurableManagedSpawnReplayDecision {
4239 Reserved,
4240 PendingLinked,
4241 Committed(ManagedWorktreeSpawnReceipt),
4242}
4243
4244impl SpawnIdempotencyValue {
4245 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4246 match self {
4247 Self::Standard { result, .. } => result
4248 .as_ref()
4249 .ok()
4250 .and_then(|receipt| receipt.context.as_ref()),
4251 Self::Managed { result, .. } => result
4252 .as_ref()
4253 .ok()
4254 .and_then(|receipt| receipt.spawn.context.as_ref()),
4255 Self::HarnessMcp => None,
4256 }
4257 .is_some_and(|receipt| &receipt.id == context_id)
4258 }
4259}
4260
4261#[derive(Clone, Copy)]
4262struct ControllerLease {
4263 connection_id: u64,
4264 expires_at: Instant,
4265}
4266
4267struct NodeEventHistory {
4268 last_sequence: u64,
4269 replay_floor_sequence: u64,
4270 events: VecDeque<NodeEventEnvelope>,
4271 record_providers: BTreeMap<SessionRecordId, AgentId>,
4272 removed_record_providers: BTreeMap<u64, AgentId>,
4273}
4274
4275impl NodeEventHistory {
4276 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4277 Self {
4278 last_sequence: 0,
4279 replay_floor_sequence: 1,
4280 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4281 record_providers,
4282 removed_record_providers: BTreeMap::new(),
4283 }
4284 }
4285}
4286
4287impl NodeShared {
4288 fn session_resume_journal(&self) -> Option<PathBuf> {
4292 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4293 }
4294
4295 async fn restore_journalled_sessions(&self) {
4298 let Some(file) = self.session_resume_journal() else { return };
4299 if !crate::pty_resume::enabled() { return; }
4300 for e in crate::pty_resume::load(&file) {
4301 crate::pty_resume::remove(&file, e.instance_id);
4302 if !e.session_key.is_empty() {
4304 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4305 }
4306 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4307 if e.acp {
4308 let record_id = {
4309 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4310 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())
4311 };
4312 let cur = self.next_instance_id.load(Ordering::Acquire);
4313 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4314 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4315 let resumed = match record_id {
4316 Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4317 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4318 };
4319 let r = match resumed {
4321 Ok(a) => Ok(a),
4322 Err(err) => {
4323 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4324 self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4325 SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4326 }
4327 };
4328 match r {
4329 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4330 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4331 }
4332 continue;
4333 }
4334 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4335 let cur = self.next_instance_id.load(Ordering::Acquire);
4337 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4338 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4339 match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4340 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4341 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4342 }
4343 continue;
4344 }
4345 let folder = std::path::Path::new(&e.cwd);
4346 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4347 let name = match (in_session_dir, folder.file_name()) {
4348 (true, Some(n)) => n.to_string_lossy().into_owned(),
4349 _ => format!("{} #{}", e.provider, e.instance_id),
4350 };
4351 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4352 let record = match self.index_provider_session(ws, provider, identity, name) {
4353 Ok(r) => r,
4354 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4355 };
4356 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: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4359 let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4360 match r {
4361 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4362 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4363 }
4364 }
4365 }
4366
4367 fn refresh_session_journal(&self) {
4368 let Some(file) = self.session_resume_journal() else { return };
4369 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4370 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4371 crate::pty_resume::refresh(&file, &homes);
4372 let ids: Vec<(u64, String)> = {
4373 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4374 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4375 };
4376 for (inst, sid) in ids {
4377 crate::pty_resume::set_session_id(&file, inst, &sid);
4378 }
4379 }
4380
4381 fn session_grok_leader_socket(
4382 &self,
4383 provider: &AgentId,
4384 mode: SessionMode,
4385 instance_id: AgentInstanceId,
4386 ) -> Option<std::path::PathBuf> {
4387 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4388 return None;
4389 }
4390 let root = self
4391 .m4a_accounts
4392 .as_ref()
4393 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4394 .unwrap_or_else(std::env::temp_dir);
4395 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4396 if let Some(previous) = self
4397 .grok_leaders
4398 .lock()
4399 .unwrap_or_else(|p| p.into_inner())
4400 .insert(instance_id, socket.clone())
4401 {
4402 crate::grok_leader::retire(&previous);
4403 }
4404 Some(socket)
4405 }
4406
4407 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4409 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4410 if let Some(socket) = socket {
4411 crate::grok_leader::retire(&socket);
4412 }
4413 }
4414
4415 fn new_with_incarnation(
4416 handle: Gate4AgentHandle,
4417 access_token: String,
4418 node_id: NodeId,
4419 incarnation_id: NodeIncarnationId,
4420 workspaces: Vec<WorkspaceConfig>,
4421 enabled_providers: Vec<AgentId>,
4422 provider_runtime_statuses: ProviderRuntimeStatuses,
4423 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4424 provider_contracts: Vec<ProviderContractSupport>,
4425 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4426 spawn_profiles: SpawnProfileRegistry,
4427 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4428 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4429 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4430 state_path: Option<PathBuf>,
4431 records: Vec<ManagedSessionRecord>,
4432 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4433 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4434 materializations: Vec<MaterializationOwnershipRecord>,
4435 persistence_warning: Option<String>,
4436 input_settle_timeout_ms: u64,
4437 ) -> Self {
4438 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4439 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4440 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4441 let (harness_mcp_proxy_event_tx, _) =
4442 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4443 let record_providers = records
4444 .iter()
4445 .map(|record| (record.record_id.clone(), record.provider.clone()))
4446 .collect();
4447 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4448 .iter()
4449 .map(|workspace| {
4450 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4451 })
4452 .collect();
4453 for lease in &managed_worktrees {
4454 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4455 }
4456 let managed_worktree_profiles = workspaces.iter()
4457 .map(|workspace| (
4458 workspace.workspace_id.clone(),
4459 workspace.managed_worktree_profiles.clone(),
4460 ))
4461 .collect();
4462 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4463 managed_worktrees,
4464 managed_worktree_tombstones,
4465 ).expect("durable managed worktree registry was validated while loading");
4466 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4467 managed_worktrees.reattach_record_holders(
4468 &managed_worktree_record_holders(&records),
4469 unix_time_ms(),
4470 );
4471 Self {
4472 handle,
4473 access_token,
4474 node_id,
4475 incarnation_id,
4476 started_at_unix_ms: std::time::SystemTime::now()
4477 .duration_since(std::time::UNIX_EPOCH)
4478 .unwrap_or_default()
4479 .as_millis()
4480 .min(u64::MAX as u128) as u64,
4481 workspaces: RwLock::new(
4482 workspaces
4483 .into_iter()
4484 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4485 .collect(),
4486 ),
4487 worktree_service_modes,
4488 managed_worktree_profiles,
4489 managed_worktrees: Mutex::new(managed_worktrees),
4490 enabled_providers,
4491 provider_runtime_statuses,
4492 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4493 provider_runtime_monitor,
4494 provider_contracts,
4495 provider_adapter_contracts,
4496 spawn_profiles,
4497 environment_profiles: RwLock::new(BTreeMap::new()),
4498 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4499 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4500 #[cfg(feature = "dig2-station-probe")]
4501 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4502 bundle_catalog: RwLock::new(BundleCatalog::default()),
4503 delivery_store: Mutex::new(None),
4504 context_pack_store: Mutex::new(None),
4505 context_catalog: RwLock::new(ContextPackCatalog::default()),
4506 native_launch_profile_control,
4507 harness_mcp_registry: None,
4508 native_session_catalog,
4509 session_environment_materializer,
4510 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4511 m4a_live: Mutex::new(BTreeMap::new()),
4512 m4a_keys: Mutex::new(BTreeMap::new()),
4513 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4514 grok_leaders: Mutex::new(BTreeMap::new()),
4515 #[cfg(unix)]
4516 signer_proxy: std::sync::OnceLock::new(),
4517 materializations: Mutex::new(
4518 materializations
4519 .into_iter()
4520 .map(|record| (record.id().clone(), record))
4521 .collect(),
4522 ),
4523 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4524 entries: BTreeMap::new(),
4525 }),
4526 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4527 controller: Mutex::new(None),
4528 provisional_spawns: Mutex::new(BTreeMap::new()),
4529 pending_acp_options: Mutex::new(BTreeMap::new()),
4530 history: Mutex::new(NodeEventHistory::new(record_providers)),
4531 event_tx,
4532 terminal_event_tx,
4533 agent_stream_event_tx,
4534 harness_mcp_proxy_event_tx,
4535 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4536 pty_input_wake: tokio::sync::Notify::new(),
4537 pty_input_marks: Mutex::new(BTreeMap::new()),
4538 next_connection_id: AtomicU64::new(1),
4539 next_instance_id: AtomicU64::new(1),
4540 next_command_id: AtomicU64::new(1),
4541 shutdown: AtomicBool::new(false),
4542 shutdown_notify: Notify::new(),
4543 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4544 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4545 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4546 mutation_gate: AsyncMutex::new(()),
4547 session_bindings: Mutex::new(BTreeMap::new()),
4548 session_records: Mutex::new(SessionRecords {
4549 records: records
4550 .into_iter()
4551 .map(|record| (record.record_id.clone(), record))
4552 .collect(),
4553 }),
4554 state_path,
4555 persistence_error: RwLock::new(
4556 persistence_warning
4557 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4558 ),
4559 state_transaction: Mutex::new(()),
4560 input_settle_timeout_ms,
4561 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4562 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4563 shell_efficiency_profile: OnceLock::new(),
4564 native_session_gauge: AtomicUsize::new(0),
4565 connection_loop_iterations: AtomicU64::new(0),
4566 connection_events_sent: AtomicU64::new(0),
4567 rejected_commands_total: AtomicU64::new(0),
4568 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4569 drive_loop_iterations_total: AtomicU64::new(0),
4570 drive_loop_iterations_idle: AtomicU64::new(0),
4571 session_record_retention: SessionRecordRetentionConfig::default(),
4572 retired_records_total: AtomicU64::new(0),
4573 #[cfg(feature = "fixture")]
4574 fixture_semantic_hook_policy: false,
4575 #[cfg(feature = "fixture")]
4576 fixture_spawn_managed_worktree_v2_failure_probe:
4577 SpawnManagedWorktreeV2FailureProbe::default(),
4578 }
4579 }
4580
4581 #[cfg(test)]
4582 fn new(
4583 handle: Gate4AgentHandle,
4584 access_token: String,
4585 node_id: NodeId,
4586 workspaces: Vec<WorkspaceConfig>,
4587 enabled_providers: Vec<AgentId>,
4588 ) -> Self {
4589 Self::new_with_incarnation(
4590 handle,
4591 access_token,
4592 node_id,
4593 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4594 workspaces,
4595 enabled_providers,
4596 ProviderRuntimeStatuses::default(),
4597 None,
4598 Vec::new(),
4599 Vec::new(),
4600 SpawnProfileRegistry::default(),
4601 None,
4602 None,
4603 None,
4604 None,
4605 Vec::new(),
4606 Vec::new(),
4607 Vec::new(),
4608 Vec::new(),
4609 None,
4610 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4611 )
4612 }
4613
4614 fn persistence_error(&self) -> Option<String> {
4615 self.persistence_error
4616 .read()
4617 .unwrap_or_else(|poisoned| poisoned.into_inner())
4618 .clone()
4619 }
4620
4621 async fn admit_provider_runtime(
4622 &self,
4623 provider: &AgentId,
4624 requirement: ProviderRuntimeRequirement,
4625 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4626 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4627 let pipe_transport = monitor.supports_pipe_transport(provider);
4628 let acp_transport = monitor.supports_acp_transport(provider);
4629 let provider = provider.clone();
4630 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4631 let (status, admission) = timeout(
4632 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4633 refresh,
4634 )
4635 .await
4636 .map_err(|_| failure(
4637 NodeFailureCode::BackendBusy,
4638 "provider runtime probe exceeded its bounded deadline",
4639 ))?
4640 .map_err(|_| failure(
4641 NodeFailureCode::BackendOperationFailed,
4642 "provider runtime probe task failed",
4643 ))?;
4644 if let Some(status) = status {
4645 self
4646 .provider_runtime_status_updates
4647 .write()
4648 .unwrap_or_else(|poisoned| poisoned.into_inner())
4649 .insert(status.provider().clone(), status);
4650 }
4651 admission.and_then(|policy| {
4652 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4653 })
4654 } else {
4655 crate::provider_runtime::admit_status(
4661 &self.provider_runtime_statuses,
4662 provider,
4663 false,
4664 false,
4665 requirement,
4666 )
4667 };
4668 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4669 admission.map_err(|error| match error {
4670 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4671 NodeFailureCode::BackendOperationFailed,
4672 "provider launcher is unavailable",
4673 ),
4674 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4675 NodeFailureCode::UnsupportedCapability,
4676 "provider semantic capability is not verified",
4677 ),
4678 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4679 NodeFailureCode::BackendBusy,
4680 "provider runtime probe is already in progress",
4681 ),
4682 })
4683 }
4684
4685 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4691 match self.provider_runtime_monitor.as_deref() {
4692 Some(monitor) => monitor.supports_pipe_transport(provider),
4693 None => false,
4694 }
4695 }
4696
4697 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4703 match self.provider_runtime_monitor.as_deref() {
4704 Some(monitor) => monitor.supports_acp_transport(provider),
4705 None => false,
4706 }
4707 }
4708
4709 fn admit_declared_hook_semantics(
4725 &self,
4726 provider: &AgentId,
4727 policy: ProviderRuntimePolicy,
4728 ) -> ProviderRuntimePolicy {
4729 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4730 return policy;
4731 }
4732 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4733 contract.provider == *provider && contract.family == AdapterFamily::Hook
4734 });
4735 if !declares_hook {
4736 return policy;
4737 }
4738 ProviderRuntimePolicy::new(
4739 policy.raw_pty_lifecycle,
4740 policy.semantic_readiness,
4741 policy.structured_prompt,
4742 policy.provider_session_identity,
4743 policy.semantic_resume,
4744 true,
4745 )
4746 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4747 }
4748
4749 fn resolve_network_allowlist_overlay_args(
4764 &self,
4765 provider: &AgentId,
4766 mode: SessionMode,
4767 approval_level: ApprovalLevel,
4768 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4769 provider_home: Option<&Path>,
4770 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4771 let Some(allowlist_id) = network_allowlist else {
4772 return Ok(Vec::new());
4773 };
4774 let catalog = self
4775 .network_allowlist_catalog
4776 .read()
4777 .unwrap_or_else(|poisoned| poisoned.into_inner());
4778 let Some(entry) = catalog.get(allowlist_id) else {
4779 return Err(failure(
4780 NodeFailureCode::UnknownNetworkAllowlist,
4781 "spawn network allowlist is unavailable on this node",
4782 ));
4783 };
4784
4785 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4787 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4788 |message| {
4789 failure(
4790 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4791 &message,
4792 )
4793 },
4794 )?;
4795 if !matches!(mode, SessionMode::Pty) {
4796 return Err(failure(
4797 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4798 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4799 ));
4800 }
4801 let Some(home) = provider_home else {
4802 return Err(failure(
4803 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4804 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4805 ));
4806 };
4807 let domains =
4808 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4809 if domains.is_empty() {
4810 return Err(failure(
4811 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4812 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4813 ));
4814 }
4815 let settings_path = home.join(
4816 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4817 );
4818 let json =
4819 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4820 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4821 |_| {
4822 failure(
4823 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4824 "Claude station network settings sidecar could not be written under ProviderHome",
4825 )
4826 },
4827 )?;
4828 let overlay =
4829 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4830 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4831 }
4832
4833 let Some(native) = entry.provider_native.as_ref() else {
4834 return Ok(Vec::new());
4835 };
4836 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4837 provider.as_str(),
4838 approval_level,
4839 native,
4840 )
4841 .map_err(|message| {
4842 failure(
4843 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4844 &message,
4845 )
4846 })?;
4847 if overlay.is_empty() {
4848 return Ok(Vec::new());
4849 }
4850 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4851 return Err(failure(
4852 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4853 "Codex network_access -c overlay requires PTY or ACP transport",
4854 ));
4855 }
4856 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4857 }
4858
4859 fn resolve_codex_network_access_overlay_args(
4861 &self,
4862 provider: &AgentId,
4863 mode: SessionMode,
4864 approval_level: ApprovalLevel,
4865 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4866 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4867 self.resolve_network_allowlist_overlay_args(
4868 provider,
4869 mode,
4870 approval_level,
4871 network_allowlist,
4872 None,
4873 )
4874 }
4875
4876 fn resolve_environment_profile(
4877 &self,
4878 resolved: &ResolvedSpawnSpec,
4879 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4880 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4886 let catalog = self
4887 .network_allowlist_catalog
4888 .read()
4889 .unwrap_or_else(|poisoned| poisoned.into_inner());
4890 let Some(entry) = catalog.get(allowlist_id) else {
4891 return Err(failure(
4892 NodeFailureCode::UnknownNetworkAllowlist,
4893 "spawn network allowlist is unavailable on this node",
4894 ));
4895 };
4896 if let Some(native) = entry.provider_native.as_ref() {
4899 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4900 resolved.provider.as_str(),
4901 resolved.approval_level,
4902 native,
4903 ) {
4904 return Err(failure(
4905 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4906 &message,
4907 ));
4908 }
4909 }
4910 }
4911 #[cfg(feature = "dig2-station-probe")]
4920 if resolved.browser_profile_id.is_some() {
4921 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4922 match probe_station_reachable() {
4923 Ok(()) => {}
4924 Err(StationProbeError::UnsupportedPlatform)
4925 | Err(StationProbeError::InvalidPipeSuffix) => {
4926 return Err(failure(
4927 NodeFailureCode::BrowserStationProbeUnavailable,
4928 "dig2browser station probe unavailable on this platform",
4929 ));
4930 }
4931 Err(StationProbeError::Unreachable) => {
4932 return Err(failure(
4933 NodeFailureCode::BrowserStationUnreachable,
4934 "dig2browser station named-pipe is unreachable",
4935 ));
4936 }
4937 }
4938 }
4939 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4940 return Ok(None);
4941 };
4942 let profiles = self
4943 .environment_profiles
4944 .read()
4945 .unwrap_or_else(|poisoned| poisoned.into_inner());
4946 let binding = profiles.get(profile_id).ok_or_else(|| {
4947 failure(
4948 NodeFailureCode::UnknownEnvironmentProfile,
4949 "spawn environment profile is unavailable on this node",
4950 )
4951 })?;
4952 if binding.provider != resolved.provider
4953 || binding.native_profile_id(resolved.mode).is_none()
4954 {
4955 return Err(failure(
4956 NodeFailureCode::EnvironmentProfileBindingMismatch,
4957 "spawn environment profile does not match the provider and mode",
4958 ));
4959 }
4960 Ok(Some(ResolvedEnvironmentProfileReceipt {
4964 profile_id: binding.id.clone(),
4965 profile_revision: binding.revision.clone(),
4966 network_allowlist: resolved.network_allowlist.clone(),
4967 browser_profile_id: resolved.browser_profile_id.clone(),
4968 }))
4969 }
4970
4971 fn resolve_bundle(
4972 &self,
4973 resolved: &ResolvedSpawnSpec,
4974 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4975 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4976 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4977 return Ok(None);
4978 };
4979 let catalog = self.bundle_catalog
4980 .read()
4981 .unwrap_or_else(|poisoned| poisoned.into_inner());
4982 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4983 failure(
4984 NodeFailureCode::UnknownBundle,
4985 "spawn bundle is unavailable on this node",
4986 )
4987 })?;
4988 self.bundle_layout(
4989 &resolved.provider,
4990 resolved.mode,
4991 environment_profile,
4992 bundle,
4993 )?;
4994 Ok(Some(bundle.receipt()))
4995 }
4996
4997 fn begin_delivery_stage(
4998 &self,
4999 manifest: DeliveryBundleManifestV2,
5000 ) -> Result<NodeResponse, NodeFailure> {
5001 let mut store = self
5002 .delivery_store
5003 .lock()
5004 .unwrap_or_else(|poisoned| poisoned.into_inner());
5005 let store = store.as_mut().ok_or_else(|| {
5006 failure(
5007 NodeFailureCode::DeliveryStageStorageFailed,
5008 "node delivery store is unavailable",
5009 )
5010 })?;
5011 let begun = store.begin(manifest).map_err(delivery_failure)?;
5012 Ok(NodeResponse::DeliveryStageBegun {
5013 stage_id: begun.stage_id,
5014 manifest_digest: begun.manifest_digest,
5015 missing_blobs: begun.missing_blobs,
5016 })
5017 }
5018
5019 fn put_delivery_blob_chunk(
5020 &self,
5021 stage_id: DeliveryStageId,
5022 blob_digest: DeliveryBlobDigestV1,
5023 offset: u64,
5024 chunk_hex: DeliveryBlobChunkHexV1,
5025 ) -> Result<NodeResponse, NodeFailure> {
5026 let mut store = self
5027 .delivery_store
5028 .lock()
5029 .unwrap_or_else(|poisoned| poisoned.into_inner());
5030 let store = store.as_mut().ok_or_else(|| {
5031 failure(
5032 NodeFailureCode::DeliveryStageStorageFailed,
5033 "node delivery store is unavailable",
5034 )
5035 })?;
5036 let accepted = store
5037 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5038 .map_err(delivery_failure)?;
5039 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5040 stage_id: accepted.stage_id,
5041 blob_digest: accepted.blob_digest,
5042 next_offset: accepted.next_offset,
5043 })
5044 }
5045
5046 fn commit_delivery_stage(
5047 &self,
5048 stage_id: DeliveryStageId,
5049 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5050 let mut store = self
5051 .delivery_store
5052 .lock()
5053 .unwrap_or_else(|poisoned| poisoned.into_inner());
5054 let store = store.as_mut().ok_or_else(|| {
5055 failure(
5056 NodeFailureCode::DeliveryStageStorageFailed,
5057 "node delivery store is unavailable",
5058 )
5059 })?;
5060 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5061 let mut catalog = self
5062 .bundle_catalog
5063 .write()
5064 .unwrap_or_else(|poisoned| poisoned.into_inner());
5065 let mut next_catalog = catalog.clone();
5066 next_catalog
5067 .insert_idempotent(prepared.bundle().clone())
5068 .map_err(|_| {
5069 failure(
5070 NodeFailureCode::DeliveryStageConflict,
5071 "delivery bundle identity conflicts with installed content",
5072 )
5073 })?;
5074 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5075 *catalog = next_catalog;
5076 Ok(receipt)
5077 }
5078
5079 fn abort_delivery_stage(
5080 &self,
5081 stage_id: &DeliveryStageId,
5082 ) -> Result<(), NodeFailure> {
5083 let mut store = self
5084 .delivery_store
5085 .lock()
5086 .unwrap_or_else(|poisoned| poisoned.into_inner());
5087 let store = store.as_mut().ok_or_else(|| {
5088 failure(
5089 NodeFailureCode::DeliveryStageStorageFailed,
5090 "node delivery store is unavailable",
5091 )
5092 })?;
5093 store.abort(stage_id).map_err(delivery_failure)
5094 }
5095
5096 fn resolve_context(
5097 &self,
5098 resolved: &ResolvedSpawnSpec,
5099 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5100 let Some(context_id) = resolved.context_id.as_ref() else {
5101 return Ok(None);
5102 };
5103 self.context_catalog
5104 .read()
5105 .unwrap_or_else(|poisoned| poisoned.into_inner())
5106 .get(context_id)
5107 .map(|context| context.receipt().clone())
5108 .ok_or_else(|| {
5109 failure(
5110 NodeFailureCode::UnknownContextPack,
5111 "spawn context pack is unavailable on this node",
5112 )
5113 })
5114 .map(Some)
5115 }
5116
5117 fn bundle_layout(
5118 &self,
5119 provider: &AgentId,
5120 mode: SessionMode,
5121 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5122 bundle: &NodeBundle,
5123 ) -> Result<BundleProviderLayout, NodeFailure> {
5124 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5125 failure(
5126 NodeFailureCode::BundleBindingMismatch,
5127 "spawn bundle does not match the provider and mode",
5128 )
5129 })?;
5130 if layout != BundleProviderLayout::Codex {
5131 return Ok(layout);
5132 }
5133 let profile = environment_profile.and_then(|receipt| {
5134 let profiles = self
5135 .environment_profiles
5136 .read()
5137 .unwrap_or_else(|poisoned| poisoned.into_inner());
5138 let binding = profiles.get(&receipt.profile_id)?;
5139 if binding.revision != receipt.profile_revision
5140 || &binding.provider != provider
5141 || binding.native_profile_id(mode).is_none()
5142 {
5143 return None;
5144 }
5145 self.environment_materialization_profiles
5146 .read()
5147 .unwrap_or_else(|poisoned| poisoned.into_inner())
5148 .get(&receipt.profile_id)
5149 .cloned()
5150 });
5151 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5152 return Err(failure(
5153 NodeFailureCode::BundleBindingMismatch,
5154 "Codex bundle requires an exact isolated CODEX_HOME profile",
5155 ));
5156 }
5157 Ok(layout)
5158 }
5159
5160 fn select_environment_profile(
5161 &self,
5162 instance_id: AgentInstanceId,
5163 provider: &AgentId,
5164 mode: SessionMode,
5165 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5166 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5167 let Some(environment_profile) = environment_profile else {
5168 return Ok(None);
5169 };
5170 let native_profile_id = {
5171 let profiles = self
5172 .environment_profiles
5173 .read()
5174 .unwrap_or_else(|poisoned| poisoned.into_inner());
5175 let binding = profiles
5176 .get(&environment_profile.profile_id)
5177 .ok_or_else(|| {
5178 failure(
5179 NodeFailureCode::UnknownEnvironmentProfile,
5180 "session environment profile is unavailable on this node",
5181 )
5182 })?;
5183 if &binding.revision != &environment_profile.profile_revision
5184 || &binding.provider != provider
5185 {
5186 return Err(failure(
5187 NodeFailureCode::EnvironmentProfileBindingMismatch,
5188 "session environment profile revision or provider changed",
5189 ));
5190 }
5191 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5192 failure(
5193 NodeFailureCode::EnvironmentProfileBindingMismatch,
5194 "session environment profile does not support this mode",
5195 )
5196 })?
5197 };
5198 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5199 failure(
5200 NodeFailureCode::BackendOperationFailed,
5201 "native environment profile controller is unavailable",
5202 )
5203 })?;
5204 control
5205 .select_native_launch_profile(instance_id, native_profile_id)
5206 .map_err(|_| {
5207 failure(
5208 NodeFailureCode::BackendOperationFailed,
5209 "native environment profile selection failed",
5210 )
5211 })?;
5212 Ok(Some(NativeEnvironmentSelectionGuard {
5213 control: Some(control),
5214 instance_id,
5215 }))
5216 }
5217
5218 fn install_prepared_launch_overlay(
5219 &self,
5220 instance_id: AgentInstanceId,
5221 overlay: PreparedNativeLaunchOverlay,
5222 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5223 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5224 failure(
5225 NodeFailureCode::BackendOperationFailed,
5226 "native launch profile controller is unavailable",
5227 )
5228 })?;
5229 match overlay {
5230 PreparedNativeLaunchOverlay::Environment(overlay) => {
5231 control
5232 .install_native_launch_environment_overlay(instance_id, overlay)
5233 .map_err(|_| failure(
5234 NodeFailureCode::BackendOperationFailed,
5235 "session environment overlay installation failed",
5236 ))?;
5237 Ok(None)
5238 }
5239 PreparedNativeLaunchOverlay::Instance(overlay) => {
5240 control
5241 .install_native_instance_launch_overlay(instance_id, overlay)
5242 .map_err(|_| failure(
5243 NodeFailureCode::BundleBindingMismatch,
5244 "session bundle launch overlay installation failed",
5245 ))?;
5246 Ok(Some(NativeInstanceOverlayGuard {
5247 control: Some(control),
5248 instance_id,
5249 }))
5250 }
5251 }
5252 }
5253
5254 fn environment_materialization_profile(
5255 &self,
5256 environment_profile: &ResolvedEnvironmentProfileReceipt,
5257 ) -> Option<NodeSessionMaterializationProfile> {
5258 self.environment_materialization_profiles
5259 .read()
5260 .unwrap_or_else(|poisoned| poisoned.into_inner())
5261 .get(&environment_profile.profile_id)
5262 .cloned()
5263 }
5264
5265 fn materialization_profile(
5266 &self,
5267 provider: &AgentId,
5268 mode: SessionMode,
5269 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5270 bundle: Option<&ResolvedBundleReceipt>,
5271 context: Option<&ResolvedContextPackReceipt>,
5272 ) -> Result<Option<(
5273 NodeSessionMaterializationProfile,
5274 bool,
5275 Option<BundleProviderLayout>,
5276 )>, NodeFailure> {
5277 let environment = environment_profile
5278 .and_then(|receipt| self.environment_materialization_profile(receipt));
5279 let (bundle, bundle_layout) = match bundle {
5280 Some(receipt) => {
5281 let catalog = self.bundle_catalog.read()
5282 .unwrap_or_else(|poisoned| poisoned.into_inner());
5283 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5284 NodeFailureCode::UnknownBundle,
5285 "session bundle is unavailable on this node",
5286 ))?;
5287 if installed.receipt() != *receipt {
5288 return Err(failure(
5289 NodeFailureCode::BundleBindingMismatch,
5290 "session bundle revision or digest changed",
5291 ));
5292 }
5293 let layout = self.bundle_layout(
5294 provider,
5295 mode,
5296 environment_profile,
5297 installed,
5298 )?;
5299 (Some(installed.clone()), Some(layout))
5300 }
5301 None => (None, None),
5302 };
5303 let has_environment_overlay = environment.is_some() || context.is_some();
5304 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5305 (None, None, None) => None,
5306 (Some(profile), None, None) => Some(Ok(profile)),
5307 (None, Some(bundle), Some(layout)) => {
5308 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5309 }
5310 (Some(profile), Some(bundle), Some(layout)) => {
5311 Some(profile.with_bundle(bundle, layout))
5312 }
5313 _ => {
5314 return Err(failure(
5315 NodeFailureCode::BundleBindingMismatch,
5316 "session bundle provider layout is unavailable",
5317 ));
5318 }
5319 };
5320 let profile = profile
5321 .transpose()
5322 .map_err(|_| failure(
5323 NodeFailureCode::BundleMaterializationFailed,
5324 "session bundle materialization profile is invalid",
5325 ))?;
5326 let profile = match (profile, context) {
5327 (Some(profile), Some(receipt)) => {
5328 let catalog = self.context_catalog
5329 .read()
5330 .unwrap_or_else(|poisoned| poisoned.into_inner());
5331 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5332 NodeFailureCode::UnknownContextPack,
5333 "session context pack is unavailable on this node",
5334 ))?;
5335 if pack.receipt() != receipt {
5336 return Err(failure(
5337 NodeFailureCode::ContextPackMaterializationFailed,
5338 "session context pack identity changed",
5339 ));
5340 }
5341 profile.with_context(pack)
5342 }
5343 (None, Some(receipt)) => {
5344 let catalog = self.context_catalog
5345 .read()
5346 .unwrap_or_else(|poisoned| poisoned.into_inner());
5347 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5348 NodeFailureCode::UnknownContextPack,
5349 "session context pack is unavailable on this node",
5350 ))?;
5351 if pack.receipt() != receipt {
5352 return Err(failure(
5353 NodeFailureCode::ContextPackMaterializationFailed,
5354 "session context pack identity changed",
5355 ));
5356 }
5357 NodeSessionMaterializationProfile::from_context(pack)
5358 }
5359 (Some(profile), None) => Ok(profile),
5360 (None, None) => return Ok(None),
5361 }
5362 .map_err(|_| failure(
5363 NodeFailureCode::ContextPackMaterializationFailed,
5364 "session context pack materialization profile is invalid",
5365 ))?;
5366 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5367 }
5368
5369 fn prepare_session_materialization<'a>(
5370 &'a self,
5371 address: &SessionAddress,
5372 provider: &AgentId,
5373 mode: SessionMode,
5374 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5375 bundle: Option<&ResolvedBundleReceipt>,
5376 context: Option<&ResolvedContextPackReceipt>,
5377 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5378 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5379 let Some((profile, has_environment_overlay, bundle_layout)) =
5380 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5381 else {
5382 return Ok(None);
5383 };
5384 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5385 failure(
5386 NodeFailureCode::BackendOperationFailed,
5387 "session environment materializer is unavailable",
5388 )
5389 })?;
5390 let materialization_failure_code = if context.is_some() {
5391 NodeFailureCode::ContextPackMaterializationFailed
5392 } else if bundle.is_some() {
5393 NodeFailureCode::BundleMaterializationFailed
5394 } else {
5395 NodeFailureCode::BackendOperationFailed
5396 };
5397 let id = MaterializationId::new(format!(
5398 "mat-{}-{}-{}",
5399 self.incarnation_id,
5400 address.session.instance_id.0,
5401 address.session.generation.0,
5402 ))
5403 .map_err(|_| {
5404 failure(
5405 NodeFailureCode::BackendOperationFailed,
5406 "session environment materialization identity failed",
5407 )
5408 })?;
5409 let owner = MaterializationOwner::Session {
5410 incarnation_id: self.incarnation_id,
5411 instance_id: address.session.instance_id,
5412 generation: address.session.generation,
5413 };
5414 let mut ownership = materializer
5415 .begin(
5416 id.clone(),
5417 environment_profile.cloned(),
5418 bundle.cloned(),
5419 context.cloned(),
5420 owner,
5421 managed_lease_id,
5422 &profile,
5423 unix_time_ms(),
5424 )
5425 .map_err(|_| {
5426 failure(
5427 materialization_failure_code,
5428 "session materialization preparation failed",
5429 )
5430 })?;
5431 {
5432 let _transaction = self
5433 .state_transaction
5434 .lock()
5435 .unwrap_or_else(|poisoned| poisoned.into_inner());
5436 let duplicate = self
5437 .materializations
5438 .lock()
5439 .unwrap_or_else(|poisoned| poisoned.into_inner())
5440 .insert(id.clone(), ownership.clone());
5441 if duplicate.is_some() {
5442 return Err(failure(
5443 NodeFailureCode::BackendOperationFailed,
5444 "session environment materialization identity conflict",
5445 ));
5446 }
5447 if let Err(error) = self.persist_state_locked() {
5448 self.materializations
5449 .lock()
5450 .unwrap_or_else(|poisoned| poisoned.into_inner())
5451 .remove(&id);
5452 return Err(persistence_failure(error));
5453 }
5454 }
5455 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5456 Ok(environment) => environment,
5457 Err(_) => {
5458 let transaction = self
5459 .state_transaction
5460 .lock()
5461 .unwrap_or_else(|poisoned| poisoned.into_inner());
5462 self.materializations
5463 .lock()
5464 .unwrap_or_else(|poisoned| poisoned.into_inner())
5465 .insert(id.clone(), ownership.clone());
5466 if self.persist_state_locked().is_err() {
5467 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5468 }
5469 drop(transaction);
5470 if ownership.state() == MaterializationState::CleanupRequired {
5471 let _ = self.cleanup_materialization(&id);
5472 }
5473 return Err(failure(
5474 materialization_failure_code,
5475 "session materialization failed",
5476 ));
5477 }
5478 };
5479 {
5480 let _transaction = self
5481 .state_transaction
5482 .lock()
5483 .unwrap_or_else(|poisoned| poisoned.into_inner());
5484 self.materializations
5485 .lock()
5486 .unwrap_or_else(|poisoned| poisoned.into_inner())
5487 .insert(id.clone(), ownership.clone());
5488 if let Err(error) = self.persist_state_locked() {
5489 drop(_transaction);
5490 let _ = self.cleanup_materialization(&id);
5491 return Err(persistence_failure(error));
5492 }
5493 }
5494 let guard = SessionMaterializationGuard {
5495 shared: self,
5496 id: Some(id),
5497 provider_home: Some(ownership.provider_home().to_path_buf()),
5498 };
5499 let bundle_arguments = match bundle {
5500 Some(receipt) => {
5501 let catalog = self.bundle_catalog.read()
5502 .unwrap_or_else(|poisoned| poisoned.into_inner());
5503 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5504 NodeFailureCode::UnknownBundle,
5505 "session bundle is unavailable on this node",
5506 ))?;
5507 if installed.receipt() != *receipt {
5508 return Err(failure(
5509 NodeFailureCode::BundleBindingMismatch,
5510 "session bundle revision or digest changed",
5511 ));
5512 }
5513 let layout = bundle_layout.ok_or_else(|| failure(
5514 NodeFailureCode::BundleBindingMismatch,
5515 "materialized bundle provider layout is unavailable",
5516 ))?;
5517 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5518 failure(
5519 NodeFailureCode::BundleMaterializationFailed,
5520 "session bundle byte revalidation failed",
5521 )
5522 })?;
5523 Some(bundle_launch_arguments(
5524 layout,
5525 ownership.bundle_root(),
5526 ownership.provider_home(),
5527 ).map_err(|_| failure(
5528 NodeFailureCode::BundleBindingMismatch,
5529 "session bundle launch binding is invalid",
5530 ))?)
5531 }
5532 None => None,
5533 };
5534 if let Some(receipt) = context {
5535 materializer
5536 .revalidate_context(&ownership, receipt)
5537 .map_err(|_| failure(
5538 NodeFailureCode::ContextPackMaterializationFailed,
5539 "session context pack byte revalidation failed",
5540 ))?;
5541 }
5542 let transport = match mode {
5543 SessionMode::Pty => TransportKind::Pty,
5544 SessionMode::Inline => TransportKind::Pipe,
5545 SessionMode::Acp => TransportKind::Acp,
5546 };
5547 let overlay = if let Some(extra_args) = bundle_arguments {
5548 Some(PreparedNativeLaunchOverlay::Instance(
5549 NativeInstanceLaunchOverlay::new(
5550 provider.clone(),
5551 transport,
5552 if has_environment_overlay {
5553 environment
5554 } else {
5555 Vec::new()
5556 },
5557 extra_args,
5558 )
5559 .map_err(|_| {
5560 failure(
5561 NodeFailureCode::BundleBindingMismatch,
5562 "session bundle launch overlay is invalid",
5563 )
5564 })?,
5565 ))
5566 } else if has_environment_overlay {
5567 let context_only = environment_profile.is_none()
5568 && bundle.is_none()
5569 && context.is_some();
5570 let overlay = if context_only {
5571 NativeLaunchEnvironmentOverlay::new_context_root(
5572 provider.clone(),
5573 transport,
5574 environment,
5575 )
5576 .map_err(|_| {
5577 failure(
5578 NodeFailureCode::ContextPackMaterializationFailed,
5579 "context-only launch overlay is invalid",
5580 )
5581 })?
5582 } else {
5583 NativeLaunchEnvironmentOverlay::new(
5584 provider.clone(),
5585 transport,
5586 environment,
5587 )
5588 .map_err(|_| {
5589 failure(
5590 NodeFailureCode::BackendOperationFailed,
5591 "session environment overlay is invalid",
5592 )
5593 })?
5594 };
5595 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5596 } else {
5597 None
5598 };
5599 Ok(Some((overlay, guard)))
5600 }
5601
5602 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5603 let Some(mut ownership) = self
5604 .materializations
5605 .lock()
5606 .unwrap_or_else(|poisoned| poisoned.into_inner())
5607 .get(id)
5608 .cloned()
5609 else {
5610 return Ok(());
5611 };
5612 if ownership.state() != MaterializationState::CleanupRequired {
5613 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5614 failure(
5615 NodeFailureCode::BackendOperationFailed,
5616 "session environment cleanup state is invalid",
5617 )
5618 })?;
5619 }
5620 {
5621 let _transaction = self
5622 .state_transaction
5623 .lock()
5624 .unwrap_or_else(|poisoned| poisoned.into_inner());
5625 let previous = self
5626 .materializations
5627 .lock()
5628 .unwrap_or_else(|poisoned| poisoned.into_inner())
5629 .insert(id.clone(), ownership.clone());
5630 if let Err(error) = self.persist_state_locked() {
5631 let mut materializations = self
5632 .materializations
5633 .lock()
5634 .unwrap_or_else(|poisoned| poisoned.into_inner());
5635 if let Some(previous) = previous {
5636 materializations.insert(id.clone(), previous);
5637 }
5638 return Err(persistence_failure(error));
5639 }
5640 }
5641 let cleanup = self
5642 .session_environment_materializer
5643 .as_ref()
5644 .ok_or(())
5645 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5646 if cleanup.is_err() {
5647 let _ = ownership.mark_recovery_required(unix_time_ms());
5648 self.materializations
5649 .lock()
5650 .unwrap_or_else(|poisoned| poisoned.into_inner())
5651 .insert(id.clone(), ownership);
5652 if self.persist_state().is_err() {
5653 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5654 }
5655 return Err(failure(
5656 NodeFailureCode::BackendOperationFailed,
5657 "session environment cleanup requires recovery",
5658 ));
5659 }
5660 let _transaction = self
5661 .state_transaction
5662 .lock()
5663 .unwrap_or_else(|poisoned| poisoned.into_inner());
5664 self.materializations
5665 .lock()
5666 .unwrap_or_else(|poisoned| poisoned.into_inner())
5667 .remove(id);
5668 if let Err(error) = self.persist_state_locked() {
5669 self.materializations
5670 .lock()
5671 .unwrap_or_else(|poisoned| poisoned.into_inner())
5672 .insert(id.clone(), ownership);
5673 return Err(persistence_failure(error));
5674 }
5675 Ok(())
5676 }
5677
5678 fn resolve_record_materialization(
5679 &self,
5680 record_id: &SessionRecordId,
5681 provider: &AgentId,
5682 mode: SessionMode,
5683 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5684 bundle: Option<&ResolvedBundleReceipt>,
5685 context: Option<&ResolvedContextPackReceipt>,
5686 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5687 let ownership = {
5688 let materializations = self
5689 .materializations
5690 .lock()
5691 .unwrap_or_else(|poisoned| poisoned.into_inner());
5692 let mut matches = materializations.values().filter(|record| {
5693 record.owner()
5694 == &MaterializationOwner::Record {
5695 record_id: record_id.clone(),
5696 }
5697 });
5698 let ownership = matches.next().cloned();
5699 if matches.next().is_some() {
5700 return Err(failure(
5701 NodeFailureCode::BackendOperationFailed,
5702 "managed session has conflicting environment ownership",
5703 ));
5704 }
5705 ownership
5706 };
5707 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5708 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5709 failure(
5710 NodeFailureCode::ContextPackMaterializationFailed,
5711 "session context materializer is unavailable",
5712 )
5713 })?;
5714 let pack = materializer
5715 .revalidate_context(ownership, receipt)
5716 .map_err(|_| {
5717 self.mark_materialization_recovery_required(ownership.id());
5718 failure(
5719 NodeFailureCode::ContextPackMaterializationFailed,
5720 "managed session context pack revalidation failed",
5721 )
5722 })?;
5723 self.context_catalog
5724 .write()
5725 .unwrap_or_else(|poisoned| poisoned.into_inner())
5726 .insert(pack)
5727 .map_err(|_| failure(
5728 NodeFailureCode::ContextPackMaterializationFailed,
5729 "managed session context pack catalog restore failed",
5730 ))?;
5731 }
5732 let materialization = self.materialization_profile(
5733 provider,
5734 mode,
5735 environment_profile,
5736 bundle,
5737 context,
5738 )?;
5739 match (ownership, materialization) {
5740 (None, None) => Ok(None),
5741 (None, Some(_)) | (Some(_), None) => Err(failure(
5742 NodeFailureCode::BackendOperationFailed,
5743 "managed session materialization is unavailable",
5744 )),
5745 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5746 if ownership.environment_profile() != environment_profile
5747 || ownership.bundle() != bundle
5748 || ownership.context() != context
5749 || ownership.state() != MaterializationState::Ready
5750 {
5751 self.mark_materialization_recovery_required(ownership.id());
5752 return Err(failure(
5753 NodeFailureCode::BackendOperationFailed,
5754 "managed session environment ownership requires recovery",
5755 ));
5756 }
5757 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5758 failure(
5759 NodeFailureCode::BackendOperationFailed,
5760 "session environment materializer is unavailable",
5761 )
5762 })?;
5763 if materializer.revalidate(&ownership).is_err() {
5764 self.mark_materialization_recovery_required(ownership.id());
5765 return Err(failure(
5766 NodeFailureCode::BackendOperationFailed,
5767 "managed session environment revalidation failed",
5768 ));
5769 }
5770 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5771 let catalog = self.bundle_catalog.read()
5772 .unwrap_or_else(|poisoned| poisoned.into_inner());
5773 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5774 return Err(failure(
5775 NodeFailureCode::UnknownBundle,
5776 "managed session bundle is unavailable",
5777 ));
5778 };
5779 let layout = bundle_layout.ok_or_else(|| failure(
5780 NodeFailureCode::BundleBindingMismatch,
5781 "record bundle provider layout is unavailable",
5782 ))?;
5783 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5784 drop(catalog);
5785 self.mark_materialization_recovery_required(ownership.id());
5786 return Err(failure(
5787 NodeFailureCode::BundleMaterializationFailed,
5788 "managed session bundle revalidation failed",
5789 ));
5790 }
5791 Some(bundle_launch_arguments(
5792 layout,
5793 ownership.bundle_root(),
5794 ownership.provider_home(),
5795 ).map_err(|_| failure(
5796 NodeFailureCode::BundleBindingMismatch,
5797 "managed session bundle launch binding is invalid",
5798 ))?)
5799 } else {
5800 None
5801 };
5802 if !has_environment_materialization {
5803 let overlay = bundle_arguments.map(|extra_args| {
5804 NativeInstanceLaunchOverlay::new(
5805 provider.clone(),
5806 TransportKind::Pty,
5807 Vec::new(),
5808 extra_args,
5809 )
5810 .map(PreparedNativeLaunchOverlay::Instance)
5811 .map_err(|_| failure(
5812 NodeFailureCode::BundleBindingMismatch,
5813 "managed session bundle launch overlay is invalid",
5814 ))
5815 }).transpose()?;
5816 return Ok(Some((ownership.id().clone(), overlay)));
5817 }
5818 let environment = match materializer.resolve_environment(&ownership, &profile) {
5819 Ok(environment) => environment,
5820 Err(
5821 SessionEnvironmentMaterializeError::SecretUnavailable
5822 | SessionEnvironmentMaterializeError::SecretDenied
5823 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5824 ) => {
5825 return Err(failure(
5826 NodeFailureCode::BackendOperationFailed,
5827 "managed session environment resolution failed",
5828 ));
5829 }
5830 Err(
5831 SessionEnvironmentMaterializeError::InvalidRoot
5832 | SessionEnvironmentMaterializeError::OwnershipMismatch
5833 | SessionEnvironmentMaterializeError::Filesystem(_)
5834 | SessionEnvironmentMaterializeError::Record(_),
5835 ) => {
5836 self.mark_materialization_recovery_required(ownership.id());
5837 return Err(failure(
5838 NodeFailureCode::BackendOperationFailed,
5839 "managed session environment resolution failed",
5840 ));
5841 }
5842 };
5843 let transport = match mode {
5844 SessionMode::Pty => TransportKind::Pty,
5845 SessionMode::Inline => TransportKind::Pipe,
5846 SessionMode::Acp => TransportKind::Acp,
5847 };
5848 let overlay = match bundle_arguments {
5849 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5850 NativeInstanceLaunchOverlay::new(
5851 provider.clone(),
5852 transport,
5853 environment,
5854 extra_args,
5855 )
5856 .map_err(|_| failure(
5857 NodeFailureCode::BundleBindingMismatch,
5858 "managed session bundle launch overlay is invalid",
5859 ))?,
5860 ),
5861 None => PreparedNativeLaunchOverlay::Environment(
5862 NativeLaunchEnvironmentOverlay::new(
5863 provider.clone(),
5864 transport,
5865 environment,
5866 )
5867 .map_err(|_| {
5868 failure(
5869 NodeFailureCode::BackendOperationFailed,
5870 "managed session environment overlay is invalid",
5871 )
5872 })?,
5873 ),
5874 };
5875 Ok(Some((ownership.id().clone(), Some(overlay))))
5876 }
5877 }
5878 }
5879
5880 fn destroy_record_materialization(
5881 &self,
5882 record_id: &SessionRecordId,
5883 ) -> Result<bool, NodeFailure> {
5884 let ids = self
5885 .materializations
5886 .lock()
5887 .unwrap_or_else(|poisoned| poisoned.into_inner())
5888 .values()
5889 .filter_map(|record| {
5890 (record.owner()
5891 == &MaterializationOwner::Record {
5892 record_id: record_id.clone(),
5893 })
5894 .then(|| record.id().clone())
5895 })
5896 .collect::<Vec<_>>();
5897 if ids.len() > 1 {
5898 return Err(failure(
5899 NodeFailureCode::BackendOperationFailed,
5900 "managed session has conflicting environment ownership",
5901 ));
5902 }
5903 let Some(id) = ids.first().cloned() else {
5904 return Ok(false);
5905 };
5906 let (terminal_record, mut cleanup_ownership) = {
5907 let _transaction = self
5908 .state_transaction
5909 .lock()
5910 .unwrap_or_else(|poisoned| poisoned.into_inner());
5911 let original_record = self.record(record_id)?;
5912 let mut terminal_record = original_record.clone();
5913 terminal_record.provider_session = None;
5914 terminal_record.active_session = None;
5915 terminal_record.state = ManagedSessionState::Unavailable;
5916 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5917 terminal_record.updated_at_unix_ms = unix_time_ms();
5918 let original_ownership = self
5919 .materializations
5920 .lock()
5921 .unwrap_or_else(|poisoned| poisoned.into_inner())
5922 .get(&id)
5923 .cloned()
5924 .ok_or_else(|| {
5925 failure(
5926 NodeFailureCode::BackendOperationFailed,
5927 "managed session environment ownership disappeared during cleanup",
5928 )
5929 })?;
5930 let mut cleanup_ownership = original_ownership.clone();
5931 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5932 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5933 failure(
5934 NodeFailureCode::BackendOperationFailed,
5935 "managed session environment cleanup state is invalid",
5936 )
5937 })?;
5938 }
5939 self.session_records
5940 .lock()
5941 .unwrap_or_else(|poisoned| poisoned.into_inner())
5942 .records
5943 .insert(record_id.clone(), terminal_record.clone());
5944 self.materializations
5945 .lock()
5946 .unwrap_or_else(|poisoned| poisoned.into_inner())
5947 .insert(id.clone(), cleanup_ownership.clone());
5948 if let Err(error) = self.persist_state_locked() {
5949 self.session_records
5950 .lock()
5951 .unwrap_or_else(|poisoned| poisoned.into_inner())
5952 .records
5953 .insert(record_id.clone(), original_record);
5954 self.materializations
5955 .lock()
5956 .unwrap_or_else(|poisoned| poisoned.into_inner())
5957 .insert(id.clone(), original_ownership);
5958 return Err(persistence_failure(error));
5959 }
5960 (terminal_record, cleanup_ownership)
5961 };
5962 let cleanup = self
5963 .session_environment_materializer
5964 .as_ref()
5965 .ok_or(())
5966 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5967 if cleanup.is_err() {
5968 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5969 self.materializations
5970 .lock()
5971 .unwrap_or_else(|poisoned| poisoned.into_inner())
5972 .insert(id.clone(), cleanup_ownership);
5973 if self.persist_state().is_err() {
5974 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5975 }
5976 return Err(failure(
5977 NodeFailureCode::BackendOperationFailed,
5978 "managed session environment cleanup requires recovery",
5979 ));
5980 }
5981 let _transaction = self
5982 .state_transaction
5983 .lock()
5984 .unwrap_or_else(|poisoned| poisoned.into_inner());
5985 self.remove_record_memory(record_id);
5986 self.materializations
5987 .lock()
5988 .unwrap_or_else(|poisoned| poisoned.into_inner())
5989 .remove(&id);
5990 if let Err(error) = self.persist_state_locked() {
5991 self.session_records
5992 .lock()
5993 .unwrap_or_else(|poisoned| poisoned.into_inner())
5994 .records
5995 .insert(record_id.clone(), terminal_record);
5996 self.materializations
5997 .lock()
5998 .unwrap_or_else(|poisoned| poisoned.into_inner())
5999 .insert(id, cleanup_ownership);
6000 return Err(persistence_failure(error));
6001 }
6002 Ok(true)
6003 }
6004
6005 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6006 let changed = {
6007 let mut materializations = self
6008 .materializations
6009 .lock()
6010 .unwrap_or_else(|poisoned| poisoned.into_inner());
6011 let Some(ownership) = materializations.get_mut(id) else {
6012 return;
6013 };
6014 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6015 };
6016 if changed && self.persist_state().is_err() {
6017 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6018 }
6019 }
6020
6021 fn reconcile_materializations(&self) {
6022 let records = self
6023 .materializations
6024 .lock()
6025 .unwrap_or_else(|poisoned| poisoned.into_inner())
6026 .values()
6027 .cloned()
6028 .collect::<Vec<_>>();
6029 for mut ownership in records {
6030 let result = match (ownership.state(), ownership.owner()) {
6031 (MaterializationState::Preparing, _)
6032 | (MaterializationState::CleanupRequired, _) => {
6033 self.cleanup_materialization(ownership.id())
6034 }
6035 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6036 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6037 self.materializations
6038 .lock()
6039 .unwrap_or_else(|poisoned| poisoned.into_inner())
6040 .insert(ownership.id().clone(), ownership.clone());
6041 self.persist_state().map_err(persistence_failure)
6042 } else {
6043 Err(failure(
6044 NodeFailureCode::BackendOperationFailed,
6045 "session environment reconciliation failed",
6046 ))
6047 }
6048 }
6049 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6050 .session_environment_materializer
6051 .as_ref()
6052 .ok_or_else(|| {
6053 failure(
6054 NodeFailureCode::BackendOperationFailed,
6055 "session environment materializer is unavailable",
6056 )
6057 })
6058 .and_then(|materializer| {
6059 materializer.revalidate(&ownership).map_err(|_| failure(
6060 NodeFailureCode::BackendOperationFailed,
6061 "session materialization revalidation failed",
6062 ))?;
6063 let record = self.record(record_id)?;
6064 if record.environment_profile.as_ref() != ownership.environment_profile()
6065 || record.bundle.as_ref() != ownership.bundle()
6066 || record.context.as_ref() != ownership.context()
6067 {
6068 return Err(failure(
6069 NodeFailureCode::BackendOperationFailed,
6070 "session materialization ownership changed after restart",
6071 ));
6072 }
6073 if let Some(receipt) = ownership.bundle() {
6074 let catalog = self.bundle_catalog.read()
6075 .unwrap_or_else(|poisoned| poisoned.into_inner());
6076 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6077 NodeFailureCode::UnknownBundle,
6078 "session bundle is unavailable after restart",
6079 ))?;
6080 if bundle.receipt() != *receipt {
6081 return Err(failure(
6082 NodeFailureCode::BundleBindingMismatch,
6083 "session bundle changed after restart",
6084 ));
6085 }
6086 let layout = self.bundle_layout(
6087 &record.provider,
6088 record.mode,
6089 record.environment_profile.as_ref(),
6090 bundle,
6091 )?;
6092 materializer
6093 .revalidate_bundle(&ownership, bundle, layout)
6094 .map_err(|_| failure(
6095 NodeFailureCode::BundleMaterializationFailed,
6096 "session bundle revalidation failed",
6097 ))?;
6098 }
6099 if let Some(receipt) = ownership.context() {
6100 let pack = materializer
6101 .revalidate_context(&ownership, receipt)
6102 .map_err(|_| {
6103 failure(
6104 NodeFailureCode::ContextPackMaterializationFailed,
6105 "session context revalidation failed",
6106 )
6107 })?;
6108 self.context_catalog
6109 .write()
6110 .unwrap_or_else(|poisoned| poisoned.into_inner())
6111 .insert(pack)
6112 .map_err(|_| {
6113 failure(
6114 NodeFailureCode::ContextPackMaterializationFailed,
6115 "session context catalog restore failed",
6116 )
6117 })?;
6118 }
6119 Ok(())
6120 }),
6121 (MaterializationState::RecoveryRequired, _) => Ok(()),
6122 };
6123 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6124 let _ = ownership.mark_recovery_required(unix_time_ms());
6125 self.materializations
6126 .lock()
6127 .unwrap_or_else(|poisoned| poisoned.into_inner())
6128 .insert(ownership.id().clone(), ownership);
6129 if self.persist_state().is_err() {
6130 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6131 }
6132 }
6133 }
6134 let materialized_profiles = self
6135 .environment_materialization_profiles
6136 .read()
6137 .unwrap_or_else(|poisoned| poisoned.into_inner())
6138 .keys()
6139 .cloned()
6140 .collect::<Vec<_>>();
6141 let ownership = self
6142 .materializations
6143 .lock()
6144 .unwrap_or_else(|poisoned| poisoned.into_inner())
6145 .values()
6146 .cloned()
6147 .collect::<Vec<_>>();
6148 let changed = {
6149 let mut session_records = self
6150 .session_records
6151 .lock()
6152 .unwrap_or_else(|poisoned| poisoned.into_inner());
6153 let mut changed = false;
6154 for record in session_records.records.values_mut() {
6155 let environment_profile = record.environment_profile.as_ref();
6156 if environment_profile.is_none()
6157 && record.bundle.is_none()
6158 && record.context.is_none()
6159 {
6160 continue;
6161 }
6162 if record.bundle.is_none()
6163 && environment_profile.is_some_and(|profile| {
6164 !materialized_profiles.contains(&profile.profile_id)
6165 })
6166 {
6167 continue;
6168 }
6169 let has_exact_ownership = ownership.iter().any(|candidate| {
6170 candidate.state() == MaterializationState::Ready
6171 && candidate.environment_profile() == environment_profile
6172 && candidate.bundle() == record.bundle.as_ref()
6173 && candidate.context() == record.context.as_ref()
6174 && candidate.owner()
6175 == &MaterializationOwner::Record {
6176 record_id: record.record_id.clone(),
6177 }
6178 });
6179 if !has_exact_ownership {
6180 record.provider_session = None;
6181 record.active_session = None;
6182 record.state = ManagedSessionState::Unavailable;
6183 record.last_error = Some(if record.bundle.is_some() {
6184 "bundle-unavailable".to_owned()
6185 } else if record.context.is_some() {
6186 "context-unavailable".to_owned()
6187 } else {
6188 "environment-profile-unavailable".to_owned()
6189 });
6190 record.updated_at_unix_ms = unix_time_ms();
6191 changed = true;
6192 }
6193 }
6194 changed
6195 };
6196 if changed && self.persist_state().is_err() {
6197 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6198 }
6199 }
6200
6201 fn replay_spawn_spec(
6202 &self,
6203 spec: &SpawnSpec,
6204 now: Instant,
6205 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6206 let mut cache = self
6207 .spawn_idempotency
6208 .lock()
6209 .unwrap_or_else(|poisoned| poisoned.into_inner());
6210 cache.entries.retain(|_, entry| entry.expires_at > now);
6211 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6212 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6213 return Err(failure(
6214 NodeFailureCode::SpawnIdempotencyConflict,
6215 "spawn idempotency key was reused across spawn kinds",
6216 ));
6217 };
6218 if existing != spec {
6219 return Err(failure(
6220 NodeFailureCode::SpawnIdempotencyConflict,
6221 "spawn idempotency key was reused with a different specification",
6222 ));
6223 }
6224 return Ok(Some(result.clone()));
6225 }
6226 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6227 return Err(failure(
6228 NodeFailureCode::SpawnIdempotencyCapacity,
6229 "spawn idempotency cache reached its bounded capacity",
6230 ));
6231 }
6232 Ok(None)
6233 }
6234
6235 fn remember_spawn_spec(
6236 &self,
6237 spec: SpawnSpec,
6238 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6239 accepted_at: Instant,
6240 ) {
6241 self
6242 .spawn_idempotency
6243 .lock()
6244 .unwrap_or_else(|poisoned| poisoned.into_inner())
6245 .entries
6246 .insert(
6247 spec.idempotency_key.clone(),
6248 SpawnIdempotencyEntry {
6249 value: SpawnIdempotencyValue::Standard { spec, result },
6250 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6251 },
6252 );
6253 }
6254
6255 fn claim_harness_mcp_spawn(
6256 &self,
6257 _reservation_id: &HarnessMcpReservationId,
6258 _activation_digest: &HarnessMcpActivationDigest,
6259 spec: &SpawnSpec,
6260 now: Instant,
6261 ) -> Result<(), NodeFailure> {
6262 let mut cache = self.spawn_idempotency.lock()
6263 .unwrap_or_else(|poisoned| poisoned.into_inner());
6264 cache.entries.retain(|_, entry| entry.expires_at > now);
6265 if cache.entries.contains_key(&spec.idempotency_key) {
6266 return Err(failure(
6267 NodeFailureCode::SpawnIdempotencyConflict,
6268 "spawn idempotency key was reused across spawn kinds",
6269 ));
6270 }
6271 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6272 return Err(failure(
6273 NodeFailureCode::SpawnIdempotencyCapacity,
6274 "spawn idempotency cache reached its bounded capacity",
6275 ));
6276 }
6277 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6278 value: SpawnIdempotencyValue::HarnessMcp,
6279 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6280 });
6281 Ok(())
6282 }
6283
6284 fn replay_managed_spawn(
6285 &self,
6286 request: &ManagedWorktreeSpawnRequest,
6287 now: Instant,
6288 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6289 let mut cache = self.spawn_idempotency.lock()
6290 .unwrap_or_else(|poisoned| poisoned.into_inner());
6291 cache.entries.retain(|_, entry| entry.expires_at > now);
6292 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6293 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6294 return Err(failure(
6295 NodeFailureCode::SpawnIdempotencyConflict,
6296 "spawn idempotency key was reused across spawn kinds",
6297 ));
6298 };
6299 if existing != request {
6300 return Err(failure(
6301 NodeFailureCode::SpawnIdempotencyConflict,
6302 "spawn idempotency key was reused with a different managed specification",
6303 ));
6304 }
6305 return Ok(Some(result.clone()));
6306 }
6307 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6308 return Err(failure(
6309 NodeFailureCode::SpawnIdempotencyCapacity,
6310 "spawn idempotency cache reached its bounded capacity",
6311 ));
6312 }
6313 Ok(None)
6314 }
6315
6316 fn remember_managed_spawn(
6317 &self,
6318 request: ManagedWorktreeSpawnRequest,
6319 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6320 accepted_at: Instant,
6321 ) {
6322 self.spawn_idempotency.lock()
6323 .unwrap_or_else(|poisoned| poisoned.into_inner())
6324 .entries
6325 .insert(
6326 request.spawn_spec.idempotency_key.clone(),
6327 SpawnIdempotencyEntry {
6328 value: SpawnIdempotencyValue::Managed { request, result },
6329 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6330 },
6331 );
6332 }
6333
6334 fn remember_managed_spawn_attempt(
6335 &self,
6336 durable_v2_key: Option<&SpawnIdempotencyKey>,
6337 request: ManagedWorktreeSpawnRequest,
6338 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6339 accepted_at: Instant,
6340 ) {
6341 if durable_v2_key.is_none() {
6342 self.remember_managed_spawn(request, result, accepted_at);
6343 }
6344 }
6345
6346 fn acquire_durable_managed_spawn_v2(
6347 &self,
6348 request: &ManagedWorktreeSpawnRequestV2,
6349 request_digest: String,
6350 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6351 if self.state_path.is_none() {
6352 return Err(failure(
6353 NodeFailureCode::BackendOperationFailed,
6354 "managed worktree V2 requires durable node state",
6355 ));
6356 }
6357 let _transaction = self.state_transaction
6358 .lock()
6359 .unwrap_or_else(|poisoned| poisoned.into_inner());
6360 let key = request.spawn_spec.idempotency_key.clone();
6361 let mut previous = None;
6362 {
6363 let mut replays = self.managed_spawn_replays
6364 .lock()
6365 .unwrap_or_else(|poisoned| poisoned.into_inner());
6366 if let Some(record) = replays.get_mut(&key) {
6367 if record.request_digest != request_digest {
6368 return Err(failure(
6369 NodeFailureCode::SpawnIdempotencyConflict,
6370 "managed worktree V2 idempotency key was reused with a different request",
6371 ));
6372 }
6373 match &record.state {
6374 ManagedWorktreeSpawnReplayStateV10::Pending {
6375 owner_incarnation_id,
6376 lease_id: None,
6377 } if owner_incarnation_id != &self.incarnation_id => {
6378 previous = Some(record.clone());
6379 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6380 owner_incarnation_id: self.incarnation_id,
6381 lease_id: None,
6382 };
6383 record.updated_at_unix_ms = unix_time_ms();
6384 }
6385 ManagedWorktreeSpawnReplayStateV10::Pending {
6386 lease_id: None,
6387 ..
6388 } => {
6389 return Err(failure(
6390 NodeFailureCode::BackendBusy,
6391 "managed worktree V2 reservation is already active",
6392 ));
6393 }
6394 ManagedWorktreeSpawnReplayStateV10::Pending {
6395 lease_id: Some(_),
6396 ..
6397 } => {
6398 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6399 }
6400 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6401 return Ok(DurableManagedSpawnReplayDecision::Committed(
6402 receipt.clone(),
6403 ));
6404 }
6405 }
6406 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6407 return Err(failure(
6408 NodeFailureCode::SpawnIdempotencyCapacity,
6409 "durable managed worktree replay ledger reached its bounded capacity",
6410 ));
6411 } else {
6412 let now = unix_time_ms();
6413 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6414 idempotency_key: key.clone(),
6415 request_digest,
6416 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6417 profile_id: request.worktree_profile_id.clone(),
6418 expected_profile_revision: request.expected_profile_revision.clone(),
6419 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6420 owner_incarnation_id: self.incarnation_id,
6421 lease_id: None,
6422 },
6423 created_at_unix_ms: now,
6424 updated_at_unix_ms: now,
6425 });
6426 }
6427 }
6428 if let Err(error) = self.persist_state_locked() {
6429 let mut replays = self.managed_spawn_replays
6430 .lock()
6431 .unwrap_or_else(|poisoned| poisoned.into_inner());
6432 match previous {
6433 Some(previous) => {
6434 replays.insert(key, previous);
6435 }
6436 None => {
6437 replays.remove(&key);
6438 }
6439 }
6440 return Err(persistence_failure(error));
6441 }
6442 Ok(DurableManagedSpawnReplayDecision::Reserved)
6443 }
6444
6445 fn link_durable_managed_spawn_v2_lease_locked(
6446 &self,
6447 key: &SpawnIdempotencyKey,
6448 lease_id: &ManagedWorktreeLeaseId,
6449 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6450 let mut replays = self.managed_spawn_replays
6451 .lock()
6452 .unwrap_or_else(|poisoned| poisoned.into_inner());
6453 let record = replays.get_mut(key).ok_or_else(|| {
6454 failure(
6455 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6456 "managed worktree V2 reservation disappeared before allocation",
6457 )
6458 })?;
6459 let previous = record.clone();
6460 match &mut record.state {
6461 ManagedWorktreeSpawnReplayStateV10::Pending {
6462 lease_id: current,
6463 ..
6464 }
6465 if current.is_none() =>
6466 {
6467 *current = Some(lease_id.clone());
6468 record.updated_at_unix_ms = unix_time_ms();
6469 Ok(previous)
6470 }
6471 _ => Err(failure(
6472 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6473 "managed worktree V2 reservation is not allocation-ready",
6474 )),
6475 }
6476 }
6477
6478 fn restore_durable_managed_spawn_v2_locked(
6479 &self,
6480 key: &SpawnIdempotencyKey,
6481 previous: ManagedWorktreeSpawnReplayRecordV10,
6482 ) {
6483 self.managed_spawn_replays
6484 .lock()
6485 .unwrap_or_else(|poisoned| poisoned.into_inner())
6486 .insert(key.clone(), previous);
6487 }
6488
6489 fn commit_durable_managed_spawn_v2(
6490 &self,
6491 request: &ManagedWorktreeSpawnRequestV2,
6492 receipt: ManagedWorktreeSpawnReceipt,
6493 ) -> Result<(), NodeFailure> {
6494 let _transaction = self.state_transaction
6495 .lock()
6496 .unwrap_or_else(|poisoned| poisoned.into_inner());
6497 let key = &request.spawn_spec.idempotency_key;
6498 let previous = {
6499 let mut replays = self.managed_spawn_replays
6500 .lock()
6501 .unwrap_or_else(|poisoned| poisoned.into_inner());
6502 let record = replays.get_mut(key).ok_or_else(|| {
6503 failure(
6504 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6505 "managed worktree V2 reservation disappeared before commit",
6506 )
6507 })?;
6508 let previous = record.clone();
6509 match &record.state {
6510 ManagedWorktreeSpawnReplayStateV10::Pending {
6511 lease_id: Some(lease_id),
6512 ..
6513 } if lease_id == &receipt.lease.lease_id => {}
6514 _ => {
6515 return Err(failure(
6516 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6517 "managed worktree V2 reservation is not linked to the spawned lease",
6518 ));
6519 }
6520 }
6521 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6522 record.updated_at_unix_ms = unix_time_ms();
6523 previous
6524 };
6525 if let Err(error) = self.persist_state_locked() {
6526 self.restore_durable_managed_spawn_v2_locked(key, previous);
6527 return Err(persistence_failure(error));
6528 }
6529 Ok(())
6530 }
6531
6532 fn remove_unallocated_durable_managed_spawn_v2(
6533 &self,
6534 request: &ManagedWorktreeSpawnRequestV2,
6535 ) -> Result<(), NodeFailure> {
6536 let _transaction = self.state_transaction
6537 .lock()
6538 .unwrap_or_else(|poisoned| poisoned.into_inner());
6539 let key = &request.spawn_spec.idempotency_key;
6540 let removed = {
6541 let mut replays = self.managed_spawn_replays
6542 .lock()
6543 .unwrap_or_else(|poisoned| poisoned.into_inner());
6544 match replays.get(key).map(|record| &record.state) {
6545 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6546 lease_id: None,
6547 ..
6548 }) => {
6549 replays.remove(key)
6550 }
6551 _ => None,
6552 }
6553 };
6554 let Some(removed) = removed else {
6555 return Ok(());
6556 };
6557 if let Err(error) = self.persist_state_locked() {
6558 self.managed_spawn_replays
6559 .lock()
6560 .unwrap_or_else(|poisoned| poisoned.into_inner())
6561 .insert(key.clone(), removed);
6562 return Err(persistence_failure(error));
6563 }
6564 Ok(())
6565 }
6566
6567 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6568 if spec.target.node_id != self.node_id {
6569 return Err(failure(
6570 NodeFailureCode::SpawnTargetMismatch,
6571 "spawn target does not match this node",
6572 ));
6573 }
6574 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6575 failure(
6576 NodeFailureCode::UnknownSpawnProfile,
6577 "spawn profile is unavailable on this node",
6578 )
6579 })?;
6580 let resolved = spec.resolve(defaults).map_err(|error| match error {
6581 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6582 NodeFailureCode::SpawnProfileRevisionMismatch,
6583 "spawn profile revision does not match the loaded profile",
6584 ),
6585 _ => failure(
6586 NodeFailureCode::InvalidRequest,
6587 "spawn specification could not be resolved",
6588 ),
6589 })?;
6590 if resolved.target.worktree_id.is_some() {
6591 self.require_worktree_service(&resolved.target.workspace_id)?;
6592 }
6593 let environment_profile = self.resolve_environment_profile(&resolved)?;
6594 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6595 self.resolve_context(&resolved)?;
6596 Ok(resolved)
6597 }
6598
6599 async fn resolve_spawn_workspace(
6600 &self,
6601 resolved: &ResolvedSpawnSpec,
6602 deadline: Instant,
6603 ) -> Result<WorkspaceId, NodeFailure> {
6604 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6605 return Ok(resolved.target.workspace_id.clone());
6606 };
6607 if self.managed_worktrees.lock()
6608 .unwrap_or_else(|poisoned| poisoned.into_inner())
6609 .lease_for_workspace(worktree_id)
6610 .is_some()
6611 {
6612 return Err(failure(
6613 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6614 "managed worktree targets may only be spawned through their lease request",
6615 ));
6616 }
6617 self.require_worktree_service(&resolved.target.workspace_id)?;
6618 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6619 let selected_root = self.workspace_root(worktree_id)?;
6620 let remaining = spawn_deadline_remaining(deadline)?;
6621 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6622 .await
6623 .map_err(|_| {
6624 failure(
6625 NodeFailureCode::SpawnDeadlineExceeded,
6626 "Git worktree selection exceeded the spawn deadline",
6627 )
6628 })?
6629 .map_err(git_worktree_failure)?;
6630 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6631 Ok(worktree_id.clone())
6632 }
6633
6634 async fn spawn_from_spec(
6635 &self,
6636 spec: SpawnSpec,
6637 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6638 self.spawn_from_spec_with_proxy(spec, None).await
6639 }
6640
6641 async fn spawn_from_spec_with_proxy(
6642 &self,
6643 spec: SpawnSpec,
6644 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6645 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6646 let accepted_at = Instant::now();
6647 if harness_mcp.is_none() {
6648 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6649 return replayed;
6650 }
6651 }
6652 let resolved = self.resolve_spawn_spec(&spec)?;
6653 let environment_profile = self.resolve_environment_profile(&resolved)?;
6654 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6655 let context = self.resolve_context(&resolved)?;
6656 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6657 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6658 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6659 let spawn_workspace_id = match spawn_workspace_id {
6660 Ok(workspace_id) => workspace_id,
6661 Err(error) => {
6662 let result = Err(error);
6663 if harness_mcp.is_none() {
6664 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6665 }
6666 return result;
6667 }
6668 };
6669 if self.managed_worktrees.lock()
6670 .unwrap_or_else(|poisoned| poisoned.into_inner())
6671 .lease_for_workspace(&spawn_workspace_id)
6672 .is_some()
6673 {
6674 let result = Err(failure(
6675 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6676 "managed worktree targets may only be spawned through their lease request",
6677 ));
6678 if harness_mcp.is_none() {
6679 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6680 }
6681 return result;
6682 }
6683 let result = self
6684 .spawn_session_with_deadline(
6685 spawn_workspace_id,
6686 resolved.provider.clone(),
6687 resolved.mode,
6688 resolved.terminal_size,
6689 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6690 environment_profile.clone(),
6691 bundle.clone(),
6692 context.clone(),
6693 None,
6694 None,
6695 SpawnRecordPolicy::Always,
6696 Some(deadline),
6697 &required_capabilities,
6698 harness_mcp,
6699 resolved.approval_level,
6700 resolved.network_allowlist.clone(),
6701 resolved.browser_profile_id.clone(),
6702 )
6703 .await
6704 .map(|(session, _runtime_policy)| {
6705 resolved.receipt_with_materialization(
6706 self.incarnation_id,
6707 session,
6708 environment_profile,
6709 bundle,
6710 context,
6711 )
6712 });
6713 if harness_mcp.is_none() {
6714 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6715 }
6716 result
6717 }
6718
6719 async fn spawn_from_spec_with_harness_mcp(
6720 &self,
6721 reservation_id: HarnessMcpReservationId,
6722 activation_digest: HarnessMcpActivationDigest,
6723 spec: SpawnSpec,
6724 deadline_unix_ms: u64,
6725 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6726 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6727 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6728 })?;
6729 if let Some(receipt) = registry
6730 .replay_spawn(&reservation_id, &activation_digest, &spec)
6731 .map_err(harness_mcp_failure)?
6732 {
6733 return Ok(receipt);
6734 }
6735 let resolved = self.resolve_spawn_spec(&spec)?;
6736 let prepared = registry
6737 .prepare_spawn(
6738 &reservation_id,
6739 &activation_digest,
6740 &spec,
6741 deadline_unix_ms,
6742 resolved.provider.clone(),
6743 )
6744 .map_err(harness_mcp_failure)?;
6745 self.claim_harness_mcp_spawn(
6746 &reservation_id,
6747 &activation_digest,
6748 &spec,
6749 Instant::now(),
6750 )?;
6751 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6752 let mut receipt = match spawn {
6753 Ok(receipt) => receipt,
6754 Err(error) => {
6755 let _ = registry.abort(&reservation_id, &activation_digest);
6756 return Err(error);
6757 }
6758 };
6759 let record_id = self
6760 .session_bindings
6761 .lock()
6762 .unwrap_or_else(|poisoned| poisoned.into_inner())
6763 .get(&receipt.session.session.instance_id)
6764 .and_then(|binding| binding.record_id.clone());
6765 let record_id = match record_id {
6766 Some(record_id) => record_id,
6767 None => {
6768 let rollback = self.remove_session(&receipt.session).await;
6769 let _ = registry.abort(&reservation_id, &activation_digest);
6770 if let Err(rollback_error) = rollback {
6771 return Err(rollback_error);
6772 }
6773 return Err(failure(
6774 NodeFailureCode::BindingMismatch,
6775 "harness MCP spawn did not retain its exact managed record",
6776 ));
6777 }
6778 };
6779 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6780 reservation_id: reservation_id.clone(),
6781 activation_digest: activation_digest.clone(),
6782 });
6783 if let Err(error) = registry.mark_spawned(
6784 &prepared,
6785 receipt.session.clone(),
6786 record_id,
6787 receipt.clone(),
6788 ) {
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(harness_mcp_failure(error));
6795 }
6796 Ok(receipt)
6797 }
6798
6799 async fn spawn_managed_worktree(
6800 &self,
6801 request: ManagedWorktreeSpawnRequest,
6802 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6803 self.spawn_managed_worktree_inner(request, None).await
6804 }
6805
6806 async fn spawn_managed_worktree_inner(
6807 &self,
6808 request: ManagedWorktreeSpawnRequest,
6809 durable_v2_key: Option<&SpawnIdempotencyKey>,
6810 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6811 let accepted_at = Instant::now();
6812 if durable_v2_key.is_none() {
6813 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6814 return replayed;
6815 }
6816 }
6817 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6818 if request.spawn_spec.target.worktree_id.is_some() {
6819 let result = Err(failure(
6820 NodeFailureCode::InvalidRequest,
6821 "managed worktree spawn must not select a caller-provided worktree",
6822 ));
6823 self.remember_managed_spawn_attempt(
6824 durable_v2_key,
6825 request,
6826 result.clone(),
6827 accepted_at,
6828 );
6829 return result;
6830 }
6831 let environment_profile = self.resolve_environment_profile(&resolved)?;
6832 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6833 let context = self.resolve_context(&resolved)?;
6834 let source_workspace_id = resolved.target.workspace_id.clone();
6835 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6836 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6837 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6838 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6839 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6840 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6841 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6842 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6843 };
6844 let runtime_policy = timeout(
6845 spawn_deadline_remaining(deadline)?,
6846 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6847 ).await.map_err(|_| failure(
6848 NodeFailureCode::SpawnDeadlineExceeded,
6849 "provider admission exceeded the managed spawn deadline",
6850 ))??;
6851 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6852 return Err(failure(
6853 NodeFailureCode::UnsupportedSpawnCapability,
6854 "provider runtime does not admit a required spawn capability",
6855 ));
6856 }
6857 self.ensure_binding_capacity()?;
6858 let source_root = self.workspace_root(&source_workspace_id)?;
6859 profile.validate_for_workspace(&source_root)
6860 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6861 let base_timeout_ms = u64::try_from(
6862 spawn_deadline_remaining(deadline)?.as_millis(),
6863 ).unwrap_or(u64::MAX).max(1);
6864 let base_commit = resolve_base_commit_with_timeout(
6865 &source_root,
6866 profile.base(),
6867 base_timeout_ms,
6868 ).await.map_err(|error| {
6869 if Instant::now() >= deadline || error.message.contains("timed out") {
6870 failure(
6871 NodeFailureCode::SpawnDeadlineExceeded,
6872 "managed worktree base resolution exceeded the spawn deadline",
6873 )
6874 } else {
6875 managed_git_worktree_failure(error)
6876 }
6877 })?;
6878
6879 let lease = {
6880 let _transaction = self.state_transaction.lock()
6881 .unwrap_or_else(|poisoned| poisoned.into_inner());
6882 let previous = self.managed_worktrees.lock()
6883 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6884 let lease = self.managed_worktrees.lock()
6885 .unwrap_or_else(|poisoned| poisoned.into_inner())
6886 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6887 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6888 let previous_replay = match durable_v2_key
6889 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6890 .transpose()
6891 {
6892 Ok(previous) => previous,
6893 Err(error) => {
6894 *self.managed_worktrees.lock()
6895 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6896 return Err(error);
6897 }
6898 };
6899 if let Err(error) = self.persist_state_locked() {
6900 *self.managed_worktrees.lock()
6901 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6902 if let (Some(key), Some(previous_replay)) =
6903 (durable_v2_key, previous_replay)
6904 {
6905 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6906 }
6907 return Err(persistence_failure(error));
6908 }
6909 lease
6910 };
6911 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6912 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6913 self.managed_worktrees.lock()
6914 .unwrap_or_else(|poisoned| poisoned.into_inner())
6915 .remove_unmutated(&lease.lease_id);
6916 if self.persist_state().is_err() {
6917 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6918 }
6919 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6920 self.remember_managed_spawn_attempt(
6921 durable_v2_key,
6922 request,
6923 result.clone(),
6924 accepted_at,
6925 );
6926 return result;
6927 }
6928 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6929 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6930 Err(error) => {
6931 self.reconcile_failed_allocation(&lease, &source_root).await;
6932 let result = Err(error);
6933 self.remember_managed_spawn_attempt(
6934 durable_v2_key,
6935 request,
6936 result.clone(),
6937 accepted_at,
6938 );
6939 return result;
6940 }
6941 };
6942 let created = match create_git_worktree_with_timeout(
6943 &source_root,
6944 &lease.target_root,
6945 &lease.branch,
6946 Some(&base_commit),
6947 mutation_timeout_ms,
6948 ).await {
6949 Ok(created) => created,
6950 Err(error) => {
6951 self.reconcile_failed_allocation(&lease, &source_root).await;
6952 let result = Err(if Instant::now() >= deadline
6953 || error.message.contains("timed out")
6954 || error.message.contains("deadline")
6955 {
6956 failure(
6957 NodeFailureCode::SpawnDeadlineExceeded,
6958 "managed worktree creation exceeded the spawn deadline",
6959 )
6960 } else {
6961 managed_git_worktree_failure(error)
6962 });
6963 self.remember_managed_spawn_attempt(
6964 durable_v2_key,
6965 request,
6966 result.clone(),
6967 accepted_at,
6968 );
6969 return result;
6970 }
6971 };
6972 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6973 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6974 let result = Err(failure(
6975 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6976 "created worktree identity did not match its durable lease",
6977 ));
6978 self.remember_managed_spawn_attempt(
6979 durable_v2_key,
6980 request,
6981 result.clone(),
6982 accepted_at,
6983 );
6984 return result;
6985 }
6986 {
6987 let mut registry = self.managed_worktrees.lock()
6988 .unwrap_or_else(|poisoned| poisoned.into_inner());
6989 let current = registry.get_mut(&lease.lease_id)
6990 .expect("allocated managed worktree lease remains present");
6991 current.expected_head = Some(created.head.clone());
6992 current.state = ManagedWorktreeLeaseState::Ready;
6993 current.updated_at_unix_ms = unix_time_ms();
6994 }
6995 if let Err(error) = self.persist_state() {
6996 self.mark_managed_recovery_required(
6997 &lease.lease_id,
6998 ManagedWorktreeCleanupFailure::Backend,
6999 );
7000 let result = Err(persistence_failure(error));
7001 self.remember_managed_spawn_attempt(
7002 durable_v2_key,
7003 request,
7004 result.clone(),
7005 accepted_at,
7006 );
7007 return result;
7008 }
7009 let ready = self.managed_worktrees.lock()
7010 .unwrap_or_else(|poisoned| poisoned.into_inner())
7011 .get(&lease.lease_id)
7012 .expect("ready managed worktree lease remains present")
7013 .snapshot();
7014 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7015 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7016 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7017 let result = Err(error);
7018 self.remember_managed_spawn_attempt(
7019 durable_v2_key,
7020 request,
7021 result.clone(),
7022 accepted_at,
7023 );
7024 return result;
7025 }
7026 let spawn = self.spawn_session_with_deadline(
7027 lease.workspace_id.clone(),
7028 resolved.provider.clone(),
7029 resolved.mode,
7030 resolved.terminal_size,
7031 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7032 environment_profile.clone(),
7033 bundle.clone(),
7034 context.clone(),
7035 Some(lease.lease_id.clone()),
7036 Some(runtime_policy),
7037 SpawnRecordPolicy::Always,
7038 Some(deadline),
7039 &required_capabilities,
7040 None,
7041 resolved.approval_level,
7042 resolved.network_allowlist.clone(),
7043 resolved.browser_profile_id.clone(),
7044 ).await;
7045 let (session, effective_runtime_policy) = match spawn {
7046 Ok(spawned) => spawned,
7047 Err(error) => {
7048 if self
7049 .cleanup_managed_worktree(&lease.lease_id, true)
7050 .await
7051 .is_err()
7052 {
7053 self.mark_managed_recovery_required(
7054 &lease.lease_id,
7055 ManagedWorktreeCleanupFailure::Backend,
7056 );
7057 }
7058 let result = Err(error);
7059 self.remember_managed_spawn_attempt(
7060 durable_v2_key,
7061 request,
7062 result.clone(),
7063 accepted_at,
7064 );
7065 return result;
7066 }
7067 };
7068 let (record_id, raw_inventory_record_id) = {
7069 let mut bindings = self.session_bindings.lock()
7070 .unwrap_or_else(|poisoned| poisoned.into_inner());
7071 let binding = bindings.get_mut(&session.session.instance_id)
7072 .expect("successful spawn retains its session binding");
7073 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7074 managed_spawn_record_ownership(binding, effective_runtime_policy)
7075 };
7076 let bound = self.managed_worktrees.lock()
7077 .unwrap_or_else(|poisoned| poisoned.into_inner())
7078 .bind_session(
7079 &lease.lease_id,
7080 ManagedWorktreeSessionHolder {
7081 incarnation_id: self.incarnation_id,
7082 instance_id: session.session.instance_id,
7083 generation: session.session.generation,
7084 },
7085 record_id,
7086 unix_time_ms(),
7087 )
7088 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7089 let snapshot = match bound {
7090 Ok(snapshot) => snapshot,
7091 Err(error) => {
7092 if raw_inventory_record_id
7093 .as_ref()
7094 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7095 {
7096 self.mark_managed_recovery_required(
7097 &lease.lease_id,
7098 ManagedWorktreeCleanupFailure::Backend,
7099 );
7100 }
7101 let _ = self.remove_session(&session).await;
7102 self.mark_managed_recovery_required(
7103 &lease.lease_id,
7104 ManagedWorktreeCleanupFailure::Backend,
7105 );
7106 let result = Err(error);
7107 self.remember_managed_spawn_attempt(
7108 durable_v2_key,
7109 request,
7110 result.clone(),
7111 accepted_at,
7112 );
7113 return result;
7114 }
7115 };
7116 if let Err(error) = self.persist_state() {
7117 if raw_inventory_record_id
7118 .as_ref()
7119 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7120 {
7121 self.mark_managed_recovery_required(
7122 &lease.lease_id,
7123 ManagedWorktreeCleanupFailure::Backend,
7124 );
7125 }
7126 let _ = self.remove_session(&session).await;
7127 if self
7128 .cleanup_managed_worktree(&lease.lease_id, true)
7129 .await
7130 .is_err()
7131 {
7132 self.mark_managed_recovery_required(
7133 &lease.lease_id,
7134 ManagedWorktreeCleanupFailure::Backend,
7135 );
7136 }
7137 let result = Err(persistence_failure(error));
7138 self.remember_managed_spawn_attempt(
7139 durable_v2_key,
7140 request,
7141 result.clone(),
7142 accepted_at,
7143 );
7144 return result;
7145 }
7146 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7147 let receipt = managed_spawn_receipt(
7148 &resolved,
7149 self.incarnation_id,
7150 session,
7151 snapshot,
7152 environment_profile,
7153 bundle,
7154 context,
7155 );
7156 let result = Ok(receipt);
7157 self.remember_managed_spawn_attempt(
7158 durable_v2_key,
7159 request,
7160 result.clone(),
7161 accepted_at,
7162 );
7163 result
7164 }
7165
7166 async fn spawn_managed_worktree_v2(
7167 &self,
7168 request: ManagedWorktreeSpawnRequestV2,
7169 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7170 if request.spawn_spec.target.node_id != self.node_id {
7171 return Err(failure(
7172 NodeFailureCode::SpawnTargetMismatch,
7173 "spawn target does not match this node",
7174 ));
7175 }
7176 if request.spawn_spec.target.worktree_id.is_some() {
7177 return Err(failure(
7178 NodeFailureCode::InvalidRequest,
7179 "managed worktree spawn must not select a caller-provided worktree",
7180 ));
7181 }
7182 let request_digest = managed_spawn_request_digest_v2(&request)?;
7183 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7184 match replay {
7185 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7186 DurableManagedSpawnReplayDecision::PendingLinked => {
7187 return Err(failure(
7188 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7189 "managed worktree V2 reservation has an incomplete durable allocation",
7190 ));
7191 }
7192 DurableManagedSpawnReplayDecision::Reserved => {}
7193 }
7194 let validated = (|| {
7195 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7196 let profile = self.managed_profile(
7197 &resolved.target.workspace_id,
7198 &request.worktree_profile_id,
7199 )?;
7200 if profile.revision() != &request.expected_profile_revision {
7201 return Err(failure(
7202 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7203 "managed worktree profile revision does not match the loaded profile",
7204 ));
7205 }
7206 Ok(())
7207 })();
7208 match validated {
7209 Ok(()) => {}
7210 Err(error) => {
7211 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7212 return Err(error);
7213 }
7214 }
7215 let legacy = ManagedWorktreeSpawnRequest {
7216 spawn_spec: request.spawn_spec.clone(),
7217 worktree_profile_id: request.worktree_profile_id.clone(),
7218 };
7219 match self
7220 .spawn_managed_worktree_inner(
7221 legacy,
7222 Some(&request.spawn_spec.idempotency_key),
7223 )
7224 .await
7225 {
7226 Ok(receipt) => {
7227 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7228 Ok(receipt)
7229 }
7230 Err(error) => {
7231 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7232 Err(error)
7233 }
7234 }
7235 }
7236
7237 fn managed_profile(
7238 &self,
7239 workspace_id: &WorkspaceId,
7240 profile_id: &WorktreeProfileId,
7241 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7242 if self.worktree_service_modes.get(workspace_id).copied()
7243 != Some(WorktreeServiceMode::Managed)
7244 {
7245 return Err(failure(
7246 NodeFailureCode::UnsupportedSpawnCapability,
7247 "workspace does not enable managed worktrees",
7248 ));
7249 }
7250 self.managed_worktree_profiles.get(workspace_id)
7251 .and_then(|profiles| profiles.get(profile_id))
7252 .cloned()
7253 .ok_or_else(|| failure(
7254 NodeFailureCode::InvalidRequest,
7255 "managed worktree profile is unavailable for this workspace",
7256 ))
7257 }
7258
7259 async fn reconcile_failed_allocation(
7260 &self,
7261 lease: &ManagedWorktreeLeaseRecord,
7262 source_root: &str,
7263 ) {
7264 match list_git_worktrees(source_root).await {
7265 Ok(worktrees) => {
7266 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7267 None => {
7268 match std::fs::symlink_metadata(&lease.target_root) {
7269 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7270 self.managed_worktrees.lock()
7271 .unwrap_or_else(|poisoned| poisoned.into_inner())
7272 .remove_unmutated(&lease.lease_id);
7273 let _ = self.persist_state();
7274 }
7275 _ => self.mark_managed_recovery_required(
7276 &lease.lease_id,
7277 ManagedWorktreeCleanupFailure::OwnershipConflict,
7278 ),
7279 }
7280 }
7281 Some(item) if exact_owned_worktree(
7282 lease,
7283 &item.path,
7284 item.branch.as_deref(),
7285 &item.head,
7286 ) => {
7287 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7288 }
7289 Some(_) => self.mark_managed_recovery_required(
7290 &lease.lease_id,
7291 ManagedWorktreeCleanupFailure::OwnershipConflict,
7292 ),
7293 }
7294 }
7295 Err(_) => self.mark_managed_recovery_required(
7296 &lease.lease_id,
7297 ManagedWorktreeCleanupFailure::Backend,
7298 ),
7299 }
7300 }
7301
7302 async fn reconcile_managed_worktrees(&self) {
7303 let leases = self.managed_worktrees.lock()
7304 .unwrap_or_else(|poisoned| poisoned.into_inner())
7305 .active_records()
7306 .cloned()
7307 .collect::<Vec<_>>();
7308 for lease in leases {
7309 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7310 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7311 _ => {
7312 self.mark_managed_recovery_required(
7313 &lease.lease_id,
7314 ManagedWorktreeCleanupFailure::OwnershipConflict,
7315 );
7316 continue;
7317 }
7318 };
7319 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7320 self.mark_managed_recovery_required(
7321 &lease.lease_id,
7322 ManagedWorktreeCleanupFailure::OwnershipConflict,
7323 );
7324 continue;
7325 }
7326 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7327 Ok(root) => root,
7328 Err(_) => {
7329 self.mark_managed_recovery_required(
7330 &lease.lease_id,
7331 ManagedWorktreeCleanupFailure::OwnershipConflict,
7332 );
7333 continue;
7334 }
7335 };
7336 let worktrees = match list_git_worktrees(&source_root).await {
7337 Ok(worktrees) => worktrees,
7338 Err(_) => {
7339 self.mark_managed_recovery_required(
7340 &lease.lease_id,
7341 ManagedWorktreeCleanupFailure::Backend,
7342 );
7343 continue;
7344 }
7345 };
7346 let listed = worktrees.iter()
7347 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7348 let Some(listed) = listed else {
7349 match std::fs::symlink_metadata(&lease.target_root) {
7350 Err(error) if error.kind() == io::ErrorKind::NotFound
7351 && lease.state == ManagedWorktreeLeaseState::Allocating
7352 && !lease.has_holders() => {
7353 let removed = self.managed_worktrees.lock()
7354 .unwrap_or_else(|poisoned| poisoned.into_inner())
7355 .tombstone(&lease.lease_id, unix_time_ms());
7356 if self.persist_state().is_ok() && removed.is_some() {
7357 self.publish(NodeEvent::ManagedWorktreeRemoved {
7358 lease_id: lease.lease_id.clone(),
7359 });
7360 }
7361 }
7362 _ => self.mark_managed_recovery_required(
7363 &lease.lease_id,
7364 ManagedWorktreeCleanupFailure::OwnershipConflict,
7365 ),
7366 }
7367 continue;
7368 };
7369 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7370 &lease,
7371 &listed.path,
7372 listed.branch.as_deref(),
7373 &listed.head,
7374 ) {
7375 self.mark_managed_recovery_required(
7376 &lease.lease_id,
7377 ManagedWorktreeCleanupFailure::OwnershipConflict,
7378 );
7379 continue;
7380 }
7381 if !self.workspaces.read()
7382 .unwrap_or_else(|poisoned| poisoned.into_inner())
7383 .contains_key(&lease.workspace_id)
7384 {
7385 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7386 self.mark_managed_recovery_required(
7387 &lease.lease_id,
7388 ManagedWorktreeCleanupFailure::Backend,
7389 );
7390 continue;
7391 }
7392 }
7393 let snapshot = {
7394 let mut registry = self.managed_worktrees.lock()
7395 .unwrap_or_else(|poisoned| poisoned.into_inner());
7396 let current = registry.get_mut(&lease.lease_id)
7397 .expect("reconciled managed lease remains present");
7398 current.expected_head = Some(listed.head.clone());
7399 current.cleanup_failure = None;
7400 current.state = if current.has_holders() {
7401 ManagedWorktreeLeaseState::InUse
7402 } else if current.retention == ManagedWorktreeRetention::Retain {
7403 ManagedWorktreeLeaseState::Retained
7404 } else {
7405 ManagedWorktreeLeaseState::Ready
7406 };
7407 current.updated_at_unix_ms = unix_time_ms();
7408 current.snapshot()
7409 };
7410 if self.persist_state().is_err() {
7411 self.mark_managed_recovery_required(
7412 &lease.lease_id,
7413 ManagedWorktreeCleanupFailure::Backend,
7414 );
7415 continue;
7416 }
7417 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7418 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7419 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7420 }
7421 }
7422 }
7423
7424 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7444 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7445 .session_records
7446 .lock()
7447 .unwrap_or_else(|poisoned| poisoned.into_inner())
7448 .records
7449 .values()
7450 .filter(|record| {
7451 record.state == ManagedSessionState::Live
7452 && record.exported_context.is_none()
7453 && record.provider_session.is_some()
7454 })
7455 .filter_map(|record| {
7456 record
7457 .active_session
7458 .clone()
7459 .map(|session| (record.record_id.clone(), session))
7460 })
7461 .collect();
7462 for (record_id, session) in candidates {
7463 let shared = Arc::clone(self);
7464 tokio::spawn(async move {
7465 shared
7466 .reconcile_context_pack_export_for_record(&record_id, session)
7467 .await;
7468 });
7469 }
7470 }
7471
7472 async fn reconcile_context_pack_export_for_record(
7491 &self,
7492 record_id: &SessionRecordId,
7493 session: SessionAddress,
7494 ) {
7495 let Ok(record) = self.record(record_id) else {
7496 return;
7497 };
7498 if !matches!(
7499 record.state,
7500 ManagedSessionState::Live | ManagedSessionState::Dormant
7501 ) || record.exported_context.is_some()
7502 || record.provider_session.is_none()
7503 {
7504 return;
7505 }
7506 let Ok(receipt) = self
7513 .export_context_pack_for_session_record(&record.record_id, &session, true)
7514 .await
7515 else {
7516 return;
7517 };
7518 self.set_exported_context(&record.record_id, receipt);
7519 }
7520
7521 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7526 let transaction = self
7527 .state_transaction
7528 .lock()
7529 .unwrap_or_else(|poisoned| poisoned.into_inner());
7530 let (previous, updated) = {
7531 let mut records = self
7532 .session_records
7533 .lock()
7534 .unwrap_or_else(|poisoned| poisoned.into_inner());
7535 let Some(record) = records.records.get_mut(record_id) else {
7536 return;
7537 };
7538 if record.exported_context.is_some() {
7539 return;
7540 }
7541 let previous = record.clone();
7542 record.exported_context = Some(receipt);
7543 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7544 (previous, record.clone())
7545 };
7546 if self.persist_state_locked().is_err() {
7547 self.session_records
7548 .lock()
7549 .unwrap_or_else(|poisoned| poisoned.into_inner())
7550 .records
7551 .insert(record_id.clone(), previous);
7552 return;
7553 }
7554 drop(transaction);
7555 self.publish_record(updated);
7556 }
7557
7558 fn managed_record_export_target_for_instance(
7568 &self,
7569 instance_id: AgentInstanceId,
7570 generation: SessionGeneration,
7571 ) -> Option<(SessionRecordId, SessionAddress)> {
7572 let bindings = self
7573 .session_bindings
7574 .lock()
7575 .unwrap_or_else(|poisoned| poisoned.into_inner());
7576 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7577 let record_id = binding.record_id.clone()?;
7578 Some((
7579 record_id,
7580 SessionAddress {
7581 workspace_id: binding.workspace_id.clone(),
7582 session: SessionKey { instance_id, generation },
7583 },
7584 ))
7585 }
7586
7587 fn mark_managed_recovery_required(
7588 &self,
7589 lease_id: &ManagedWorktreeLeaseId,
7590 failure_kind: ManagedWorktreeCleanupFailure,
7591 ) {
7592 let snapshot = {
7593 let mut registry = self.managed_worktrees.lock()
7594 .unwrap_or_else(|poisoned| poisoned.into_inner());
7595 let Some(lease) = registry.get_mut(lease_id) else { return };
7596 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7597 lease.cleanup_failure = Some(failure_kind);
7598 lease.updated_at_unix_ms = unix_time_ms();
7599 lease.snapshot()
7600 };
7601 let _ = self.persist_state();
7602 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7603 }
7604
7605 fn set_persistence_error(&self, error: Option<String>) {
7606 *self
7607 .persistence_error
7608 .write()
7609 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7610 .map(|message| session_registry::sanitized_persistence_summary(&message));
7611 }
7612
7613 fn persist_state(&self) -> io::Result<()> {
7614 let _transaction = self
7615 .state_transaction
7616 .lock()
7617 .unwrap_or_else(|poisoned| poisoned.into_inner());
7618 self.persist_state_locked()
7619 }
7620
7621 fn persist_state_locked(&self) -> io::Result<()> {
7622 let workspaces = self
7623 .workspaces
7624 .read()
7625 .unwrap_or_else(|poisoned| poisoned.into_inner())
7626 .clone();
7627 let records = self
7628 .session_records
7629 .lock()
7630 .unwrap_or_else(|poisoned| poisoned.into_inner())
7631 .records
7632 .values()
7633 .cloned()
7634 .collect::<Vec<_>>();
7635 let mut managed = self.managed_worktrees
7636 .lock()
7637 .unwrap_or_else(|poisoned| poisoned.into_inner());
7638 managed.reattach_record_holders(
7639 &managed_worktree_record_holders(&records),
7640 unix_time_ms(),
7641 );
7642 let materializations = self
7643 .materializations
7644 .lock()
7645 .unwrap_or_else(|poisoned| poisoned.into_inner())
7646 .values()
7647 .cloned()
7648 .collect::<Vec<_>>();
7649 let managed_spawn_replays = self.managed_spawn_replays
7650 .lock()
7651 .unwrap_or_else(|poisoned| poisoned.into_inner())
7652 .values()
7653 .cloned()
7654 .collect::<Vec<_>>();
7655 let result = session_registry::save_v10(
7656 self.state_path.as_deref(),
7657 &self.node_id,
7658 &workspaces,
7659 &records,
7660 &managed.records(),
7661 &managed.tombstones(),
7662 &materializations,
7663 &managed_spawn_replays,
7664 );
7665 match &result {
7666 Ok(warning) => self.set_persistence_error(warning.clone()),
7667 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7668 }
7669 result.map(|_| ())
7670 }
7671
7672 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7673 let _mutation_guard = self.mutation_gate.lock().await;
7674 self.begin_shutdown_locked().await
7675 }
7676
7677 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7678 if self.shutdown.swap(true, Ordering::AcqRel) {
7679 return Ok(());
7680 }
7681 SessionLifecycleHook::on_shutdown(self);
7682 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7683 let mut first_error = None;
7684 for session in &self.handle.snapshot().sessions {
7685 let remaining = deadline.saturating_duration_since(Instant::now());
7686 if remaining.is_zero() {
7687 first_error.get_or_insert_with(|| {
7688 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7689 });
7690 break;
7691 }
7692 if let Err(error) = self
7693 .dispatch_bounded(
7694 ControlCommand::Stop {
7695 instance_id: session.instance_id,
7696 force: true,
7697 },
7698 remaining,
7699 )
7700 .await
7701 {
7702 first_error.get_or_insert(error);
7703 }
7704 }
7705 self.shutdown_notify.notify_waiters();
7706 match first_error {
7707 Some(error) => Err(error),
7708 None => Ok(()),
7709 }
7710 }
7711
7712 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7713 let bindings = self
7714 .session_bindings
7715 .lock()
7716 .unwrap_or_else(|poisoned| poisoned.into_inner());
7717 if bindings.len() >= CONTROL_SESSIONS_MAX {
7718 Err(failure(
7719 NodeFailureCode::BackendBusy,
7720 "node session-binding capacity is full; remove an existing session first",
7721 ))
7722 } else {
7723 Ok(())
7724 }
7725 }
7726
7727 fn ensure_workspace_environment_profile_compatible(
7734 &self,
7735 workspace_id: &WorkspaceId,
7736 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7737 ) -> Result<(), NodeFailure> {
7738 let bindings = self
7739 .session_bindings
7740 .lock()
7741 .unwrap_or_else(|poisoned| poisoned.into_inner());
7742 for binding in bindings.values() {
7743 if &binding.workspace_id != workspace_id {
7744 continue;
7745 }
7746 if binding.environment_profile.as_ref() != environment_profile {
7747 return Err(failure(
7748 NodeFailureCode::WorkspaceBusy,
7749 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7750 ));
7751 }
7752 }
7753 Ok(())
7754 }
7755
7756 #[cfg(test)]
7757 fn bind_session(
7758 &self,
7759 instance_id: AgentInstanceId,
7760 workspace_id: WorkspaceId,
7761 generation: SessionGeneration,
7762 ) {
7763 self.bind_session_with_policy(
7764 &SessionAddress {
7765 workspace_id,
7766 session: SessionKey { instance_id, generation },
7767 },
7768 ProviderRuntimePolicy::raw_pty(),
7769 None,
7770 );
7771 }
7772
7773 #[cfg(test)]
7774 fn bind_session_with_policy(
7775 &self,
7776 address: &SessionAddress,
7777 runtime_policy: ProviderRuntimePolicy,
7778 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7779 ) {
7780 self.bind_session_with_materialization(
7781 address,
7782 runtime_policy,
7783 environment_profile,
7784 None,
7785 None,
7786 None,
7787 );
7788 }
7789
7790 fn bind_session_with_materialization(
7791 &self,
7792 address: &SessionAddress,
7793 runtime_policy: ProviderRuntimePolicy,
7794 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7795 bundle: Option<ResolvedBundleReceipt>,
7796 context: Option<ResolvedContextPackReceipt>,
7797 materialization_id: Option<MaterializationId>,
7798 ) {
7799 let mut bindings = self
7800 .session_bindings
7801 .lock()
7802 .unwrap_or_else(|poisoned| poisoned.into_inner());
7803 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7804 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7805 bindings.insert(address.session.instance_id, SessionBinding {
7806 workspace_id: address.workspace_id.clone(),
7807 generation: address.session.generation,
7808 runtime_policy,
7809 pending_resume: None,
7810 record_id: None,
7811 managed_worktree_lease_id: None,
7812 environment_profile,
7813 bundle,
7814 context,
7815 materialization_id,
7816 });
7817 }
7818
7819 #[cfg(test)]
7820 fn bind_managed_session(
7821 &self,
7822 address: &SessionAddress,
7823 record_id: SessionRecordId,
7824 runtime_policy: ProviderRuntimePolicy,
7825 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7826 ) {
7827 self.bind_managed_session_with_materialization(
7828 address,
7829 record_id,
7830 runtime_policy,
7831 environment_profile,
7832 None,
7833 None,
7834 None,
7835 );
7836 }
7837
7838 fn bind_managed_session_with_materialization(
7839 &self,
7840 address: &SessionAddress,
7841 record_id: SessionRecordId,
7842 runtime_policy: ProviderRuntimePolicy,
7843 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7844 bundle: Option<ResolvedBundleReceipt>,
7845 context: Option<ResolvedContextPackReceipt>,
7846 materialization_id: Option<MaterializationId>,
7847 ) {
7848 let mut bindings = self
7849 .session_bindings
7850 .lock()
7851 .unwrap_or_else(|poisoned| poisoned.into_inner());
7852 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7853 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7854 bindings.insert(
7855 address.session.instance_id,
7856 SessionBinding {
7857 workspace_id: address.workspace_id.clone(),
7858 generation: address.session.generation,
7859 runtime_policy,
7860 pending_resume: None,
7861 record_id: Some(record_id),
7862 managed_worktree_lease_id: None,
7863 environment_profile,
7864 bundle,
7865 context,
7866 materialization_id,
7867 },
7868 );
7869 }
7870
7871 #[cfg(test)]
7872 fn bind_spawn_session(
7873 &self,
7874 address: &SessionAddress,
7875 provider: AgentId,
7876 mode: SessionMode,
7877 runtime_policy: ProviderRuntimePolicy,
7878 record_policy: SpawnRecordPolicy,
7879 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7880 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7881 self.bind_spawn_session_with_materialization(
7882 address,
7883 provider,
7884 mode,
7885 runtime_policy,
7886 record_policy,
7887 environment_profile,
7888 None,
7889 None,
7890 None,
7891 )
7892 }
7893
7894 fn bind_spawn_session_with_materialization(
7895 &self,
7896 address: &SessionAddress,
7897 provider: AgentId,
7898 mode: SessionMode,
7899 runtime_policy: ProviderRuntimePolicy,
7900 record_policy: SpawnRecordPolicy,
7901 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7902 bundle: Option<ResolvedBundleReceipt>,
7903 context: Option<ResolvedContextPackReceipt>,
7904 materialization_id: Option<MaterializationId>,
7905 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7906 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7907 && !runtime_policy.provider_session_identity
7908 {
7909 self.bind_session_with_materialization(
7910 address,
7911 runtime_policy,
7912 environment_profile,
7913 bundle,
7914 context,
7915 materialization_id,
7916 );
7917 return Ok(None);
7918 }
7919 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7920 let record = self.new_record(
7921 address,
7922 provider,
7923 mode,
7924 active_record_state(runtime_policy.provider_session_identity, false),
7925 None,
7926 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7927 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7928 record_owns_lifecycle.then(|| context.clone()).flatten(),
7929 )?;
7930 let record_id = record.record_id.clone();
7931 let transaction = self
7932 .state_transaction
7933 .lock()
7934 .unwrap_or_else(|poisoned| poisoned.into_inner());
7935 self.insert_record(record.clone())?;
7936 let previous_materialization = if record_owns_lifecycle {
7937 if let Some(materialization_id) = materialization_id.as_ref() {
7938 let mut materializations = self
7939 .materializations
7940 .lock()
7941 .unwrap_or_else(|poisoned| poisoned.into_inner());
7942 let Some(ownership) = materializations.get_mut(materialization_id) else {
7943 drop(materializations);
7944 self.remove_record_memory(&record_id);
7945 return Err(failure(
7946 NodeFailureCode::BackendOperationFailed,
7947 "session environment ownership disappeared before binding",
7948 ));
7949 };
7950 let previous = ownership.clone();
7951 if ownership
7952 .transfer_to_record(record_id.clone(), unix_time_ms())
7953 .is_err()
7954 {
7955 drop(materializations);
7956 self.remove_record_memory(&record_id);
7957 return Err(failure(
7958 NodeFailureCode::BackendOperationFailed,
7959 "session environment ownership transfer failed",
7960 ));
7961 }
7962 Some(previous)
7963 } else {
7964 None
7965 }
7966 } else {
7967 None
7968 };
7969 self.bind_managed_session_with_materialization(
7970 address,
7971 record_id.clone(),
7972 runtime_policy,
7973 environment_profile,
7974 bundle,
7975 context,
7976 materialization_id.clone(),
7977 );
7978 if let Err(error) = self.persist_state_locked() {
7979 self.remove_binding(address);
7980 self.remove_record_memory(&record_id);
7981 if let (Some(materialization_id), Some(previous)) =
7982 (materialization_id.as_ref(), previous_materialization)
7983 {
7984 self.materializations
7985 .lock()
7986 .unwrap_or_else(|poisoned| poisoned.into_inner())
7987 .insert(materialization_id.clone(), previous);
7988 }
7989 return Err(persistence_failure(error));
7990 }
7991 drop(transaction);
7992 self.publish_record(record);
7993 Ok(Some(record_id))
7994 }
7995
7996 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7997 for _ in 0..8 {
7998 let bytes = random_nonce().map_err(|error| {
7999 failure(
8000 NodeFailureCode::BackendOperationFailed,
8001 &format!("session record identity generation failed: {error}"),
8002 )
8003 })?;
8004 let mut value = String::with_capacity(27);
8005 value.push_str("sr-");
8006 for byte in &bytes[..12] {
8007 use std::fmt::Write as _;
8008 let _ = write!(&mut value, "{byte:02x}");
8009 }
8010 let record_id = SessionRecordId::new(value).map_err(|error| {
8011 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8012 })?;
8013 let records = self
8014 .session_records
8015 .lock()
8016 .unwrap_or_else(|poisoned| poisoned.into_inner());
8017 if !records.records.contains_key(&record_id) {
8018 return Ok(record_id);
8019 }
8020 }
8021 Err(failure(
8022 NodeFailureCode::BackendBusy,
8023 "could not allocate a unique session record ID",
8024 ))
8025 }
8026
8027 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8028 for _ in 0..8 {
8029 let bytes = random_nonce().map_err(|error| {
8030 failure(
8031 NodeFailureCode::BackendOperationFailed,
8032 &format!("task identity generation failed: {error}"),
8033 )
8034 })?;
8035 let mut nonce = [0_u8; 12];
8036 nonce.copy_from_slice(&bytes[..12]);
8037 let task_id = TaskId::from_nonce(nonce);
8038 let records = self
8039 .session_records
8040 .lock()
8041 .unwrap_or_else(|poisoned| poisoned.into_inner());
8042 if !records.records.values().any(|record| {
8043 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8044 == Some(&task_id)
8045 }) {
8046 return Ok(task_id);
8047 }
8048 }
8049 Err(failure(
8050 NodeFailureCode::BackendBusy,
8051 "could not allocate a unique task ID",
8052 ))
8053 }
8054
8055 fn default_record_name(&self, provider: &AgentId) -> String {
8056 let records = self
8057 .session_records
8058 .lock()
8059 .unwrap_or_else(|poisoned| poisoned.into_inner());
8060 let ordinal = records
8061 .records
8062 .values()
8063 .filter(|record| &record.provider == provider)
8064 .count()
8065 .saturating_add(1);
8066 format!("{} #{ordinal}", provider.as_str())
8067 }
8068
8069 fn new_record(
8070 &self,
8071 address: &SessionAddress,
8072 provider: AgentId,
8073 mode: SessionMode,
8074 state: ManagedSessionState,
8075 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8076 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8077 bundle: Option<ResolvedBundleReceipt>,
8078 context: Option<ResolvedContextPackReceipt>,
8079 ) -> Result<ManagedSessionRecord, NodeFailure> {
8080 let canonical_root = self.workspace_root(&address.workspace_id)?;
8081 let now = unix_time_ms();
8082 Ok(ManagedSessionRecord {
8083 record_id: self.allocate_record_id()?,
8084 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8085 provider,
8086 mode,
8087 state,
8088 workspace_id: address.workspace_id.clone(),
8089 canonical_root: opaque_windows_path(canonical_root),
8090 provider_session,
8091 active_session: Some(address.clone()),
8092 environment_profile,
8093 bundle,
8094 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8095 context,
8096 exported_context: None,
8097 task_binding: None,
8098 created_at_unix_ms: now,
8099 updated_at_unix_ms: now,
8100 last_error: None,
8101 })
8102 }
8103
8104 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8105 let mut records = self
8106 .session_records
8107 .lock()
8108 .unwrap_or_else(|poisoned| poisoned.into_inner());
8109 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8110 return Err(failure(
8111 NodeFailureCode::BackendBusy,
8112 "managed session record capacity is full; forget an inactive record first",
8113 ));
8114 }
8115 if let Some(identity) = record.provider_session.as_ref() {
8116 if records.records.values().any(|candidate| {
8117 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8118 session_registry::same_provider_session(
8119 &candidate.provider,
8120 candidate_identity,
8121 &record.provider,
8122 identity,
8123 )
8124 })
8125 }) {
8126 return Err(failure(
8127 NodeFailureCode::SessionRecordConflict,
8128 "provider session is already represented by another managed record",
8129 ));
8130 }
8131 }
8132 match records.records.entry(record.record_id.clone()) {
8133 std::collections::btree_map::Entry::Vacant(entry) => {
8134 entry.insert(record);
8135 }
8136 std::collections::btree_map::Entry::Occupied(_) => {
8137 return Err(failure(
8138 NodeFailureCode::SessionRecordConflict,
8139 "session record ID collision",
8140 ));
8141 }
8142 }
8143 Ok(())
8144 }
8145
8146 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8147 self.session_records
8148 .lock()
8149 .unwrap_or_else(|poisoned| poisoned.into_inner())
8150 .records
8151 .get(record_id)
8152 .cloned()
8153 .ok_or_else(|| {
8154 failure(
8155 NodeFailureCode::UnknownSessionRecord,
8156 "managed session record does not exist",
8157 )
8158 })
8159 }
8160
8161 fn revalidate_session_record_preview(
8162 &self,
8163 expected: &ManagedSessionRecord,
8164 identity: &ProviderSessionIdentity,
8165 ) -> Result<(), NodeFailure> {
8166 let current = self.record(&expected.record_id)?;
8167 let current_root = self.workspace_root(¤t.workspace_id)?;
8168 if current.provider != expected.provider
8169 || current.workspace_id != expected.workspace_id
8170 || current.canonical_root != expected.canonical_root
8171 || current.provider_session.as_ref() != Some(identity)
8172 || !platform::roots_equal(
8173 ¤t_root,
8174 windows_path_text(¤t.canonical_root),
8175 )
8176 {
8177 return Err(failure(
8178 NodeFailureCode::SessionRecordConflict,
8179 "managed session changed while its preview was loading",
8180 ));
8181 }
8182 Ok(())
8183 }
8184
8185 fn revalidate_session_record_context_export(
8186 &self,
8187 expected: &ManagedSessionRecord,
8188 identity: Option<&ProviderSessionIdentity>,
8189 session: &SessionAddress,
8190 allow_clean_detachment: bool,
8191 ) -> Result<(), NodeFailure> {
8192 let current = self.record(&expected.record_id)?;
8193 let current_root = self.workspace_root(¤t.workspace_id)?;
8194 let snapshot = self.internal_session_snapshot(session)?;
8195 let binding = self
8196 .session_bindings
8197 .lock()
8198 .unwrap_or_else(|poisoned| poisoned.into_inner())
8199 .get(&session.session.instance_id)
8200 .cloned()
8201 .ok_or_else(|| {
8202 failure(
8203 NodeFailureCode::SessionRecordConflict,
8204 "managed session runtime binding disappeared during context export",
8205 )
8206 })?;
8207 if !session_record_context_export_binding_is_exact(
8208 expected,
8209 ¤t,
8210 identity,
8211 session,
8212 &binding,
8213 &snapshot.agent_id,
8214 ¤t_root,
8215 allow_clean_detachment,
8216 ) || !session_record_context_export_source_is_usable(
8217 ¤t,
8218 &snapshot.status,
8219 allow_clean_detachment,
8220 ) {
8221 return Err(failure(
8222 NodeFailureCode::SessionRecordConflict,
8223 "managed session binding changed during context export",
8224 ));
8225 }
8226 Ok(())
8227 }
8228
8229 fn revalidate_loaded_history(
8230 &self,
8231 session: &SessionAddress,
8232 candidate_id: &str,
8233 loaded: &HistorySessionRecord,
8234 ) -> Result<(), NodeFailure> {
8235 let snapshot = self.internal_session_snapshot(session)?;
8236 if snapshot.history.pending.is_some()
8237 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8238 || snapshot.history.loaded.as_ref() != Some(loaded)
8239 {
8240 return Err(failure(
8241 NodeFailureCode::SessionRecordConflict,
8242 "managed session history changed during context export",
8243 ));
8244 }
8245 Ok(())
8246 }
8247
8248 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8249 self.session_records
8250 .lock()
8251 .unwrap_or_else(|poisoned| poisoned.into_inner())
8252 .records
8253 .remove(record_id)
8254 }
8255
8256 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8257 if self.destroy_record_materialization(record_id)? {
8258 self.publish(NodeEvent::SessionRecordRemoved {
8259 record_id: record_id.clone(),
8260 });
8261 return Ok(());
8262 }
8263 let transaction = self
8264 .state_transaction
8265 .lock()
8266 .unwrap_or_else(|poisoned| poisoned.into_inner());
8267 let Some(removed) = self.remove_record_memory(record_id) else {
8268 return Ok(());
8269 };
8270 if let Err(error) = self.persist_state_locked() {
8271 let _ = self.insert_record(removed);
8272 return Err(persistence_failure(error));
8273 }
8274 drop(transaction);
8275 self.publish(NodeEvent::SessionRecordRemoved {
8276 record_id: record_id.clone(),
8277 });
8278 Ok(())
8279 }
8280
8281 fn publish_record(&self, record: ManagedSessionRecord) {
8282 self.publish(NodeEvent::SessionRecordUpserted { record });
8283 }
8284
8285 fn mark_record_error(
8286 &self,
8287 record_id: &SessionRecordId,
8288 message: &str,
8289 ) -> Result<(), NodeFailure> {
8290 let transaction = self
8291 .state_transaction
8292 .lock()
8293 .unwrap_or_else(|poisoned| poisoned.into_inner());
8294 let previous = {
8295 let mut records = self
8296 .session_records
8297 .lock()
8298 .unwrap_or_else(|poisoned| poisoned.into_inner());
8299 let Some(record) = records.records.get_mut(record_id) else {
8300 return Ok(());
8301 };
8302 let previous = record.clone();
8303 record.state = ManagedSessionState::Unavailable;
8304 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8305 record.updated_at_unix_ms = unix_time_ms();
8306 previous
8307 };
8308 if let Err(error) = self.persist_state_locked() {
8309 self.session_records
8310 .lock()
8311 .unwrap_or_else(|poisoned| poisoned.into_inner())
8312 .records
8313 .insert(record_id.clone(), previous);
8314 return Err(persistence_failure(error));
8315 }
8316 let record = self.record(record_id)?;
8317 drop(transaction);
8318 self.publish_record(record);
8319 Ok(())
8320 }
8321
8322 fn rename_session_record(
8323 &self,
8324 record_id: &SessionRecordId,
8325 display_name: String,
8326 ) -> Result<ManagedSessionRecord, NodeFailure> {
8327 validate_display_name(&display_name)
8328 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8329 let _transaction = self
8330 .state_transaction
8331 .lock()
8332 .unwrap_or_else(|poisoned| poisoned.into_inner());
8333 let previous = {
8334 let mut records = self
8335 .session_records
8336 .lock()
8337 .unwrap_or_else(|poisoned| poisoned.into_inner());
8338 let record = records.records.get_mut(record_id).ok_or_else(|| {
8339 failure(
8340 NodeFailureCode::UnknownSessionRecord,
8341 "managed session record does not exist",
8342 )
8343 })?;
8344 let previous = record.clone();
8345 record.display_name = display_name;
8346 record.updated_at_unix_ms = unix_time_ms();
8347 previous
8348 };
8349 if let Err(error) = self.persist_state_locked() {
8350 self.session_records
8351 .lock()
8352 .unwrap_or_else(|poisoned| poisoned.into_inner())
8353 .records
8354 .insert(record_id.clone(), previous);
8355 return Err(persistence_failure(error));
8356 }
8357 let updated = self.record(record_id)?;
8358 self.publish_record(updated.clone());
8359 Ok(updated)
8360 }
8361
8362 fn set_session_task(
8363 &self,
8364 record_id: &SessionRecordId,
8365 expected_revision: u64,
8366 target: SessionTaskTargetV1,
8367 ) -> Result<ManagedSessionRecord, NodeFailure> {
8368 let transaction = self
8369 .state_transaction
8370 .lock()
8371 .unwrap_or_else(|poisoned| poisoned.into_inner());
8372 let current = self.record(record_id)?;
8373 let current_task_id = current
8374 .task_binding
8375 .as_ref()
8376 .and_then(|binding| binding.task_id.as_ref());
8377 let current_revision = current
8378 .task_binding
8379 .as_ref()
8380 .map_or(0, |binding| binding.revision);
8381 if expected_revision != current_revision {
8382 return Err(failure(
8383 NodeFailureCode::SessionRecordConflict,
8384 "managed session task binding revision changed",
8385 ));
8386 }
8387 let exact = match &target {
8388 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8389 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8390 SessionTaskTargetV1::New => false,
8391 };
8392 if exact {
8393 return Ok(current);
8394 }
8395 let revision = current_revision.checked_add(1).ok_or_else(|| {
8396 failure(
8397 NodeFailureCode::SessionRecordConflict,
8398 "managed session task binding revision is exhausted",
8399 )
8400 })?;
8401 let task_id = match target {
8402 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8403 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8404 SessionTaskTargetV1::Clear => None,
8405 };
8406 let changed_at_unix_ms = unix_time_ms()
8407 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8408 failure(
8409 NodeFailureCode::SessionRecordConflict,
8410 "managed session record timestamp is exhausted",
8411 )
8412 })?);
8413 let updated = {
8414 let mut records = self
8415 .session_records
8416 .lock()
8417 .unwrap_or_else(|poisoned| poisoned.into_inner());
8418 let record = records.records.get_mut(record_id).ok_or_else(|| {
8419 failure(
8420 NodeFailureCode::UnknownSessionRecord,
8421 "managed session record does not exist",
8422 )
8423 })?;
8424 record.task_binding = Some(SessionTaskBindingV1 {
8425 revision,
8426 task_id,
8427 changed_at_unix_ms,
8428 });
8429 record.updated_at_unix_ms = changed_at_unix_ms;
8430 record.clone()
8431 };
8432 if let Err(error) = self.persist_state_locked() {
8433 self.session_records
8434 .lock()
8435 .unwrap_or_else(|poisoned| poisoned.into_inner())
8436 .records
8437 .insert(record_id.clone(), current);
8438 return Err(persistence_failure(error));
8439 }
8440 drop(transaction);
8441 self.publish_record(updated.clone());
8442 Ok(updated)
8443 }
8444
8445 fn index_provider_session(
8446 &self,
8447 workspace_id: WorkspaceId,
8448 provider: AgentId,
8449 identity: ProviderSessionIdentity,
8450 display_name: String,
8451 ) -> Result<ManagedSessionRecord, NodeFailure> {
8452 self.index_provider_session_with_policy(
8453 workspace_id,
8454 provider,
8455 identity,
8456 display_name,
8457 false,
8458 )
8459 }
8460
8461 fn index_provider_session_with_policy(
8462 &self,
8463 workspace_id: WorkspaceId,
8464 provider: AgentId,
8465 identity: ProviderSessionIdentity,
8466 display_name: String,
8467 allow_validated_transcript_path: bool,
8468 ) -> Result<ManagedSessionRecord, NodeFailure> {
8469 validate_display_name(&display_name)
8470 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8471 identity
8472 .validate()
8473 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8474 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8475 return Err(failure(
8476 NodeFailureCode::InvalidRequest,
8477 "provider session reference index v1 does not accept transcript paths",
8478 ));
8479 }
8480 let canonical_root = self.workspace_root(&workspace_id)?;
8481 if !self.enabled_providers.contains(&provider) {
8482 return Err(failure(
8483 NodeFailureCode::InvalidRequest,
8484 "provider is not enabled on this node",
8485 ));
8486 }
8487
8488 let transaction = self
8489 .state_transaction
8490 .lock()
8491 .unwrap_or_else(|poisoned| poisoned.into_inner());
8492 {
8493 let records = self
8494 .session_records
8495 .lock()
8496 .unwrap_or_else(|poisoned| poisoned.into_inner());
8497 if let Some(existing) = records.records.values().find(|candidate| {
8498 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8499 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8500 })
8501 }) {
8502 if existing.provider == provider && existing.workspace_id == workspace_id {
8503 return Ok(existing.clone());
8504 }
8505 return Err(failure(
8506 NodeFailureCode::SessionRecordConflict,
8507 "provider session reference conflicts with another provider or workspace",
8508 ));
8509 }
8510 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8511 return Err(failure(
8512 NodeFailureCode::BackendBusy,
8513 "managed session record capacity is full; forget an inactive record first",
8514 ));
8515 }
8516 }
8517
8518 let now = unix_time_ms();
8519 let record = ManagedSessionRecord {
8520 record_id: self.allocate_record_id()?,
8521 display_name,
8522 provider,
8523 mode: SessionMode::Pty,
8524 state: ManagedSessionState::Dormant,
8525 workspace_id,
8526 canonical_root: opaque_windows_path(canonical_root),
8527 provider_session: Some(identity),
8528 active_session: None,
8529 environment_profile: None,
8530 bundle: None,
8531 context_id: None,
8532 context: None,
8533 exported_context: None,
8534 task_binding: None,
8535 created_at_unix_ms: now,
8536 updated_at_unix_ms: now,
8537 last_error: None,
8538 };
8539 self.insert_record(record.clone())?;
8540 if let Err(error) = self.persist_state_locked() {
8541 self.remove_record_memory(&record.record_id);
8542 return Err(persistence_failure(error));
8543 }
8544 drop(transaction);
8545 self.publish_record(record.clone());
8546 Ok(record)
8547 }
8548
8549 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8550 self.session_bindings
8551 .lock()
8552 .unwrap_or_else(|poisoned| poisoned.into_inner())
8553 .iter()
8554 .find_map(|(instance_id, binding)| {
8555 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8556 workspace_id: binding.workspace_id.clone(),
8557 session: SessionKey {
8558 instance_id: *instance_id,
8559 generation: binding.generation,
8560 },
8561 })
8562 })
8563 }
8564
8565 async fn forget_session_record(
8566 &self,
8567 record_id: &SessionRecordId,
8568 ) -> Result<(), NodeFailure> {
8569 let record = self.record(record_id)?;
8570 if record.active_session.is_some()
8571 || matches!(
8572 record.state,
8573 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8574 )
8575 {
8576 return Err(failure(
8577 NodeFailureCode::SessionRecordBusy,
8578 "stop the managed session before forgetting it",
8579 ));
8580 }
8581 if let Some(address) = self.bound_address_for_record(record_id) {
8582 self.remove_session(&address).await?;
8583 }
8584 self.remove_managed_session_record(record_id, &record.workspace_id).await
8585 }
8586
8587 async fn remove_managed_session_record(
8602 &self,
8603 record_id: &SessionRecordId,
8604 workspace_id: &WorkspaceId,
8605 ) -> Result<(), NodeFailure> {
8606 let managed_lease_id = self.managed_worktrees.lock()
8607 .unwrap_or_else(|poisoned| poisoned.into_inner())
8608 .lease_for_workspace(workspace_id);
8609 if !self.destroy_record_materialization(record_id)? {
8610 let transaction = self
8611 .state_transaction
8612 .lock()
8613 .unwrap_or_else(|poisoned| poisoned.into_inner());
8614 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8615 failure(
8616 NodeFailureCode::UnknownSessionRecord,
8617 "managed session record does not exist",
8618 )
8619 })?;
8620 if let Err(error) = self.persist_state_locked() {
8621 let _ = self.insert_record(removed);
8622 return Err(persistence_failure(error));
8623 }
8624 drop(transaction);
8625 }
8626 self.publish(NodeEvent::SessionRecordRemoved {
8627 record_id: record_id.clone(),
8628 });
8629 if let Some(lease_id) = managed_lease_id {
8630 if let Some(snapshot) = self.managed_worktrees.lock()
8631 .unwrap_or_else(|poisoned| poisoned.into_inner())
8632 .get(&lease_id)
8633 .map(ManagedWorktreeLeaseRecord::snapshot)
8634 {
8635 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8636 }
8637 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8638 }
8639 Ok(())
8640 }
8641
8642 async fn retire_unavailable_session_records(&self) {
8655 let policy = self.session_record_retention;
8656 let age_enabled = policy.age_ms > 0;
8657 let keep_enabled = policy.keep_per_workspace > 0;
8658 if !age_enabled && !keep_enabled {
8659 return;
8660 }
8661 let now = unix_time_ms();
8662 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8663 self.session_records
8664 .lock()
8665 .unwrap_or_else(|poisoned| poisoned.into_inner())
8666 .records
8667 .values()
8668 .filter(|record| record.state == ManagedSessionState::Unavailable)
8669 .map(|record| {
8670 (
8671 record.record_id.clone(),
8672 record.workspace_id.clone(),
8673 record.provider.clone(),
8674 record.updated_at_unix_ms,
8675 )
8676 })
8677 .collect()
8678 };
8679 if candidates.is_empty() {
8680 return;
8681 }
8682 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8683 BTreeMap::new();
8684 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8685 by_workspace
8686 .entry(workspace_id)
8687 .or_default()
8688 .push((record_id, provider, updated_at_unix_ms));
8689 }
8690 let reason = if age_enabled && keep_enabled {
8691 "age+keep-n"
8692 } else if age_enabled {
8693 "age"
8694 } else {
8695 "keep-n"
8696 };
8697 let mut retired: u64 = 0;
8698 for (workspace_id, mut records) in by_workspace {
8699 records.sort_by(|left, right| right.2.cmp(&left.2));
8702 for (index, (record_id, provider, updated_at_unix_ms)) in
8703 records.into_iter().enumerate()
8704 {
8705 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8706 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8707 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8708 let should_retire = if age_enabled && keep_enabled {
8709 age_hit && keep_hit
8710 } else {
8711 age_hit || keep_hit
8712 };
8713 if !should_retire {
8714 continue;
8715 }
8716 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8717 Ok(()) => {
8718 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8719 retired += 1;
8720 tracing::info!(
8721 node_id = %self.node_id,
8722 record_id = %record_id,
8723 workspace_id = %workspace_id,
8724 provider = %provider,
8725 age_ms = record_age_ms,
8726 reason,
8727 "managed session record retired"
8728 );
8729 }
8730 Err(error) => tracing::warn!(
8731 node_id = %self.node_id,
8732 record_id = %record_id,
8733 workspace_id = %workspace_id,
8734 code = ?error.code,
8735 cause = %error.message,
8736 "session record retention sweep failed to retire a record"
8737 ),
8738 }
8739 }
8740 }
8741 if retired > 0 {
8742 let remaining_unavailable = self
8743 .session_records
8744 .lock()
8745 .unwrap_or_else(|poisoned| poisoned.into_inner())
8746 .records
8747 .values()
8748 .filter(|record| record.state == ManagedSessionState::Unavailable)
8749 .count();
8750 tracing::info!(
8751 node_id = %self.node_id,
8752 retired,
8753 remaining_unavailable,
8754 "session record retention sweep completed"
8755 );
8756 }
8757 }
8758
8759 async fn resume_session_record(
8760 &self,
8761 record_id: &SessionRecordId,
8762 terminal_size: gate4agent_types::TerminalSize,
8763 initial_prompt: Option<String>,
8764 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8765 let mut record = self.record(record_id)?;
8766 if record.mode == SessionMode::Acp
8769 && record.provider.as_str() == "claude"
8770 && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8771 {
8772 record.mode = SessionMode::Pty;
8773 if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8774 r.mode = SessionMode::Pty;
8775 }
8776 tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8777 }
8778 if record.active_session.is_some()
8779 || matches!(
8780 record.state,
8781 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8782 )
8783 {
8784 return Err(failure(
8785 NodeFailureCode::SessionRecordBusy,
8786 "managed session already has a live runtime binding",
8787 ));
8788 }
8789 let identity = record.provider_session.clone().ok_or_else(|| {
8790 failure(
8791 NodeFailureCode::SessionRecordNotResumable,
8792 "managed session has no verified provider session identity",
8793 )
8794 })?;
8795 let working_directory = self.workspace_root(&record.workspace_id)?;
8796 if !platform::roots_equal(
8797 &working_directory,
8798 windows_path_text(&record.canonical_root),
8799 ) {
8800 return Err(failure(
8801 NodeFailureCode::SessionRecordConflict,
8802 "managed session workspace root changed; refusing to resume in another directory",
8803 ));
8804 }
8805 {
8806 let records = self
8807 .session_records
8808 .lock()
8809 .unwrap_or_else(|poisoned| poisoned.into_inner());
8810 if records.records.values().any(|candidate| {
8811 candidate.record_id != *record_id
8812 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8813 session_registry::same_provider_session(
8814 &candidate.provider,
8815 candidate_identity,
8816 &record.provider,
8817 &identity,
8818 )
8819 })
8820 && (candidate.active_session.is_some()
8821 || matches!(
8822 candidate.state,
8823 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8824 ))
8825 }) {
8826 return Err(failure(
8827 NodeFailureCode::SessionRecordConflict,
8828 "provider session is already active through another managed record",
8829 ));
8830 }
8831 }
8832 let runtime_requirement = if initial_prompt.is_some() {
8833 ProviderRuntimeRequirement::ResumeWithPrompt
8834 } else {
8835 ProviderRuntimeRequirement::Resume
8836 };
8837 let runtime_policy = self.admit_provider_runtime(
8838 &record.provider,
8839 runtime_requirement,
8840 )
8841 .await?;
8842 let working_directory = match self.m4a_accounts.as_ref() {
8844 Some(_) => {
8845 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8846 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8847 }
8848 None => working_directory,
8849 };
8850 let request = ResumeLaunchRequest {
8851 working_directory,
8852 terminal_size,
8853 initial_prompt,
8854 };
8855 request
8856 .validate()
8857 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8858 self.ensure_binding_capacity()?;
8859 let resolved_materialization = match self.resolve_record_materialization(
8860 record_id,
8861 &record.provider,
8862 record.mode,
8863 record.environment_profile.as_ref(),
8864 record.bundle.as_ref(),
8865 record.context.as_ref(),
8866 ) {
8867 Ok(materialization) => materialization,
8868 Err(error) => {
8869 let label = match error.code {
8870 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8871 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8872 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8873 _ => "environment-profile-unavailable",
8874 };
8875 self.mark_record_error(record_id, label)?;
8876 return Err(error);
8877 }
8878 };
8879 let (materialization_id, environment_overlay) = match resolved_materialization {
8880 Some((id, overlay)) => (Some(id), overlay),
8881 None => (None, None),
8882 };
8883 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8884 let environment_overlay = {
8886 let mut args: Vec<OsString> = Vec::new();
8887 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8888 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8889 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8890 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8891 }
8892 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8893 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8894 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8895 if let Some(sel) = options.as_ref() {
8896 args.push("--model".into()); args.push(sel.model.clone().into());
8897 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8898 args.push("--effort".into()); args.push(effort.to_owned().into());
8899 }
8900 }
8901 }
8902 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8903 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8904 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8905 instance_id: instance_id.0,
8906 provider: record.provider.as_str().to_owned(),
8907 workspace_id: record.workspace_id.as_str().to_owned(),
8908 provider_session_id: identity.id.clone(),
8909 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8910 cwd: String::new(),
8911 acp: record.mode == SessionMode::Acp,
8912 options,
8913 cols: 0,
8914 rows: 0,
8915 });
8916 }
8917 }
8918 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8919 };
8920 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8922 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8923 if let Some(old) = self.bound_address_for_record(record_id) {
8924 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8925 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8926 }
8927 }
8928 let session_key = self.m4a_session_key(instance_id);
8929 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8930 }
8931 _ => environment_overlay,
8932 };
8933 let mut environment_selection = match SpawnPreparer::select_environment(self,
8934 instance_id,
8935 &record.provider,
8936 record.mode,
8937 record.environment_profile.as_ref(),
8938 ) {
8939 Ok(selection) => selection,
8940 Err(error) => {
8941 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8942 return Err(error);
8943 }
8944 };
8945 let mut instance_overlay = environment_overlay
8946 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8947 .transpose()?
8948 .flatten();
8949 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8950 self.remove_session(&stale_address).await?;
8951 }
8952 let address = SessionAddress {
8953 workspace_id: record.workspace_id.clone(),
8954 session: SessionKey {
8955 instance_id,
8956 generation: SessionGeneration(1),
8957 },
8958 };
8959 let transaction = self
8960 .state_transaction
8961 .lock()
8962 .unwrap_or_else(|poisoned| poisoned.into_inner());
8963 let current = self.record(record_id)?;
8964 if current.active_session.is_some()
8965 || matches!(
8966 current.state,
8967 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8968 )
8969 {
8970 return Err(failure(
8971 NodeFailureCode::SessionRecordBusy,
8972 "managed session became active while resume was being prepared",
8973 ));
8974 }
8975 self.bind_managed_session_with_materialization(
8976 &address,
8977 record_id.clone(),
8978 runtime_policy,
8979 record.environment_profile.clone(),
8980 record.bundle.clone(),
8981 record.context.clone(),
8982 materialization_id,
8983 );
8984 if let Some(selection) = environment_selection.take() {
8985 selection.retain();
8986 }
8987 if let Some(overlay) = instance_overlay.take() {
8988 overlay.retain();
8989 }
8990 drop(transaction);
8991
8992 let transport = match record.mode {
8993 SessionMode::Pty => TransportKind::Pty,
8994 SessionMode::Inline => TransportKind::Pipe,
8995 SessionMode::Acp => TransportKind::Acp,
8996 };
8997 let agent_id = record.provider.clone();
8998 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8999 if let Err(error) = self
9000 .dispatch_bounded(
9001 ControlCommand::Register {
9002 instance_id,
9003 agent_id,
9004 transport,
9005 },
9006 dispatch_timeout,
9007 )
9008 .await
9009 {
9010 if let Some(binding) = self.remove_binding(&address) {
9011 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9012 }
9013 return Err(error);
9014 }
9015 let command = self.prepare_command(ControlCommand::Resume {
9016 instance_id,
9017 target: ResumeTarget::ProviderSession { identity },
9018 runtime_policy,
9019 request,
9020 });
9021 let started_after = self.current_sequence();
9022 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9023 self.rollback_spawn(&address, dispatch_timeout).await?;
9024 return Err(error);
9025 }
9026 if let Err(error) = self.dispatch_envelope(command) {
9027 self.clear_armed_resume(&address);
9028 self.rollback_spawn(&address, dispatch_timeout).await?;
9029 return Err(error);
9030 }
9031 match self
9032 .wait_for_record_resume(&address, started_after)
9033 .await
9034 {
9035 Ok(settled_address) => {
9036 let managed_lease_id = {
9037 self.managed_worktrees.lock()
9038 .unwrap_or_else(|poisoned| poisoned.into_inner())
9039 .lease_for_workspace(&record.workspace_id)
9040 };
9041 if let Some(lease_id) = managed_lease_id {
9042 {
9043 let mut bindings = self.session_bindings.lock()
9044 .unwrap_or_else(|poisoned| poisoned.into_inner());
9045 let binding = bindings.get_mut(&settled_address.session.instance_id)
9046 .ok_or_else(|| failure(
9047 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9048 "resumed managed session lost its exact binding",
9049 ))?;
9050 if binding.generation != settled_address.session.generation {
9051 return Err(failure(
9052 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9053 "resumed managed session generation diverged",
9054 ));
9055 }
9056 binding.managed_worktree_lease_id = Some(lease_id.clone());
9057 }
9058 let snapshot = self.managed_worktrees.lock()
9059 .unwrap_or_else(|poisoned| poisoned.into_inner())
9060 .bind_session(
9061 &lease_id,
9062 ManagedWorktreeSessionHolder {
9063 incarnation_id: self.incarnation_id,
9064 instance_id: settled_address.session.instance_id,
9065 generation: settled_address.session.generation,
9066 },
9067 Some(record_id.clone()),
9068 unix_time_ms(),
9069 ).map_err(|message| failure(
9070 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071 &message,
9072 ))?;
9073 if let Err(error) = self.persist_state() {
9074 let _ = self.remove_session(&settled_address).await;
9075 return Err(persistence_failure(error));
9076 }
9077 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9078 }
9079 Ok((self.record(record_id)?, settled_address))
9080 }
9081 Err(error) => {
9082 if matches!(
9083 error.code,
9084 NodeFailureCode::SessionRecordNotResumable
9085 | NodeFailureCode::BackendOperationFailed
9086 ) {
9087 if let Some(bound) = self.bound_address_for_record(record_id) {
9088 let _ = self.remove_session(&bound).await;
9089 }
9090 }
9091 Err(error)
9092 }
9093 }
9094 }
9095
9096 async fn wait_for_record_resume(
9097 &self,
9098 initial_address: &SessionAddress,
9099 after_sequence: u64,
9100 ) -> Result<SessionAddress, NodeFailure> {
9101 let deadline = Instant::now()
9102 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9103 let mut scan_after = after_sequence;
9104 loop {
9105 let events = self
9106 .history
9107 .lock()
9108 .unwrap_or_else(|poisoned| poisoned.into_inner())
9109 .events
9110 .iter()
9111 .filter(|event| event.sequence > scan_after)
9112 .cloned()
9113 .collect::<Vec<_>>();
9114 for envelope in events {
9115 scan_after = scan_after.max(envelope.sequence);
9116 let NodeEvent::Control { address, event } = envelope.event else {
9117 continue;
9118 };
9119 if address.session.instance_id != initial_address.session.instance_id {
9120 continue;
9121 }
9122 match event.event {
9123 ControlEventKind::Resumed { .. } => return Ok(address),
9124 ControlEventKind::ResumeDenied { reason } => {
9125 return Err(failure(
9126 NodeFailureCode::SessionRecordNotResumable,
9127 &reason,
9128 ));
9129 }
9130 ControlEventKind::ResumeFailed { message }
9131 | ControlEventKind::CommandRejected { message } => {
9132 return Err(failure(
9133 NodeFailureCode::BackendOperationFailed,
9134 &message,
9135 ));
9136 }
9137 _ => {}
9138 }
9139 }
9140 if Instant::now() >= deadline {
9141 return Err(failure(
9142 NodeFailureCode::BackendBusy,
9143 "managed resume did not settle before the bounded deadline",
9144 ));
9145 }
9146 sleep(Duration::from_millis(5)).await;
9147 }
9148 }
9149
9150 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9151 self.workspaces
9152 .read()
9153 .unwrap_or_else(|poisoned| poisoned.into_inner())
9154 .get(workspace_id)
9155 .cloned()
9156 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9157 }
9158
9159 fn workspace_roots(&self) -> Vec<PathBuf> {
9160 self.workspaces
9161 .read()
9162 .unwrap_or_else(|poisoned| poisoned.into_inner())
9163 .values()
9164 .map(PathBuf::from)
9165 .collect()
9166 }
9167
9168 async fn inspect_workspace(
9169 &self,
9170 workspace_id: WorkspaceId,
9171 ) -> Result<WorkspaceInspection, NodeFailure> {
9172 let _permit = self
9173 .inspection_slots
9174 .clone()
9175 .try_acquire_owned()
9176 .map_err(|_| {
9177 tracing::warn!(
9178 workspace_id = %workspace_id,
9179 "workspace inspection rejected: inspection capacity is busy",
9180 );
9181 failure(
9182 NodeFailureCode::BackendBusy,
9183 "workspace inspection capacity is busy",
9184 )
9185 })?;
9186 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9187 tracing::warn!(
9188 workspace_id = %workspace_id,
9189 code = ?error.code,
9190 "workspace inspection rejected: workspace is not registered",
9191 );
9192 error
9193 })?;
9194 let tree_root = canonical_root.clone();
9195 let started_at = Instant::now();
9196 let time_budget_ms = workspace_inspection_time_budget_ms();
9197 let entry_cap = workspace_inspection_entry_cap();
9198 let deadline = started_at + Duration::from_millis(time_budget_ms);
9199 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9200 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9201 })
9202 .await
9203 .map_err(|error| {
9204 tracing::warn!(
9205 workspace_id = %workspace_id,
9206 error = %error,
9207 "workspace inspection rejected: walk task failed",
9208 );
9209 failure(
9210 NodeFailureCode::BackendOperationFailed,
9211 &format!("workspace inspection task failed: {error}"),
9212 )
9213 })?;
9214 let (mut git, git_time_budget_exceeded) =
9215 inspect_git_workspace(&canonical_root, deadline).await;
9216 if !git.worktrees.is_empty() {
9217 let registered = self
9218 .workspaces
9219 .read()
9220 .unwrap_or_else(|poisoned| poisoned.into_inner())
9221 .iter()
9222 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9223 .collect::<Vec<_>>();
9224 for worktree in &mut git.worktrees {
9225 worktree.workspace_id = registered
9226 .iter()
9227 .find(|(_, root)| {
9228 worktree_paths_equal(root, windows_path_text(&worktree.path))
9229 })
9230 .map(|(workspace_id, _)| workspace_id.clone());
9231 }
9232 }
9233 if git.is_repository {
9234 git.managed_worktree = self
9235 .managed_worktrees
9236 .lock()
9237 .unwrap_or_else(|poisoned| poisoned.into_inner())
9238 .git_scope(&workspace_id, git.branch.as_deref());
9239 }
9240 let truncation = workspace_inspection_truncation(
9241 &walk_budget,
9242 git_time_budget_exceeded,
9243 started_at.elapsed(),
9244 );
9245 if let Some(truncation) = &truncation {
9246 tracing::info!(
9247 workspace_id = %workspace_id,
9248 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9249 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9250 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9251 entries_visited = truncation.entries_visited,
9252 elapsed_ms = truncation.elapsed_ms,
9253 "workspace inspection truncated by its inner budget",
9254 );
9255 }
9256 Ok(WorkspaceInspection {
9257 workspace_id,
9258 entries,
9259 tree_truncated,
9260 git,
9261 truncation,
9262 })
9263 }
9264
9265 async fn browse_host_directories(
9266 &self,
9267 directory: Option<OpaqueHostPath>,
9268 after: Option<OpaqueHostPath>,
9269 ) -> Result<HostDirectoryListing, NodeFailure> {
9270 let permit = self
9271 .inspection_slots
9272 .clone()
9273 .try_acquire_owned()
9274 .map_err(|_| {
9275 failure(
9276 NodeFailureCode::BackendBusy,
9277 "host directory browse capacity is busy",
9278 )
9279 })?;
9280 let task = tokio::task::spawn_blocking(move || {
9281 let _permit = permit;
9282 browse_host_directories(directory, after)
9283 });
9284 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9285 .await
9286 .map_err(|_| {
9287 failure(
9288 NodeFailureCode::HostDirectoryReadTimedOut,
9289 "host directory browse exceeded its bounded deadline",
9290 )
9291 })?
9292 .map_err(|_| {
9293 failure(
9294 NodeFailureCode::HostDirectoryReadFailed,
9295 "host directory browse task failed",
9296 )
9297 })?
9298 .map_err(host_directory_failure)
9299 }
9300
9301 async fn read_workspace_file(
9302 &self,
9303 workspace_id: WorkspaceId,
9304 path: RepositoryPath,
9305 ) -> Result<WorkspaceFileRead, NodeFailure> {
9306 let canonical_root = self.workspace_root(&workspace_id)?;
9307 let permit = self
9308 .inspection_slots
9309 .clone()
9310 .try_acquire_owned()
9311 .map_err(|_| {
9312 failure(
9313 NodeFailureCode::BackendBusy,
9314 "workspace file read capacity is busy",
9315 )
9316 })?;
9317 let path_to_read = path.clone();
9318 let task = tokio::task::spawn_blocking(move || {
9319 let _permit = permit;
9320 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9321 });
9322 let content = timeout(
9323 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9324 task,
9325 )
9326 .await
9327 .map_err(|_| {
9328 failure(
9329 NodeFailureCode::RepositoryFileReadTimedOut,
9330 "repository file read exceeded its bounded deadline",
9331 )
9332 })?
9333 .map_err(|_| {
9334 failure(
9335 NodeFailureCode::RepositoryFileReadFailed,
9336 "repository file read task failed",
9337 )
9338 })?
9339 .map_err(workspace_file_failure)?;
9340 let content = match content {
9341 WorkspaceFileBytes::Utf8(text) => {
9342 let revision = Some(workspace_file_revision(text.as_bytes()));
9343 return Ok(WorkspaceFileRead {
9344 workspace_id,
9345 path,
9346 content: WorkspaceFileContent::Utf8 {
9347 byte_len: u32::try_from(text.len())
9348 .expect("bounded workspace text length must fit u32"),
9349 text,
9350 },
9351 revision,
9352 });
9353 }
9354 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9355 WorkspaceFileContent::NonUtf8 {
9356 byte_len: u32::try_from(byte_length)
9357 .expect("bounded workspace file length must fit u32"),
9358 }
9359 }
9360 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9361 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9362 },
9363 };
9364 Ok(WorkspaceFileRead {
9365 workspace_id,
9366 path,
9367 content,
9368 revision: None,
9369 })
9370 }
9371
9372 async fn write_workspace_file(
9373 &self,
9374 workspace_id: WorkspaceId,
9375 path: RepositoryPath,
9376 expected_revision: WorkspaceFileRevision,
9377 text: String,
9378 ) -> Result<WorkspaceFileRead, NodeFailure> {
9379 let canonical_root = self.workspace_root(&workspace_id)?;
9380 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9381 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9382 })?;
9383 let path_to_write = path.clone();
9384 let expected = expected_revision.as_str().to_owned();
9385 let text_to_write = text.clone();
9386 let task = tokio::task::spawn_blocking(move || {
9387 let _permit = permit;
9388 write_workspace_file_to_disk(
9389 Path::new(&canonical_root),
9390 &path_to_write,
9391 &expected,
9392 &text_to_write,
9393 )
9394 });
9395 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9396 .await
9397 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9398 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9399 .map_err(workspace_file_write_failure)?;
9400 Ok(WorkspaceFileRead {
9401 workspace_id,
9402 path,
9403 content: WorkspaceFileContent::Utf8 {
9404 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9405 text,
9406 },
9407 revision: Some(WorkspaceFileRevision::new(revision)
9408 .expect("workspace writer returns a valid SHA-256 revision")),
9409 })
9410 }
9411
9412 async fn create_workspace_file(
9413 &self,
9414 workspace_id: WorkspaceId,
9415 path: RepositoryPath,
9416 ) -> Result<WorkspaceFileRead, NodeFailure> {
9417 let canonical_root = self.workspace_root(&workspace_id)?;
9418 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9419 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9420 })?;
9421 let path_to_create = path.clone();
9422 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9423 let task_commit_state = Arc::clone(&commit_state);
9424 let task = tokio::task::spawn_blocking(move || {
9425 let _permit = permit;
9426 create_workspace_file_on_disk(
9427 Path::new(&canonical_root),
9428 &path_to_create,
9429 &task_commit_state,
9430 )
9431 });
9432 let revision = settle_workspace_entry_create(
9433 task,
9434 commit_state,
9435 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9436 "repository file create exceeded its bounded deadline",
9437 "repository file create task failed",
9438 )
9439 .await?;
9440 Ok(WorkspaceFileRead {
9441 workspace_id,
9442 path,
9443 content: WorkspaceFileContent::Utf8 {
9444 text: String::new(),
9445 byte_len: 0,
9446 },
9447 revision: Some(WorkspaceFileRevision::new(revision)
9448 .expect("workspace creator returns a valid SHA-256 revision")),
9449 })
9450 }
9451
9452 async fn create_workspace_directory(
9453 &self,
9454 workspace_id: WorkspaceId,
9455 path: RepositoryPath,
9456 ) -> Result<WorkspaceEntry, NodeFailure> {
9457 let canonical_root = self.workspace_root(&workspace_id)?;
9458 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9459 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9460 })?;
9461 let path_to_create = path.clone();
9462 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9463 let task_commit_state = Arc::clone(&commit_state);
9464 let task = tokio::task::spawn_blocking(move || {
9465 let _permit = permit;
9466 create_workspace_directory_on_disk(
9467 Path::new(&canonical_root),
9468 &path_to_create,
9469 &task_commit_state,
9470 )
9471 });
9472 settle_workspace_entry_create(
9473 task,
9474 commit_state,
9475 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9476 "repository directory create exceeded its bounded deadline",
9477 "repository directory create task failed",
9478 )
9479 .await?;
9480 Ok(WorkspaceEntry {
9481 relative_path: path,
9482 kind: WorkspaceEntryKind::Directory,
9483 })
9484 }
9485
9486 async fn read_git_history(
9487 &self,
9488 workspace_id: WorkspaceId,
9489 path: Option<RepositoryPath>,
9490 before: Option<GitObjectId>,
9491 limit: u16,
9492 ) -> Result<GitHistoryPage, NodeFailure> {
9493 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9494 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9495 })?;
9496 let root = self.workspace_root(&workspace_id)?;
9497 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9498 }
9499
9500 async fn read_git_diff(
9501 &self,
9502 workspace_id: WorkspaceId,
9503 request: GitDiffRequest,
9504 ) -> Result<GitDiff, NodeFailure> {
9505 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9506 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9507 })?;
9508 let root = self.workspace_root(&workspace_id)?;
9509 read_git_diff_bounded(&root, request).await
9510 }
9511
9512 fn native_session_route_root(
9513 &self,
9514 route: &NativeSessionCatalogRoute,
9515 ) -> Result<Option<String>, NodeFailure> {
9516 route
9517 .validate()
9518 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9519 match route.scope {
9520 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9521 .workspace_id
9522 .as_ref()
9523 .map(|workspace_id| self.workspace_root(workspace_id))
9524 .transpose(),
9525 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9526 }
9527 }
9528
9529 fn project_native_session_entries(
9530 &self,
9531 route: &NativeSessionCatalogRoute,
9532 entries: Vec<ScopedNativeSessionCatalogEntry>,
9533 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9534 let current_root = route
9535 .workspace_id
9536 .as_ref()
9537 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9538 let records = self
9539 .session_records
9540 .lock()
9541 .unwrap_or_else(|poisoned| poisoned.into_inner());
9542 let mut seen_records = std::collections::HashSet::new();
9543 entries
9544 .into_iter()
9545 .map(|entry| {
9546 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9547 let matches = records
9548 .records
9549 .values()
9550 .filter(|record| {
9551 record.provider == route.provider
9552 && record.workspace_id == *workspace_id
9553 && current_root.as_deref().is_some_and(|root| {
9554 platform::roots_equal(
9555 root,
9556 windows_path_text(&record.canonical_root),
9557 )
9558 })
9559 && record.provider_session.as_ref().is_some_and(|identity| {
9560 session_registry::same_provider_session(
9561 &record.provider,
9562 identity,
9563 &route.provider,
9564 &entry.provider_session,
9565 )
9566 })
9567 })
9568 .map(|record| record.record_id.clone())
9569 .collect::<Vec<_>>();
9570 (matches.len() == 1
9571 && seen_records.insert(matches[0].clone()))
9572 .then(|| matches[0].clone())
9573 });
9574 let metadata = entry.metadata;
9575 crate::protocol::NativeSessionCatalogEntry {
9576 selection_id: metadata.selection_id,
9577 title: metadata.title,
9578 modified_at_unix_ms: metadata.modified_at_unix_ms,
9579 model: metadata.model,
9580 message_count: metadata.message_count,
9581 completed_turn_count: metadata.completed_turn_count,
9582 external_group: entry.external_group,
9583 record_id,
9584 }
9585 })
9586 .collect()
9587 }
9588
9589 async fn catalog_native_sessions(
9590 &self,
9591 route: NativeSessionCatalogRoute,
9592 limit: u16,
9593 ) -> Result<(
9594 Vec<crate::protocol::NativeSessionCatalogEntry>,
9595 crate::protocol::NativeSessionCatalogSummary,
9596 ), NodeFailure> {
9597 let canonical_root = self.native_session_route_root(&route)?;
9598 let registered_workspace_roots = self.workspace_roots();
9599 let provider = route.provider.clone();
9600 let scope = route.scope;
9601 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9602 failure(
9603 NodeFailureCode::InvalidRequest,
9604 "native session catalog is not configured",
9605 )
9606 })?;
9607 let _permit = Arc::clone(&self.inspection_slots)
9608 .try_acquire_owned()
9609 .map_err(|_| {
9610 failure(
9611 NodeFailureCode::BackendBusy,
9612 "native session catalog capacity is busy",
9613 )
9614 })?;
9615 let page = run_native_session_catalog_operation(
9616 catalog,
9617 "native session catalog",
9618 move |catalog| {
9619 catalog.catalog_initial_for_scope(
9620 &provider,
9621 scope,
9622 canonical_root.as_deref().map(Path::new),
9623 ®istered_workspace_roots,
9624 limit,
9625 )
9626 },
9627 )
9628 .await?;
9629 let summary = crate::protocol::NativeSessionCatalogSummary {
9630 catalog_revision: page.revision,
9631 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9632 recent_total_count: page.recent_total_count,
9633 older_total_count: page.older_total_count,
9634 recent_next_after_selection_id: page.next_after_selection_id,
9635 recent_has_more: page.remaining_count > 0,
9636 };
9637 Ok((self.project_native_session_entries(&route, page.entries), summary))
9638 }
9639
9640 async fn page_native_sessions(
9641 &self,
9642 route: NativeSessionCatalogRoute,
9643 window: crate::protocol::NativeSessionCatalogWindow,
9644 catalog_revision: u64,
9645 recent_cutoff_unix_ms: u64,
9646 after_selection_id: Option<String>,
9647 limit: u16,
9648 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9649 let canonical_root = self.native_session_route_root(&route)?;
9650 let registered_workspace_roots = self.workspace_roots();
9651 let provider = route.provider.clone();
9652 let scope = route.scope;
9653 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9654 failure(
9655 NodeFailureCode::InvalidRequest,
9656 "native session catalog is not configured",
9657 )
9658 })?;
9659 let _permit = Arc::clone(&self.inspection_slots)
9660 .try_acquire_owned()
9661 .map_err(|_| {
9662 failure(
9663 NodeFailureCode::BackendBusy,
9664 "native session catalog capacity is busy",
9665 )
9666 })?;
9667 let page = run_native_session_catalog_operation(
9668 catalog,
9669 "native session catalog page",
9670 move |catalog| {
9671 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9672 &provider,
9673 scope,
9674 canonical_root.as_deref().map(Path::new),
9675 ®istered_workspace_roots,
9676 window,
9677 catalog_revision,
9678 recent_cutoff_unix_ms,
9679 after_selection_id.as_deref(),
9680 limit,
9681 ))
9682 },
9683 )
9684 .await?;
9685 let page = page.map_err(|error| match error {
9686 NativeSessionCatalogError::StaleCatalog => failure(
9687 NodeFailureCode::StaleNativeSessionCatalog,
9688 "native session catalog revision or cursor is stale",
9689 ),
9690 _ => failure(
9691 NodeFailureCode::BackendOperationFailed,
9692 "native session catalog page failed",
9693 ),
9694 })?;
9695 let entries = self.project_native_session_entries(&route, page.entries);
9696 Ok(crate::protocol::NativeSessionCatalogPage {
9697 window: page.window,
9698 revision: page.revision,
9699 entries,
9700 next_after_selection_id: page.next_after_selection_id,
9701 remaining_count: page.remaining_count,
9702 has_more: page.remaining_count > 0,
9703 })
9704 }
9705
9706 async fn preview_native_session(
9707 &self,
9708 selection: NativeSessionSelection,
9709 message_limit: u16,
9710 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9711 selection
9712 .validate()
9713 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9714 let canonical_root = self.native_session_route_root(&selection.route)?;
9715 let registered_workspace_roots = self.workspace_roots();
9716 let provider = selection.route.provider.clone();
9717 let scope = selection.route.scope;
9718 let catalog_revision = selection.catalog_revision;
9719 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9720 let selection_id = selection.selection_id.clone();
9721 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9722 failure(
9723 NodeFailureCode::InvalidRequest,
9724 "native session preview is not configured",
9725 )
9726 })?;
9727 let _permit = Arc::clone(&self.inspection_slots)
9728 .try_acquire_owned()
9729 .map_err(|_| {
9730 failure(
9731 NodeFailureCode::BackendBusy,
9732 "native session preview capacity is busy",
9733 )
9734 })?;
9735 let preview = run_native_session_catalog_operation(
9736 catalog,
9737 "native session preview",
9738 move |catalog| {
9739 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9740 &provider,
9741 scope,
9742 canonical_root.as_deref().map(Path::new),
9743 ®istered_workspace_roots,
9744 catalog_revision,
9745 recent_cutoff_unix_ms,
9746 &selection_id,
9747 message_limit,
9748 ))
9749 },
9750 )
9751 .await?;
9752 preview
9753 .map(Into::into)
9754 .map_err(native_session_preview_failure)
9755 }
9756
9757 async fn index_native_session(
9758 &self,
9759 selection: NativeSessionSelection,
9760 display_name: String,
9761 ) -> Result<ManagedSessionRecord, NodeFailure> {
9762 selection
9763 .validate()
9764 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9765 if selection.route.scope
9766 != gate4agent_types::NativeSessionCatalogScope::Workspace
9767 {
9768 return Err(failure(
9769 NodeFailureCode::WorkspaceRegistrationRequired,
9770 "register the external project as a workspace before indexing or resuming it",
9771 ));
9772 }
9773 let workspace_id = selection
9774 .route
9775 .workspace_id
9776 .clone()
9777 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9778 let canonical_root = self
9779 .native_session_route_root(&selection.route)?
9780 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9781 let registered_workspace_roots = self.workspace_roots();
9782 let provider = selection.route.provider.clone();
9783 let catalog_revision = selection.catalog_revision;
9784 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9785 let selection_id = selection.selection_id.clone();
9786 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9787 failure(
9788 NodeFailureCode::InvalidRequest,
9789 "native session index is not configured",
9790 )
9791 })?;
9792 let _permit = Arc::clone(&self.inspection_slots)
9793 .try_acquire_owned()
9794 .map_err(|_| {
9795 failure(
9796 NodeFailureCode::BackendBusy,
9797 "native session index capacity is busy",
9798 )
9799 })?;
9800 let resolved = run_native_session_catalog_operation(
9801 catalog,
9802 "native session index resolution",
9803 move |catalog| {
9804 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9805 &provider,
9806 gate4agent_types::NativeSessionCatalogScope::Workspace,
9807 Some(Path::new(&canonical_root)),
9808 ®istered_workspace_roots,
9809 catalog_revision,
9810 recent_cutoff_unix_ms,
9811 &selection_id,
9812 ))
9813 },
9814 )
9815 .await?
9816 .map_err(native_session_preview_failure)?;
9817 self.index_provider_session_with_policy(
9818 workspace_id,
9819 selection.route.provider,
9820 resolved.identity,
9821 display_name,
9822 true,
9823 )
9824 }
9825
9826 async fn preview_native_session_by_session_id(
9827 &self,
9828 workspace_id: WorkspaceId,
9829 provider: AgentId,
9830 session_id: String,
9831 message_limit: u16,
9832 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9833 let canonical_root = self.workspace_root(&workspace_id)?;
9834 let registered_workspace_roots = self.workspace_roots();
9835 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9836 failure(
9837 NodeFailureCode::InvalidRequest,
9838 "native session preview is not configured",
9839 )
9840 })?;
9841 let _permit = Arc::clone(&self.inspection_slots)
9842 .try_acquire_owned()
9843 .map_err(|_| {
9844 failure(
9845 NodeFailureCode::BackendBusy,
9846 "native session preview capacity is busy",
9847 )
9848 })?;
9849 run_native_session_catalog_operation(
9850 catalog,
9851 "native session preview",
9852 move |catalog| {
9853 catalog.preview_session_id_for_workspace(
9854 &provider,
9855 Path::new(&canonical_root),
9856 ®istered_workspace_roots,
9857 &session_id,
9858 message_limit,
9859 )
9860 },
9861 )
9862 .await
9863 }
9864
9865 async fn register_workspace(
9866 &self,
9867 workspace_id: WorkspaceId,
9868 root: String,
9869 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9870 validate_workspace_request_root(&root)?;
9871 let canonicalize_workspace_id = workspace_id.clone();
9872 let workspace = tokio::task::spawn_blocking(move || {
9873 WorkspaceConfig::new(canonicalize_workspace_id, root)
9874 })
9875 .await
9876 .map_err(|error| {
9877 failure(
9878 NodeFailureCode::BackendOperationFailed,
9879 &format!("workspace canonicalization task failed: {error}"),
9880 )
9881 })?
9882 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9883 let transaction = self
9884 .state_transaction
9885 .lock()
9886 .unwrap_or_else(|poisoned| poisoned.into_inner());
9887 let mut workspaces = self
9888 .workspaces
9889 .write()
9890 .unwrap_or_else(|poisoned| poisoned.into_inner());
9891 if workspaces.contains_key(&workspace_id) {
9892 return Err(failure(
9893 NodeFailureCode::DuplicateWorkspaceId,
9894 "workspace ID is already registered",
9895 ));
9896 }
9897 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9898 platform::roots_equal(existing_root, workspace.canonical_root())
9899 }) {
9900 return Err(failure(
9901 NodeFailureCode::DuplicateWorkspaceRoot,
9902 &format!("workspace root is already registered as '{existing_id}'"),
9903 ));
9904 }
9905 {
9906 let records = self
9907 .session_records
9908 .lock()
9909 .unwrap_or_else(|poisoned| poisoned.into_inner());
9910 if let Some(record) = records.records.values().find(|record| {
9911 record.workspace_id == workspace_id
9912 && !record
9913 .canonical_root
9914 .as_utf8()
9915 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9916 }) {
9917 return Err(failure(
9918 NodeFailureCode::SessionRecordConflict,
9919 &format!(
9920 "workspace ID is retained by session record '{}' at another root",
9921 record.record_id,
9922 ),
9923 ));
9924 }
9925 }
9926 let snapshot = WorkspaceSnapshot {
9927 workspace_id: workspace_id.clone(),
9928 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9929 sessions: Vec::new(),
9930 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9931 managed_worktree_profiles: Some(WorktreeProfileInventory {
9932 profiles: Vec::new(),
9933 }),
9934 };
9935 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9936 drop(workspaces);
9937 let previous_records = {
9938 let mut records = self
9939 .session_records
9940 .lock()
9941 .unwrap_or_else(|poisoned| poisoned.into_inner());
9942 let previous = records.records.clone();
9943 for record in records.records.values_mut().filter(|record| {
9944 record.workspace_id == workspace_id
9945 && record
9946 .canonical_root
9947 .as_utf8()
9948 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9949 }) {
9950 if record.active_session.is_none() {
9951 record.state = detached_record_state(record);
9952 }
9953 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9954 record.last_error = None;
9955 }
9956 record.updated_at_unix_ms = unix_time_ms();
9957 }
9958 previous
9959 };
9960 if let Err(error) = self.persist_state_locked() {
9961 self.workspaces
9962 .write()
9963 .unwrap_or_else(|poisoned| poisoned.into_inner())
9964 .remove(&workspace_id);
9965 self.session_records
9966 .lock()
9967 .unwrap_or_else(|poisoned| poisoned.into_inner())
9968 .records = previous_records;
9969 return Err(persistence_failure(error));
9970 }
9971 let restored = self
9972 .session_records
9973 .lock()
9974 .unwrap_or_else(|poisoned| poisoned.into_inner())
9975 .records
9976 .values()
9977 .filter(|record| record.workspace_id == workspace_id)
9978 .cloned()
9979 .collect::<Vec<_>>();
9980 drop(transaction);
9981 self.publish(NodeEvent::WorkspaceAdded {
9982 workspace: snapshot.clone(),
9983 });
9984 for record in restored {
9985 self.publish_record(record);
9986 }
9987 Ok(snapshot)
9988 }
9989
9990 async fn create_standalone_workspace(
9991 &self,
9992 workspace_id: WorkspaceId,
9993 root: String,
9994 initial_branch: Option<String>,
9995 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9996 validate_workspace_request_root(&root)?;
9997 validate_git_revision("initial branch", initial_branch.as_deref())?;
9998 let preflight_workspace_id = workspace_id.clone();
9999 let preflight_root = root.clone();
10000 let canonical_candidate = tokio::task::spawn_blocking(move || {
10001 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10002 })
10003 .await
10004 .map_err(|_| failure(
10005 NodeFailureCode::BackendOperationFailed,
10006 "standalone workspace root preflight task failed",
10007 ))?
10008 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10009 {
10010 let workspaces = self
10011 .workspaces
10012 .read()
10013 .unwrap_or_else(|poisoned| poisoned.into_inner());
10014 if workspaces.contains_key(&workspace_id) {
10015 return Err(failure(
10016 NodeFailureCode::DuplicateWorkspaceId,
10017 "workspace ID is already registered",
10018 ));
10019 }
10020 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10021 platform::roots_equal(existing_root, &canonical_candidate)
10022 }) {
10023 return Err(failure(
10024 NodeFailureCode::DuplicateWorkspaceRoot,
10025 &format!("workspace root is already registered as '{existing_id}'"),
10026 ));
10027 }
10028 }
10029 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10030 .await
10031 .map_err(standalone_workspace_failure)?;
10032 prepared
10033 .verify_for_registration()
10034 .await
10035 .map_err(standalone_workspace_failure)?;
10036 match self
10037 .register_workspace(workspace_id, prepared.root().to_owned())
10038 .await
10039 {
10040 Ok(workspace) => Ok(workspace),
10041 Err(registration_error) => {
10042 if prepared.compensate_registration_failure().await {
10043 Err(registration_error)
10044 } else {
10045 Err(failure(
10046 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10047 "standalone workspace initialization succeeded but registration failed; recovery is required",
10048 ))
10049 }
10050 }
10051 }
10052 }
10053
10054 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10055 let bindings = self
10056 .session_bindings
10057 .lock()
10058 .unwrap_or_else(|poisoned| poisoned.into_inner());
10059 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10060 return Err(failure(
10061 NodeFailureCode::WorkspaceBusy,
10062 "workspace retains a bound session; remove every session before unregistering it",
10063 ));
10064 }
10065 drop(bindings);
10066 let transaction = self
10067 .state_transaction
10068 .lock()
10069 .unwrap_or_else(|poisoned| poisoned.into_inner());
10070 let mut workspaces = self
10071 .workspaces
10072 .write()
10073 .unwrap_or_else(|poisoned| poisoned.into_inner());
10074 if !workspaces.contains_key(workspace_id) {
10075 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10076 }
10077 if workspaces.len() == 1 {
10078 return Err(failure(
10079 NodeFailureCode::LastWorkspace,
10080 "node must retain at least one workspace",
10081 ));
10082 }
10083 let removed_root = workspaces
10084 .remove(workspace_id)
10085 .expect("workspace presence was validated");
10086 drop(workspaces);
10087 let previous_records = {
10088 let mut records = self
10089 .session_records
10090 .lock()
10091 .unwrap_or_else(|poisoned| poisoned.into_inner());
10092 let previous = records.records.clone();
10093 for record in records
10094 .records
10095 .values_mut()
10096 .filter(|record| &record.workspace_id == workspace_id)
10097 {
10098 record.active_session = None;
10099 record.state = ManagedSessionState::Unavailable;
10100 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10101 record.updated_at_unix_ms = unix_time_ms();
10102 }
10103 previous
10104 };
10105 if let Err(error) = self.persist_state_locked() {
10106 self.workspaces
10107 .write()
10108 .unwrap_or_else(|poisoned| poisoned.into_inner())
10109 .insert(workspace_id.clone(), removed_root);
10110 self.session_records
10111 .lock()
10112 .unwrap_or_else(|poisoned| poisoned.into_inner())
10113 .records = previous_records;
10114 return Err(persistence_failure(error));
10115 }
10116 let unavailable = self
10117 .session_records
10118 .lock()
10119 .unwrap_or_else(|poisoned| poisoned.into_inner())
10120 .records
10121 .values()
10122 .filter(|record| &record.workspace_id == workspace_id)
10123 .cloned()
10124 .collect::<Vec<_>>();
10125 drop(transaction);
10126 self.publish(NodeEvent::WorkspaceRemoved {
10127 workspace_id: workspace_id.clone(),
10128 });
10129 for record in unavailable {
10130 self.publish_record(record);
10131 }
10132 Ok(())
10133 }
10134
10135 async fn create_worktree(
10136 &self,
10137 source_workspace_id: WorkspaceId,
10138 workspace_id: WorkspaceId,
10139 target_root: String,
10140 branch: String,
10141 base: Option<String>,
10142 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10143 self.require_worktree_service(&source_workspace_id)?;
10144 validate_workspace_request_root(&target_root)?;
10145 validate_git_revision("worktree base", base.as_deref())?;
10146 let source_root = self.workspace_root(&source_workspace_id)?;
10147 {
10148 let workspaces = self
10149 .workspaces
10150 .read()
10151 .unwrap_or_else(|poisoned| poisoned.into_inner());
10152 if workspaces.contains_key(&workspace_id) {
10153 return Err(failure(
10154 NodeFailureCode::DuplicateWorkspaceId,
10155 "workspace ID is already registered",
10156 ));
10157 }
10158 if let Some((existing_id, _)) = workspaces
10159 .iter()
10160 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10161 {
10162 return Err(failure(
10163 NodeFailureCode::DuplicateWorkspaceRoot,
10164 &format!("workspace root is already registered as '{existing_id}'"),
10165 ));
10166 }
10167 }
10168 let mut worktree = create_git_worktree(
10169 &source_root,
10170 &target_root,
10171 &branch,
10172 base.as_deref(),
10173 )
10174 .await
10175 .map_err(git_worktree_failure)?;
10176 let workspace = match self
10177 .register_workspace(workspace_id, worktree.path.clone())
10178 .await
10179 {
10180 Ok(workspace) => workspace,
10181 Err(registration_error) => {
10182 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10183 return Err(failure(
10184 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10185 "worktree registration failed and compensation requires recovery",
10186 ));
10187 }
10188 return Err(failure(
10189 registration_error.code,
10190 ®istration_error.message,
10191 ));
10192 }
10193 };
10194 worktree.workspace_id = Some(workspace.workspace_id.clone());
10195 Ok((protocol_worktree(worktree), workspace))
10196 }
10197
10198 async fn remove_worktree(
10199 &self,
10200 source_workspace_id: WorkspaceId,
10201 target_root: String,
10202 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10203 self.require_worktree_service(&source_workspace_id)?;
10204 validate_workspace_request_root(&target_root)?;
10205 let source_root = self.workspace_root(&source_workspace_id)?;
10206 let target_root = normalize_worktree_removal_target(&target_root)
10207 .map_err(git_worktree_failure)?;
10208 let target = list_git_worktrees(&source_root)
10209 .await
10210 .map_err(git_worktree_failure)?
10211 .into_iter()
10212 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10213 .ok_or_else(|| {
10214 failure(
10215 NodeFailureCode::WorktreeProtected,
10216 "refusing to remove a path that is not in Git's worktree listing",
10217 )
10218 })?;
10219 let registered_workspace_id = {
10220 let workspaces = self
10221 .workspaces
10222 .read()
10223 .unwrap_or_else(|poisoned| poisoned.into_inner());
10224 let matched = workspaces
10225 .iter()
10226 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10227 .map(|(workspace_id, _)| workspace_id.clone());
10228 if matched.is_some() && workspaces.len() == 1 {
10229 return Err(failure(
10230 NodeFailureCode::LastWorkspace,
10231 "node must retain at least one workspace",
10232 ));
10233 }
10234 matched
10235 };
10236 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10237 let bindings = self
10238 .session_bindings
10239 .lock()
10240 .unwrap_or_else(|poisoned| poisoned.into_inner());
10241 if bindings
10242 .values()
10243 .any(|binding| &binding.workspace_id == workspace_id)
10244 {
10245 return Err(failure(
10246 NodeFailureCode::WorkspaceBusy,
10247 "worktree workspace retains a bound session; remove every session first",
10248 ));
10249 }
10250 }
10251 remove_git_worktree(&source_root, &target.path)
10252 .await
10253 .map_err(git_worktree_failure)?;
10254 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10255 self.unregister_workspace(workspace_id)
10256 .map_err(|error| failure(error.code, &error.message))?;
10257 }
10258 Ok(registered_workspace_id)
10259 }
10260
10261 async fn cleanup_managed_worktree(
10262 &self,
10263 lease_id: &ManagedWorktreeLeaseId,
10264 explicit: bool,
10265 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10266 let lease = self.managed_worktrees.lock()
10267 .unwrap_or_else(|poisoned| poisoned.into_inner())
10268 .get(lease_id)
10269 .cloned()
10270 .ok_or_else(|| failure(
10271 NodeFailureCode::UnknownManagedWorktreeLease,
10272 "managed worktree lease does not exist",
10273 ))?;
10274 if lease.state == ManagedWorktreeLeaseState::Removed {
10275 return Ok(lease.snapshot());
10276 }
10277 if lease.has_holders() {
10278 return Err(failure(
10279 NodeFailureCode::ManagedWorktreeBusy,
10280 "managed worktree lease still has a session or durable-record holder",
10281 ));
10282 }
10283 if self
10284 .materializations
10285 .lock()
10286 .unwrap_or_else(|poisoned| poisoned.into_inner())
10287 .values()
10288 .any(|record| record.managed_lease_id() == Some(lease_id))
10289 {
10290 return Err(failure(
10291 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10292 "managed worktree cleanup is blocked by session environment ownership",
10293 ));
10294 }
10295 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10296 let snapshot = {
10297 let mut registry = self.managed_worktrees.lock()
10298 .unwrap_or_else(|poisoned| poisoned.into_inner());
10299 let current = registry.get_mut(lease_id)
10300 .expect("managed worktree lease remains present");
10301 current.state = ManagedWorktreeLeaseState::Retained;
10302 current.cleanup_failure = None;
10303 current.updated_at_unix_ms = unix_time_ms();
10304 current.snapshot()
10305 };
10306 if self.persist_state().is_err() {
10307 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10308 }
10309 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10310 return Ok(snapshot);
10311 }
10312 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10313 if profile.revision() != &lease.profile_revision {
10314 self.mark_managed_recovery_required(
10315 lease_id,
10316 ManagedWorktreeCleanupFailure::OwnershipConflict,
10317 );
10318 return Err(failure(
10319 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10320 "managed worktree profile revision changed",
10321 ));
10322 }
10323 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10324 self.mark_managed_recovery_required(
10325 lease_id,
10326 ManagedWorktreeCleanupFailure::OwnershipConflict,
10327 );
10328 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10329 })?;
10330 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10331 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10332 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10333 managed_git_worktree_failure(error)
10334 })?;
10335 let target = listed.iter()
10336 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10337 if let Some(target) = target {
10338 if target.is_main || target.is_bare || !exact_owned_worktree(
10339 &lease,
10340 &target.path,
10341 target.branch.as_deref(),
10342 &target.head,
10343 ) {
10344 self.mark_managed_recovery_required(
10345 lease_id,
10346 ManagedWorktreeCleanupFailure::OwnershipConflict,
10347 );
10348 return Err(failure(
10349 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10350 "Git worktree identity no longer matches the managed lease",
10351 ));
10352 }
10353 if target.prunable {
10354 self.mark_managed_cleanup_blocked(
10355 lease_id,
10356 ManagedWorktreeCleanupFailure::Prunable,
10357 );
10358 return Err(failure(
10359 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10360 "managed worktree is prunable and requires recovery",
10361 ));
10362 }
10363 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10364 let failure_kind = match error.kind {
10365 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10366 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10367 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10368 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10369 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10370 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10371 };
10372 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10373 self.mark_managed_recovery_required(lease_id, failure_kind);
10374 } else {
10375 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10376 }
10377 return Err(managed_git_worktree_failure(error));
10378 }
10379 } else {
10380 match std::fs::symlink_metadata(&lease.target_root) {
10381 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10382 Ok(_) => {
10383 self.mark_managed_recovery_required(
10384 lease_id,
10385 ManagedWorktreeCleanupFailure::OwnershipConflict,
10386 );
10387 return Err(failure(
10388 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10389 "managed target exists but is not owned by Git",
10390 ));
10391 }
10392 Err(_) => {
10393 self.mark_managed_cleanup_blocked(
10394 lease_id,
10395 ManagedWorktreeCleanupFailure::Backend,
10396 );
10397 return Err(failure(
10398 NodeFailureCode::BackendOperationFailed,
10399 "managed target identity could not be inspected",
10400 ));
10401 }
10402 }
10403 }
10404 let registered = self.workspaces.read()
10405 .unwrap_or_else(|poisoned| poisoned.into_inner())
10406 .contains_key(&lease.workspace_id);
10407 if registered {
10408 self.unregister_workspace(&lease.workspace_id)?;
10409 }
10410 let removed = {
10411 self.managed_worktrees.lock()
10412 .unwrap_or_else(|poisoned| poisoned.into_inner())
10413 .tombstone(lease_id, unix_time_ms())
10414 .expect("managed worktree lease remains present during cleanup")
10415 };
10416 self.persist_state().map_err(persistence_failure)?;
10417 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10418 Ok(removed.snapshot())
10419 }
10420
10421 fn mark_managed_cleanup_blocked(
10422 &self,
10423 lease_id: &ManagedWorktreeLeaseId,
10424 failure_kind: ManagedWorktreeCleanupFailure,
10425 ) {
10426 let snapshot = {
10427 let mut registry = self.managed_worktrees.lock()
10428 .unwrap_or_else(|poisoned| poisoned.into_inner());
10429 let Some(lease) = registry.get_mut(lease_id) else { return };
10430 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10431 lease.cleanup_failure = Some(failure_kind);
10432 lease.updated_at_unix_ms = unix_time_ms();
10433 lease.snapshot()
10434 };
10435 let _ = self.persist_state();
10436 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10437 }
10438
10439 fn require_worktree_service(
10440 &self,
10441 source_workspace_id: &WorkspaceId,
10442 ) -> Result<(), NodeFailure> {
10443 if self.managed_worktrees.lock()
10444 .unwrap_or_else(|poisoned| poisoned.into_inner())
10445 .lease_for_workspace(source_workspace_id)
10446 .is_some()
10447 {
10448 return Err(failure(
10449 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10450 "managed worktree targets are internal-only workspaces",
10451 ));
10452 }
10453 match self
10454 .worktree_service_modes
10455 .get(source_workspace_id)
10456 .copied()
10457 .unwrap_or(WorktreeServiceMode::Manual)
10458 {
10459 WorktreeServiceMode::Manual => Ok(()),
10460 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10461 NodeFailureCode::UnsupportedSpawnCapability,
10462 "Git worktree service mode is unavailable in this node build",
10463 )),
10464 }
10465 }
10466
10467 fn managed_reservation_conflict(
10468 &self,
10469 workspace_id: Option<&WorkspaceId>,
10470 root: Option<&str>,
10471 ) -> bool {
10472 self.managed_worktrees.lock()
10473 .unwrap_or_else(|poisoned| poisoned.into_inner())
10474 .active_records()
10475 .any(|lease| {
10476 workspace_id.is_some_and(|workspace_id| {
10477 workspace_id == &lease.source_workspace_id
10478 || workspace_id == &lease.workspace_id
10479 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10480 })
10481 }
10482
10483 fn reject_managed_reservation(
10484 &self,
10485 workspace_id: Option<&WorkspaceId>,
10486 root: Option<&str>,
10487 ) -> Result<(), NodeFailure> {
10488 if self.managed_reservation_conflict(workspace_id, root) {
10489 Err(failure(
10490 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10491 "managed worktree ownership may only be mutated through its lease cleanup",
10492 ))
10493 } else {
10494 Ok(())
10495 }
10496 }
10497
10498 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10499 self.validate_address(address)?;
10500 self.workspace_root(&address.workspace_id)
10501 }
10502
10503 fn require_session_runtime_policy(
10537 &self,
10538 address: &SessionAddress,
10539 requirement: ProviderRuntimeRequirement,
10540 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10541 let runtime_policy = {
10542 let bindings = self
10543 .session_bindings
10544 .lock()
10545 .unwrap_or_else(|poisoned| poisoned.into_inner());
10546 let binding = bindings
10547 .get(&address.session.instance_id)
10548 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10549 if binding.workspace_id != address.workspace_id {
10550 return Err(failure(
10551 NodeFailureCode::SessionWorkspaceMismatch,
10552 "session belongs to another workspace",
10553 ));
10554 }
10555 if binding.generation != address.session.generation {
10556 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10557 }
10558 binding.runtime_policy
10559 };
10560 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10561 let snapshot = self.internal_session_snapshot(address)?;
10562 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10563 && snapshot.provider.lead_activity != ProviderActivity::Idle
10564 {
10565 return Err(failure(
10566 NodeFailureCode::TurnInFlight,
10567 "session already has a turn in flight; wait for it to complete before sending another prompt",
10568 ));
10569 }
10570 }
10571 require_policy(runtime_policy, false, false, requirement)
10572 .map(|()| runtime_policy)
10573 .map_err(|_| failure(
10574 NodeFailureCode::UnsupportedCapability,
10575 "session runtime policy does not admit this semantic operation",
10576 ))
10577 }
10578
10579 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10580 let mut bindings = self
10581 .session_bindings
10582 .lock()
10583 .unwrap_or_else(|poisoned| poisoned.into_inner());
10584 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10585 binding.workspace_id == address.workspace_id
10586 && binding.generation == address.session.generation
10587 }) {
10588 let removed = bindings.remove(&address.session.instance_id);
10589 self.clear_terminal_frame_watermark(address);
10590 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10591 return removed;
10592 }
10593 None
10594 }
10595
10596 fn cleanup_session_owned_materialization(
10597 &self,
10598 address: &SessionAddress,
10599 binding: &SessionBinding,
10600 ) -> Result<(), NodeFailure> {
10601 let Some(id) = binding.materialization_id.as_ref() else {
10602 return Ok(());
10603 };
10604 let owner = self
10605 .materializations
10606 .lock()
10607 .unwrap_or_else(|poisoned| poisoned.into_inner())
10608 .get(id)
10609 .map(|record| record.owner().clone());
10610 match owner {
10611 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10612 Some(MaterializationOwner::Session {
10613 incarnation_id,
10614 instance_id,
10615 generation,
10616 }) if incarnation_id == self.incarnation_id
10617 && instance_id == address.session.instance_id
10618 && generation == address.session.generation =>
10619 {
10620 self.cleanup_materialization(id)
10621 }
10622 Some(MaterializationOwner::Session { .. }) => Err(failure(
10623 NodeFailureCode::BackendOperationFailed,
10624 "session environment ownership requires recovery",
10625 )),
10626 }
10627 }
10628
10629 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10630 let mut watermarks = self
10631 .terminal_frame_watermarks
10632 .lock()
10633 .unwrap_or_else(|poisoned| poisoned.into_inner());
10634 if watermarks
10635 .get(&address.session.instance_id)
10636 .is_some_and(|(current, _)| current == address)
10637 {
10638 watermarks.remove(&address.session.instance_id);
10639 }
10640 }
10641
10642 #[cfg(test)]
10643 fn address_for(
10644 &self,
10645 instance_id: AgentInstanceId,
10646 generation: SessionGeneration,
10647 ) -> Option<SessionAddress> {
10648 let binding = self.session_bindings
10649 .lock()
10650 .unwrap_or_else(|poisoned| poisoned.into_inner())
10651 .get(&instance_id)
10652 .cloned()?;
10653 (binding.generation == generation).then_some(SessionAddress {
10654 workspace_id: binding.workspace_id,
10655 session: SessionKey { instance_id, generation },
10656 })
10657 }
10658
10659 fn arm_resume(
10660 &self,
10661 address: &SessionAddress,
10662 command_id: CommandId,
10663 runtime_policy: ProviderRuntimePolicy,
10664 ) -> Result<(), NodeFailure> {
10665 let mut bindings = self
10666 .session_bindings
10667 .lock()
10668 .unwrap_or_else(|poisoned| poisoned.into_inner());
10669 let binding = bindings
10670 .get_mut(&address.session.instance_id)
10671 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10672 if binding.workspace_id != address.workspace_id {
10673 return Err(failure(
10674 NodeFailureCode::SessionWorkspaceMismatch,
10675 "session belongs to another workspace",
10676 ));
10677 }
10678 if binding.generation != address.session.generation {
10679 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10680 }
10681 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10682 Ok(())
10683 }
10684
10685 fn clear_armed_resume(&self, address: &SessionAddress) {
10686 let mut bindings = self
10687 .session_bindings
10688 .lock()
10689 .unwrap_or_else(|poisoned| poisoned.into_inner());
10690 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10691 if binding.workspace_id == address.workspace_id
10692 && binding.generation == address.session.generation
10693 {
10694 binding.pending_resume = None;
10695 }
10696 }
10697 }
10698
10699 fn publish_control(&self, event: ControlEvent) {
10700 let revokes_harness_mcp = matches!(
10701 &event.event,
10702 ControlEventKind::Exited { .. }
10703 | ControlEventKind::Failed { .. }
10704 | ControlEventKind::Removed
10705 );
10706 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10707 let mut bindings = self
10708 .session_bindings
10709 .lock()
10710 .unwrap_or_else(|poisoned| poisoned.into_inner());
10711 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10712 return;
10713 };
10714 let previous_address = SessionAddress {
10715 workspace_id: binding.workspace_id.clone(),
10716 session: SessionKey {
10717 instance_id: event.instance_id,
10718 generation: binding.generation,
10719 },
10720 };
10721 let mut resume_command_rejected = false;
10722 if binding.generation == event.generation {
10723 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10724 let rejected_resume = matches!(
10725 &event.event,
10726 ControlEventKind::CommandRejected { .. }
10727 ) && event.command_id == Some(pending_command_id);
10728 let resume_failed = matches!(
10729 &event.event,
10730 ControlEventKind::ResumeDenied { .. }
10731 | ControlEventKind::ResumeFailed { .. }
10732 );
10733 if pending_generation == binding.generation
10734 && (rejected_resume || resume_failed)
10735 {
10736 resume_command_rejected = rejected_resume;
10737 binding.pending_resume = None;
10738 }
10739 }
10740 } else {
10741 let expected = binding.generation.0.checked_add(1);
10742 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10743 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10744 generation == binding.generation
10745 })
10746 || expected != Some(event.generation.0)
10747 || !authorized
10748 {
10749 return;
10750 }
10751 let (_, _, runtime_policy) = binding
10752 .pending_resume
10753 .expect("authorized resume retains its pending runtime policy");
10754 binding.generation = event.generation;
10755 binding.runtime_policy = runtime_policy;
10756 binding.pending_resume = None;
10757 }
10758 (
10759 SessionAddress {
10760 workspace_id: binding.workspace_id.clone(),
10761 session: SessionKey {
10762 instance_id: event.instance_id,
10763 generation: event.generation,
10764 },
10765 },
10766 (previous_address.session.generation != event.generation)
10767 .then_some(previous_address),
10768 binding.record_id.clone(),
10769 binding.runtime_policy.provider_session_identity,
10770 resume_command_rejected,
10771 )
10772 };
10773 if let Some(previous_address) = previous_address.as_ref() {
10774 self.clear_terminal_frame_watermark(previous_address);
10775 }
10776 if let Some(record_id) = record_id {
10777 self.reconcile_managed_record(
10778 &record_id,
10779 &address,
10780 &event,
10781 identity_admitted,
10782 resume_command_rejected,
10783 );
10784 }
10785 match &event.event {
10793 ControlEventKind::Running { process_id } => {
10794 #[cfg(unix)]
10795 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10796 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10797 }
10798 tracing::info!(
10799 node_id = %self.node_id,
10800 workspace_id = %address.workspace_id,
10801 session = ?address.session,
10802 pid = ?process_id,
10803 "session process is running",
10804 );
10805 }
10806 ControlEventKind::Exited { exit_code, forced } => {
10807 #[cfg(unix)]
10808 if let Some(proxy) = self.signer_proxy.get().cloned() {
10809 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10810 if proxy.has_session(&key) {
10811 std::thread::spawn(move || proxy.release(&key));
10813 }
10814 }
10815 tracing::info!(
10816 node_id = %self.node_id,
10817 workspace_id = %address.workspace_id,
10818 session = ?address.session,
10819 exit_code = ?exit_code,
10820 forced = forced,
10821 "session process exited",
10822 );
10823 }
10824 ControlEventKind::Failed { message } => {
10825 tracing::warn!(
10826 node_id = %self.node_id,
10827 workspace_id = %address.workspace_id,
10828 session = ?address.session,
10829 cause = %message,
10830 "session failed",
10831 );
10832 }
10833 ControlEventKind::CommandRejected { message } => {
10834 tracing::warn!(
10835 node_id = %self.node_id,
10836 workspace_id = %address.workspace_id,
10837 session = ?address.session,
10838 cause = %message,
10839 "session command rejected",
10840 );
10841 }
10842 ControlEventKind::Removed => {
10843 tracing::info!(
10844 node_id = %self.node_id,
10845 workspace_id = %address.workspace_id,
10846 session = ?address.session,
10847 "session removed",
10848 );
10849 }
10850 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10851 tracing::info!(
10852 node_id = %self.node_id,
10853 workspace_id = %address.workspace_id,
10854 session = ?address.session,
10855 provider_session = ?provider_session,
10856 "session resume authorized",
10857 );
10858 }
10859 ControlEventKind::Resumed { session: provider_session, process_id } => {
10860 tracing::info!(
10861 node_id = %self.node_id,
10862 workspace_id = %address.workspace_id,
10863 session = ?address.session,
10864 provider_session = ?provider_session,
10865 pid = ?process_id,
10866 "session resumed",
10867 );
10868 }
10869 ControlEventKind::ResumeDenied { reason } => {
10870 tracing::warn!(
10871 node_id = %self.node_id,
10872 workspace_id = %address.workspace_id,
10873 session = ?address.session,
10874 cause = %reason,
10875 "session resume denied",
10876 );
10877 }
10878 ControlEventKind::ResumeFailed { message } => {
10879 tracing::warn!(
10880 node_id = %self.node_id,
10881 workspace_id = %address.workspace_id,
10882 session = ?address.session,
10883 cause = %message,
10884 "session resume failed",
10885 );
10886 }
10887 _ => {}
10888 }
10889 self.apply_pending_acp_options(&address, &event);
10890 self.apply_acp_auto_permit(&address, &event);
10891 let stream_chunk = agent_stream_chunk(&event);
10892 self.publish(NodeEvent::Control {
10893 address: address.clone(),
10894 event,
10895 });
10896 if let Some(chunk) = stream_chunk {
10897 self.publish_agent_stream_chunk(address.clone(), chunk);
10898 }
10899 if revokes_harness_mcp {
10900 SessionLifecycleHook::on_session_ended(self, &address);
10901 }
10902 }
10903
10904 fn reconcile_managed_record(
10905 &self,
10906 record_id: &SessionRecordId,
10907 address: &SessionAddress,
10908 event: &ControlEvent,
10909 identity_admitted: bool,
10910 resume_command_rejected: bool,
10911 ) {
10912 let _transaction = self
10913 .state_transaction
10914 .lock()
10915 .unwrap_or_else(|poisoned| poisoned.into_inner());
10916 let observed_identity = match &event.event {
10917 ControlEventKind::ProviderEvent {
10918 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10919 ..
10920 } if identity_admitted => Some(identity.clone()),
10921 _ => None,
10922 };
10923 let identity_was_observed = observed_identity.is_some();
10924 let replacement_id = observed_identity
10925 .as_ref()
10926 .and_then(|_| self.allocate_record_id().ok());
10927 let mut upserts = Vec::new();
10928 let mut removals = Vec::new();
10929 let mut rebind = None;
10930 let original_records;
10931 let original_materializations = self
10932 .materializations
10933 .lock()
10934 .unwrap_or_else(|poisoned| poisoned.into_inner())
10935 .clone();
10936 {
10937 let mut records = self
10938 .session_records
10939 .lock()
10940 .unwrap_or_else(|poisoned| poisoned.into_inner());
10941 original_records = records.records.clone();
10942 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10943 return;
10944 };
10945 if let Some(identity) = observed_identity {
10946 let existing_id = records
10947 .records
10948 .values()
10949 .find(|record| {
10950 record.record_id != *record_id
10951 && record.provider_session.as_ref().is_some_and(|record_identity| {
10952 session_registry::same_provider_session(
10953 &record.provider,
10954 record_identity,
10955 ¤t_snapshot.provider,
10956 &identity,
10957 )
10958 })
10959 })
10960 .map(|record| record.record_id.clone());
10961 if let Some(existing_id) = existing_id {
10962 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10963 record.mode == current_snapshot.mode
10964 && record.workspace_id == current_snapshot.workspace_id
10965 && record.environment_profile == current_snapshot.environment_profile
10966 && record.bundle == current_snapshot.bundle
10967 && record.context_id == current_snapshot.context_id
10968 && record.context == current_snapshot.context
10969 && record
10970 .canonical_root
10971 .as_utf8()
10972 .zip(current_snapshot.canonical_root.as_utf8())
10973 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10974 });
10975 if !scope_matches {
10976 if let Some(current) = records.records.get_mut(record_id) {
10977 current.state = ManagedSessionState::Unavailable;
10978 current.last_error =
10979 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10980 current.updated_at_unix_ms = unix_time_ms();
10981 upserts.push(current.clone());
10982 }
10983 } else {
10984 let existing_is_active = records
10985 .records
10986 .get(&existing_id)
10987 .is_some_and(|record| record.active_session.is_some());
10988 if existing_is_active {
10989 if let Some(current) = records.records.get_mut(record_id) {
10990 current.state = ManagedSessionState::Unavailable;
10991 current.last_error =
10992 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10993 current.updated_at_unix_ms = unix_time_ms();
10994 upserts.push(current.clone());
10995 }
10996 } else {
10997 if current_snapshot.provider_session.is_some() {
10998 if let Some(current) = records.records.get_mut(record_id) {
10999 current.active_session = None;
11000 current.state = ManagedSessionState::Dormant;
11001 current.updated_at_unix_ms = unix_time_ms();
11002 upserts.push(current.clone());
11003 }
11004 } else {
11005 records.records.remove(record_id);
11006 removals.push(record_id.clone());
11007 }
11008 if let Some(existing) = records.records.get_mut(&existing_id) {
11009 existing.provider_session = Some(identity.clone());
11010 existing.active_session = Some(address.clone());
11011 existing.state = ManagedSessionState::Live;
11012 existing.updated_at_unix_ms = unix_time_ms();
11013 existing.last_error = None;
11014 upserts.push(existing.clone());
11015 }
11016 rebind = Some(existing_id);
11017 }
11018 }
11019 } else if current_snapshot
11020 .provider_session
11021 .as_ref()
11022 .is_some_and(|current| {
11023 !session_registry::same_provider_session(
11024 ¤t_snapshot.provider,
11025 current,
11026 ¤t_snapshot.provider,
11027 &identity,
11028 )
11029 })
11030 {
11031 if let Some(current) = records.records.get_mut(record_id) {
11032 current.active_session = None;
11033 if current.bundle.is_some() || current.context.is_some() {
11034 let had_bundle = current.bundle.is_some();
11035 current.provider_session = None;
11036 current.bundle = None;
11037 current.context_id = None;
11038 current.context = None;
11039 current.state = ManagedSessionState::Unavailable;
11040 current.last_error = Some(if had_bundle {
11041 "bundle-unavailable".to_owned()
11042 } else {
11043 "context-unavailable".to_owned()
11044 });
11045 } else {
11046 current.state = ManagedSessionState::Dormant;
11047 }
11048 current.updated_at_unix_ms = unix_time_ms();
11049 upserts.push(current.clone());
11050 }
11051 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11052 if let Some(current) = records.records.get_mut(record_id) {
11053 current.state = ManagedSessionState::Unavailable;
11054 current.last_error =
11055 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11056 current.updated_at_unix_ms = unix_time_ms();
11057 upserts.push(current.clone());
11058 }
11059 } else if let Some(new_record_id) = replacement_id {
11060 let now = unix_time_ms();
11061 let replacement = ManagedSessionRecord {
11062 record_id: new_record_id.clone(),
11063 display_name: format!(
11064 "{} #{}",
11065 current_snapshot.provider.as_str(),
11066 records
11067 .records
11068 .values()
11069 .filter(|record| {
11070 record.provider == current_snapshot.provider
11071 })
11072 .count()
11073 .saturating_add(1),
11074 ),
11075 provider: current_snapshot.provider.clone(),
11076 mode: current_snapshot.mode,
11077 state: ManagedSessionState::Live,
11078 workspace_id: current_snapshot.workspace_id,
11079 canonical_root: current_snapshot.canonical_root,
11080 provider_session: Some(identity),
11081 active_session: Some(address.clone()),
11082 environment_profile: current_snapshot.environment_profile,
11083 bundle: current_snapshot.bundle,
11084 context_id: current_snapshot.context_id,
11085 context: current_snapshot.context,
11086 exported_context: None,
11092 task_binding: current_snapshot.task_binding,
11093 created_at_unix_ms: now,
11094 updated_at_unix_ms: now,
11095 last_error: None,
11096 };
11097 records
11098 .records
11099 .insert(new_record_id.clone(), replacement.clone());
11100 upserts.push(replacement);
11101 rebind = Some(new_record_id);
11102 } else if let Some(current) = records.records.get_mut(record_id) {
11103 current.state = ManagedSessionState::Unavailable;
11104 current.last_error =
11105 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11106 upserts.push(current.clone());
11107 }
11108 } else if let Some(current) = records.records.get_mut(record_id) {
11109 current.provider_session = Some(identity);
11110 current.active_session = Some(address.clone());
11111 current.state = ManagedSessionState::Live;
11112 current.updated_at_unix_ms = unix_time_ms();
11113 current.last_error = None;
11114 upserts.push(current.clone());
11115 }
11116 } else if let Some(current) = records.records.get_mut(record_id) {
11117 if resume_command_rejected {
11118 current.active_session = None;
11119 current.state = detached_record_state(current);
11120 current.updated_at_unix_ms = unix_time_ms();
11121 current.last_error =
11122 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11123 upserts.push(current.clone());
11124 } else {
11125 match &event.event {
11126 ControlEventKind::Running { .. }
11127 | ControlEventKind::ResumeAuthorized { .. }
11128 | ControlEventKind::Resumed { .. } => {
11129 current.active_session = Some(address.clone());
11130 current.state = active_record_state(
11131 identity_admitted,
11132 current.provider_session.is_some(),
11133 );
11134 current.updated_at_unix_ms = unix_time_ms();
11135 current.last_error = None;
11136 upserts.push(current.clone());
11137 }
11138 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11139 current.active_session = None;
11140 current.state = detached_record_state(current);
11141 current.updated_at_unix_ms = unix_time_ms();
11142 upserts.push(current.clone());
11143 }
11144 ControlEventKind::Failed { message }
11145 | ControlEventKind::ResumeFailed { message } => {
11146 current.active_session = None;
11147 current.state = detached_record_state(current);
11148 current.updated_at_unix_ms = unix_time_ms();
11149 current.last_error = Some(
11150 session_registry::sanitized_record_error_summary(message),
11151 );
11152 upserts.push(current.clone());
11153 }
11154 ControlEventKind::ResumeDenied { reason } => {
11155 current.active_session = None;
11156 current.state = detached_record_state(current);
11157 current.updated_at_unix_ms = unix_time_ms();
11158 current.last_error = Some(
11159 session_registry::sanitized_record_error_summary(reason),
11160 );
11161 upserts.push(current.clone());
11162 }
11163 _ => {}
11164 }
11165 }
11166 }
11167 }
11168 let materialization_owner_record = identity_was_observed.then(|| {
11169 rebind.clone().unwrap_or_else(|| record_id.clone())
11170 }).and_then(|candidate| {
11171 self.session_records
11172 .lock()
11173 .unwrap_or_else(|poisoned| poisoned.into_inner())
11174 .records
11175 .get(&candidate)
11176 .filter(|record| {
11177 record.provider_session.is_some()
11178 && record.active_session.as_ref() == Some(address)
11179 })
11180 .map(|_| candidate)
11181 });
11182 if let Some(owner_record_id) = materialization_owner_record {
11183 let materialization_id = self
11184 .session_bindings
11185 .lock()
11186 .unwrap_or_else(|poisoned| poisoned.into_inner())
11187 .get(&address.session.instance_id)
11188 .and_then(|binding| binding.materialization_id.clone());
11189 if let Some(materialization_id) = materialization_id {
11190 let mut transfer_failed = false;
11191 {
11192 let mut materializations = self
11193 .materializations
11194 .lock()
11195 .unwrap_or_else(|poisoned| poisoned.into_inner());
11196 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11197 match ownership.owner() {
11198 MaterializationOwner::Session {
11199 incarnation_id,
11200 instance_id,
11201 generation,
11202 } if *incarnation_id == self.incarnation_id
11203 && *instance_id == address.session.instance_id
11204 && *generation == address.session.generation =>
11205 {
11206 transfer_failed = ownership
11207 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11208 .is_err();
11209 }
11210 MaterializationOwner::Record { record_id }
11211 if record_id == &owner_record_id => {}
11212 MaterializationOwner::Record { record_id } => {
11213 let expected = record_id.clone();
11214 transfer_failed = ownership
11215 .transfer_record_owner(
11216 &expected,
11217 owner_record_id.clone(),
11218 unix_time_ms(),
11219 )
11220 .is_err();
11221 }
11222 _ => transfer_failed = true,
11223 }
11224 if transfer_failed {
11225 let _ = ownership.mark_recovery_required(unix_time_ms());
11226 }
11227 } else {
11228 transfer_failed = true;
11229 }
11230 }
11231 if transfer_failed {
11232 if let Some(record) = self
11233 .session_records
11234 .lock()
11235 .unwrap_or_else(|poisoned| poisoned.into_inner())
11236 .records
11237 .get_mut(&owner_record_id)
11238 {
11239 record.state = ManagedSessionState::Unavailable;
11240 record.last_error = Some("environment-profile-unavailable".to_owned());
11241 record.updated_at_unix_ms = unix_time_ms();
11242 upserts.push(record.clone());
11243 }
11244 }
11245 }
11246 }
11247 if let Some(new_record_id) = rebind.clone() {
11248 if let Some(binding) = self
11249 .session_bindings
11250 .lock()
11251 .unwrap_or_else(|poisoned| poisoned.into_inner())
11252 .get_mut(&address.session.instance_id)
11253 {
11254 binding.record_id = Some(new_record_id);
11255 }
11256 }
11257 if upserts.is_empty() && removals.is_empty() {
11258 return;
11259 }
11260 if self.persist_state_locked().is_err() {
11261 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11262 let mut records = self
11263 .session_records
11264 .lock()
11265 .unwrap_or_else(|poisoned| poisoned.into_inner());
11266 records.records = original_records;
11267 *self
11268 .materializations
11269 .lock()
11270 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11271 if let Some(current) = records.records.get_mut(record_id) {
11272 current.state = ManagedSessionState::Unavailable;
11273 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11274 current.updated_at_unix_ms = unix_time_ms();
11275 upserts = vec![current.clone()];
11276 } else {
11277 upserts.clear();
11278 }
11279 removals.clear();
11280 drop(records);
11281 if let Some(binding) = self
11282 .session_bindings
11283 .lock()
11284 .unwrap_or_else(|poisoned| poisoned.into_inner())
11285 .get_mut(&address.session.instance_id)
11286 {
11287 binding.record_id = Some(record_id.clone());
11288 }
11289 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11290 }
11291 for record in upserts {
11292 self.publish_record(record);
11293 }
11294 for record_id in removals {
11295 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11296 }
11297 }
11298
11299 fn snapshot(&self) -> NodeSnapshot {
11300 let control = self.handle.snapshot();
11301 let bindings = self
11302 .session_bindings
11303 .lock()
11304 .unwrap_or_else(|poisoned| poisoned.into_inner())
11305 .clone();
11306 let workspace_roots = self
11307 .workspaces
11308 .read()
11309 .unwrap_or_else(|poisoned| poisoned.into_inner())
11310 .clone();
11311 let environment_profiles = self
11312 .environment_profiles
11313 .read()
11314 .unwrap_or_else(|poisoned| poisoned.into_inner());
11315 let mut workspaces = workspace_roots
11316 .iter()
11317 .map(|(workspace_id, canonical_root)| {
11318 (
11319 workspace_id.clone(),
11320 WorkspaceSnapshot {
11321 workspace_id: workspace_id.clone(),
11322 canonical_root: opaque_windows_path(canonical_root.clone()),
11323 sessions: Vec::new(),
11324 worktree_service_mode: Some(
11325 self.worktree_service_modes
11326 .get(workspace_id)
11327 .copied()
11328 .unwrap_or(WorktreeServiceMode::Manual),
11329 ),
11330 managed_worktree_profiles: Some(WorktreeProfileInventory {
11331 profiles: self
11332 .managed_worktree_profiles
11333 .get(workspace_id)
11334 .into_iter()
11335 .flat_map(BTreeMap::values)
11336 .map(|profile| ManagedWorktreeProfileSummary {
11337 id: profile.profile_id().clone(),
11338 revision: profile.revision().clone(),
11339 retention: profile.retention(),
11340 })
11341 .collect(),
11342 }),
11343 },
11344 )
11345 })
11346 .collect::<BTreeMap<_, _>>();
11347 let mut agent_progress = Vec::new();
11348 for session in &control.sessions {
11349 let Some(binding) = bindings.get(&session.instance_id) else {
11350 continue;
11351 };
11352 if binding.generation != session.generation {
11353 continue;
11354 }
11355 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11356 let address = SessionAddress {
11357 workspace_id: binding.workspace_id.clone(),
11358 session: SessionKey {
11359 instance_id: session.instance_id,
11360 generation: session.generation,
11361 },
11362 };
11363 if let Some(progress) =
11364 agent_progress_from_provider_snapshot(address, &session.provider)
11365 {
11366 agent_progress.push(progress);
11367 }
11368 workspace.sessions.push(session.clone());
11369 }
11370 }
11371 NodeSnapshot {
11372 node_id: self.node_id.clone(),
11373 enabled_providers: self.enabled_providers.clone(),
11374 provider_runtime_statuses: {
11375 let updates = self
11376 .provider_runtime_status_updates
11377 .read()
11378 .unwrap_or_else(|poisoned| poisoned.into_inner());
11379 ProviderRuntimeStatuses::new(
11380 self.provider_runtime_statuses
11381 .iter()
11382 .filter(|status| !updates.contains_key(status.provider()))
11383 .cloned()
11384 .chain(updates.values().cloned()),
11385 )
11386 .expect("refreshed provider runtime inventory remains bounded and unique")
11387 },
11388 workspaces: workspaces.into_values().collect(),
11389 session_records: self
11390 .session_records
11391 .lock()
11392 .unwrap_or_else(|poisoned| poisoned.into_inner())
11393 .records
11394 .values()
11395 .cloned()
11396 .collect(),
11397 managed_worktrees: self.managed_worktrees
11398 .lock()
11399 .unwrap_or_else(|poisoned| poisoned.into_inner())
11400 .snapshots(),
11401 launch_inventory: Some(LaunchInventory {
11402 spawn_profiles: Some(
11403 self.spawn_profiles
11404 .iter()
11405 .filter_map(|profile| {
11406 resolved_spawn_profile_summary(profile, &environment_profiles)
11407 })
11408 .collect(),
11409 ),
11410 bundles: Some(
11411 self.bundle_catalog
11412 .read()
11413 .unwrap_or_else(|poisoned| poisoned.into_inner())
11414 .iter()
11415 .map(NodeBundle::receipt)
11416 .collect(),
11417 ),
11418 network_allowlists: {
11421 let catalog = self
11422 .network_allowlist_catalog
11423 .read()
11424 .unwrap_or_else(|poisoned| poisoned.into_inner());
11425 if catalog.is_empty() {
11426 None
11427 } else {
11428 Some(catalog.ids().cloned().collect())
11429 }
11430 },
11431 }),
11432 agent_progress,
11433 }
11434 }
11435
11436 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11437 assert!(
11438 !matches!(
11439 &event,
11440 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11441 ),
11442 "terminal frame and agent stream events must use their own dedicated live channel",
11443 );
11444 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11445 let sequence = history
11446 .last_sequence
11447 .checked_add(1)
11448 .expect("node event sequence exhausted");
11449 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11450 if let Some(evicted) = history.events.pop_front() {
11451 history.replay_floor_sequence = evicted
11452 .sequence
11453 .checked_add(1)
11454 .expect("node replay floor sequence exhausted");
11455 history.removed_record_providers.remove(&evicted.sequence);
11456 }
11457 }
11458 match &event {
11459 NodeEvent::HarnessMcpReadCall { .. } => {
11460 panic!("transient harness MCP calls must not enter durable event history")
11461 }
11462 NodeEvent::SessionRecordUpserted { record } => {
11463 history
11464 .record_providers
11465 .insert(record.record_id.clone(), record.provider.clone());
11466 }
11467 NodeEvent::SessionRecordRemoved { record_id } => {
11468 if let Some(provider) = history.record_providers.remove(record_id) {
11469 history.removed_record_providers.insert(sequence, provider);
11470 }
11471 }
11472 NodeEvent::ManagedWorktreeUpserted { .. }
11473 | NodeEvent::ManagedWorktreeRemoved { .. }
11474 | NodeEvent::Control { .. }
11475 | NodeEvent::ControllerChanged { .. }
11476 | NodeEvent::SessionRecordHistorySummarized { .. }
11477 | NodeEvent::WorkspaceAdded { .. }
11478 | NodeEvent::WorkspaceRemoved { .. }
11479 | NodeEvent::ResyncRequired { .. }
11480 | NodeEvent::AgentStream { .. }
11481 | NodeEvent::TerminalFrame { .. } => {}
11482 }
11483 let envelope = NodeEventEnvelope { sequence, event };
11484 history.events.push_back(envelope.clone());
11485 history.last_sequence = sequence;
11486 let _ = self.event_tx.send(envelope.clone());
11487 drop(history);
11488 envelope
11489 }
11490
11491 fn publish_transient(&self, event: NodeEvent) -> bool {
11502 assert!(event.requires_harness_mcp_proxy_capability());
11503 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11504 let envelope = NodeEventEnvelope { sequence: 0, event };
11505 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11506 Ok(_) => true,
11507 Err(broadcast::error::SendError(undelivered)) => {
11508 if let Some(NodeEventEnvelope {
11509 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11510 ..
11511 }) = undelivered.first() {
11512 tracing::warn!(
11518 call_id = call_id.as_str(),
11519 content_type = ?request.content_type,
11520 cause = "no-subscribers",
11521 "harness MCP proxy event could not be published",
11522 );
11523 }
11524 false
11525 }
11526 }
11527 }
11528
11529 fn publish_terminal_frames(&self) {
11530 let snapshot = self.handle.snapshot();
11531 let bindings = self
11532 .session_bindings
11533 .lock()
11534 .unwrap_or_else(|poisoned| poisoned.into_inner());
11535 let candidates = snapshot
11536 .sessions
11537 .iter()
11538 .filter_map(|session| {
11539 let binding = bindings.get(&session.instance_id)?;
11540 if binding.generation != session.generation {
11541 return None;
11542 }
11543 let frame = session.terminal_frame.clone()?;
11544 crate::session_signals::feed(
11545 binding.workspace_id.as_str(),
11546 session.instance_id.0,
11547 session.agent_id.as_str(),
11548 &frame.contents,
11549 );
11550 Some((
11551 SessionAddress {
11552 workspace_id: binding.workspace_id.clone(),
11553 session: SessionKey {
11554 instance_id: session.instance_id,
11555 generation: session.generation,
11556 },
11557 },
11558 frame,
11559 ))
11560 })
11561 .collect();
11562 drop(bindings);
11563 self.publish_terminal_frame_candidates(candidates);
11564 }
11565
11566 fn publish_terminal_frame_candidates(
11567 &self,
11568 candidates: Vec<(SessionAddress, TerminalFrame)>,
11569 ) {
11570 let mut watermarks = self
11571 .terminal_frame_watermarks
11572 .lock()
11573 .unwrap_or_else(|poisoned| poisoned.into_inner());
11574 let mut events = Vec::with_capacity(candidates.len());
11575 for (address, frame) in candidates {
11576 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11577 let advanced = watermarks
11578 .get(&address.session.instance_id)
11579 .map_or(true, |(current_address, sequence)| {
11580 current_address != &address || frame.sequence > *sequence
11581 });
11582 if !advanced {
11583 continue;
11584 }
11585 if is_first_frame {
11586 if self.controller_state().is_none() {
11600 tracing::warn!(
11601 node_id = %self.node_id,
11602 workspace_id = %address.workspace_id,
11603 session = ?address.session,
11604 terminal_size = ?frame.size,
11605 "session producing output with no controller attached",
11606 );
11607 } else {
11608 tracing::info!(
11609 node_id = %self.node_id,
11610 workspace_id = %address.workspace_id,
11611 session = ?address.session,
11612 terminal_size = ?frame.size,
11613 "session produced its first output",
11614 );
11615 }
11616 }
11617 watermarks.insert(
11618 address.session.instance_id,
11619 (address.clone(), frame.sequence),
11620 );
11621 if let Some((at, reported)) = self
11622 .pty_input_marks
11623 .lock()
11624 .unwrap_or_else(|poisoned| poisoned.into_inner())
11625 .get_mut(&address.session.instance_id)
11626 {
11627 if !*reported && at.elapsed() < Duration::from_secs(5) {
11629 *reported = true;
11630 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");
11631 }
11632 }
11633 events.push(NodeEvent::TerminalFrame { address, frame });
11634 }
11635 drop(watermarks);
11636 if events.is_empty() {
11637 return;
11638 }
11639 let mut history = self
11640 .history
11641 .lock()
11642 .unwrap_or_else(|poisoned| poisoned.into_inner());
11643 let mut envelopes = Vec::with_capacity(events.len());
11644 for event in events {
11645 let sequence = history
11646 .last_sequence
11647 .checked_add(1)
11648 .expect("node event sequence exhausted");
11649 history.last_sequence = sequence;
11650 envelopes.push(NodeEventEnvelope { sequence, event });
11651 }
11652 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11653 drop(history);
11654 }
11655
11656 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11665 let mut history = self
11666 .history
11667 .lock()
11668 .unwrap_or_else(|poisoned| poisoned.into_inner());
11669 let sequence = history
11670 .last_sequence
11671 .checked_add(1)
11672 .expect("node event sequence exhausted");
11673 history.last_sequence = sequence;
11674 drop(history);
11675 tracing::debug!(
11683 source_sequence = chunk.source_sequence,
11684 kind = agent_stream_chunk_kind_label(&chunk.kind),
11685 envelope_sequence = sequence,
11686 "publishing agent stream chunk",
11687 );
11688 let envelope = NodeEventEnvelope {
11689 sequence,
11690 event: NodeEvent::AgentStream { address, chunk },
11691 };
11692 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11693 }
11694
11695 fn current_sequence(&self) -> u64 {
11696 self.history
11697 .lock()
11698 .unwrap_or_else(|poisoned| poisoned.into_inner())
11699 .last_sequence
11700 }
11701
11702 fn controller_state(&self) -> Option<ControllerState> {
11703 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11704 let lease = controller.as_ref().copied()?;
11705 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11706 if remaining.is_zero() {
11707 *controller = None;
11708 return None;
11709 }
11710 Some(ControllerState {
11711 connection_id: lease.connection_id,
11712 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11713 })
11714 }
11715
11716 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11717 if role != ClientRole::Operator {
11718 tracing::warn!(
11719 node_id = %self.node_id,
11720 connection_id,
11721 "controller lease rejected: observer connections cannot acquire control",
11722 );
11723 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11724 }
11725 let lease_ms = if lease_ms == 0 {
11726 DEFAULT_CONTROLLER_LEASE_MS
11727 } else {
11728 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11729 };
11730 let now = Instant::now();
11731 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11732 if let Some(active) = controller.as_ref() {
11733 if active.connection_id != connection_id && active.expires_at > now {
11734 tracing::warn!(
11735 node_id = %self.node_id,
11736 connection_id,
11737 holder_connection_id = active.connection_id,
11738 "controller lease rejected: another operator holds it",
11739 );
11740 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11741 }
11742 }
11743 let is_new_attach = match controller.as_ref() {
11750 Some(active) => active.connection_id != connection_id,
11751 None => true,
11752 };
11753 *controller = Some(ControllerLease {
11754 connection_id,
11755 expires_at: now + Duration::from_millis(lease_ms),
11756 });
11757 drop(controller);
11758 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11759 if is_new_attach {
11760 tracing::info!(
11761 node_id = %self.node_id,
11762 connection_id,
11763 lease_ms,
11764 "controller lease acquired",
11765 );
11766 } else {
11767 tracing::debug!(
11768 node_id = %self.node_id,
11769 connection_id,
11770 lease_ms,
11771 "controller lease renewed",
11772 );
11773 }
11774 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11775 Ok(state)
11776 }
11777
11778 fn release_controller(&self, connection_id: u64) -> bool {
11779 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11780 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11781 if released {
11782 *controller = None;
11783 }
11784 drop(controller);
11785 if released {
11786 tracing::info!(
11787 node_id = %self.node_id,
11788 connection_id,
11789 "controller lease released",
11790 );
11791 self.publish(NodeEvent::ControllerChanged { controller: None });
11792 }
11793 released
11794 }
11795
11796 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11805 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11806 return;
11807 };
11808 let id = address.session.instance_id;
11809 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11810 return;
11811 };
11812 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11813 .iter()
11814 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11815 .collect();
11816 let (writes, set_model, effort_gap) =
11817 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11818 if set_model {
11819 if let Some(model_id) = model.clone() {
11820 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11821 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11822 }
11823 }
11824 }
11825 for (option_id, value_json) in writes {
11826 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11827 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11828 }
11829 }
11830 if effort_gap {
11831 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11832 }
11833 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11834 }
11835
11836 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11839 let id = address.session.instance_id;
11840 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11841 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11842 match provider_event {
11843 ProviderEvent::ModeChanged { mode_id, available } => {
11844 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11845 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11846 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11847 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11848 }
11849 }
11850 }
11851 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11852 match self.dispatch(ControlCommand::ResolveInteraction {
11853 instance_id: id,
11854 generation: address.session.generation,
11855 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11856 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11857 }) {
11858 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11859 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11860 }
11861 }
11862 ProviderEvent::SessionEnded { .. } => crate::auto_permit::forget_acp(id.0),
11863 _ => {}
11864 }
11865 }
11866
11867 fn mark_spawn_provisional(&self, address: SessionAddress) {
11868 let id = address.session.instance_id;
11869 self.provisional_spawns
11870 .lock()
11871 .unwrap_or_else(|poisoned| poisoned.into_inner())
11872 .insert(id, (address, Instant::now()));
11873 tracing::info!(
11874 node_id = %self.node_id,
11875 instance_id = id.0,
11876 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11877 "draft spawn; waiting for HQ view",
11878 );
11879 }
11880
11881 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11882 let removed = self
11883 .provisional_spawns
11884 .lock()
11885 .unwrap_or_else(|poisoned| poisoned.into_inner())
11886 .remove(&instance_id);
11887 if removed.is_some() {
11888 tracing::info!(
11889 node_id = %self.node_id,
11890 instance_id = instance_id.0,
11891 "draft spawn claimed by HQ view",
11892 );
11893 }
11894 }
11895
11896 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11897 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11898 let mut map = self
11899 .provisional_spawns
11900 .lock()
11901 .unwrap_or_else(|poisoned| poisoned.into_inner());
11902 let due: Vec<AgentInstanceId> = map
11903 .iter()
11904 .filter(|(_, (_, at))| at.elapsed() >= grace)
11905 .map(|(id, _)| *id)
11906 .collect();
11907 due.into_iter()
11908 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11909 .collect()
11910 }
11911
11912 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11913 let instance_id = address.session.instance_id;
11914 tracing::warn!(
11915 node_id = %self.node_id,
11916 instance_id = instance_id.0,
11917 workspace = %address.workspace_id,
11918 reason = "attach_never_arrived",
11919 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11920 "draft spawn never viewed; stopping PTY",
11921 );
11922 let _ = self.dispatch(ControlCommand::Stop {
11923 instance_id,
11924 force: true,
11925 });
11926 let _ = self.remove_session(&address).await;
11927 }
11928
11929 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11930 if role != ClientRole::Operator {
11931 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11932 }
11933 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11934 let now = Instant::now();
11935 match controller.as_ref().copied() {
11936 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11937 Some(active) if active.expires_at <= now => {
11938 *controller = None;
11939 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11940 }
11941 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11942 }
11943 }
11944
11945 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11946 if !self
11947 .workspaces
11948 .read()
11949 .unwrap_or_else(|poisoned| poisoned.into_inner())
11950 .contains_key(&address.workspace_id)
11951 {
11952 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11953 }
11954 let binding = self
11955 .session_bindings
11956 .lock()
11957 .unwrap_or_else(|poisoned| poisoned.into_inner())
11958 .get(&address.session.instance_id)
11959 .cloned()
11960 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11961 if binding.workspace_id != address.workspace_id {
11962 return Err(failure(
11963 NodeFailureCode::SessionWorkspaceMismatch,
11964 "session belongs to another workspace",
11965 ));
11966 }
11967 if binding.generation != address.session.generation {
11968 return Err(failure(
11969 NodeFailureCode::StaleGeneration,
11970 &format!(
11971 "stale session generation: expected {}, received {}",
11972 binding.generation.0,
11973 address.session.generation.0,
11974 ),
11975 ));
11976 }
11977 let snapshot = self.handle.snapshot();
11978 let Some(current) = snapshot
11979 .sessions
11980 .iter()
11981 .find(|item| item.instance_id == address.session.instance_id)
11982 else {
11983 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11984 };
11985 if current.generation != address.session.generation {
11986 return Err(failure(
11987 NodeFailureCode::StaleGeneration,
11988 &format!(
11989 "stale session generation: expected {}, received {}",
11990 current.generation.0,
11991 address.session.generation.0,
11992 ),
11993 ));
11994 }
11995 Ok(current.agent_id.clone())
11996 }
11997
11998 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11999 self.dispatch_envelope(self.prepare_command(command))
12000 }
12001
12002 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12003 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12004 CommandEnvelope {
12005 id: CommandId(id),
12006 command,
12007 }
12008 }
12009
12010 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12011 let command_id = envelope.id;
12012 self.handle
12013 .dispatch(envelope)
12014 .map_err(|error| match error {
12015 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12016 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12017 })?;
12018 Ok(command_id)
12019 }
12020
12021 fn record_rejected_command(
12030 &self,
12031 command_id: CommandId,
12032 message: String,
12033 unsupported_transport: bool,
12034 ) {
12035 let now = Instant::now();
12036 let mut reasons = self.rejected_command_reasons.lock()
12037 .unwrap_or_else(|poisoned| poisoned.into_inner());
12038 reasons.retain(|_, reason| reason.expires_at > now);
12039 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12040 if let Some(&oldest) = reasons.keys().next() {
12041 reasons.remove(&oldest);
12042 }
12043 }
12044 reasons.insert(
12045 command_id,
12046 RejectedCommandReason {
12047 message,
12048 unsupported_transport,
12049 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12050 },
12051 );
12052 }
12053
12054 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12061 let now = Instant::now();
12062 let mut reasons = self.rejected_command_reasons.lock()
12063 .unwrap_or_else(|poisoned| poisoned.into_inner());
12064 match reasons.remove(&command_id) {
12065 Some(reason) if reason.expires_at > now => Some(reason),
12066 _ => None,
12067 }
12068 }
12069
12070 async fn dispatch_bounded(
12071 &self,
12072 command: ControlCommand,
12073 timeout_duration: Duration,
12074 ) -> Result<CommandId, NodeFailure> {
12075 let deadline = Instant::now() + timeout_duration;
12076 loop {
12077 match self.dispatch(command.clone()) {
12078 Ok(command_id) => return Ok(command_id),
12079 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12080 sleep(Duration::from_millis(2)).await;
12081 }
12082 Err(error) => return Err(error),
12083 }
12084 }
12085 }
12086
12087 fn internal_session_snapshot(
12088 &self,
12089 address: &SessionAddress,
12090 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12091 self.validate_address(address)?;
12092 self.handle
12093 .snapshot()
12094 .sessions
12095 .clone()
12096 .into_iter()
12097 .find(|session| {
12098 session.instance_id == address.session.instance_id
12099 && session.generation == address.session.generation
12100 })
12101 .ok_or_else(|| {
12102 failure(
12103 NodeFailureCode::UnknownSession,
12104 "session snapshot is unavailable",
12105 )
12106 })
12107 }
12108
12109 fn require_acp_session(
12117 &self,
12118 address: &SessionAddress,
12119 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12120 let snapshot = self.internal_session_snapshot(address)?;
12121 if snapshot.transport != TransportKind::Acp {
12122 return Err(failure(
12123 NodeFailureCode::UnsupportedTransport,
12124 "this verb requires a session spawned over the ACP transport",
12125 ));
12126 }
12127 Ok(snapshot)
12128 }
12129
12130 async fn dispatch_history_bounded(
12131 &self,
12132 address: &SessionAddress,
12133 command: ControlCommand,
12134 operation: HistoryOperation,
12135 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12136 self.validate_address(address)?;
12137 let started_after = self.current_sequence();
12138 let command_id = self
12139 .dispatch_bounded(
12140 command,
12141 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12142 )
12143 .await?;
12144 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12145 let mut scan_after = started_after;
12146 let mut accepted_after = None;
12147 loop {
12148 let events = self
12149 .history
12150 .lock()
12151 .unwrap_or_else(|poisoned| poisoned.into_inner())
12152 .events
12153 .iter()
12154 .filter(|envelope| envelope.sequence > scan_after)
12155 .cloned()
12156 .collect::<Vec<_>>();
12157 for envelope in events {
12158 scan_after = scan_after.max(envelope.sequence);
12159 let NodeEvent::Control {
12160 address: event_address,
12161 event,
12162 } = envelope.event
12163 else {
12164 continue;
12165 };
12166 if event_address != *address {
12167 continue;
12168 }
12169 if event.command_id == Some(command_id) {
12170 match &event.event {
12171 ControlEventKind::CommandRejected { .. } => {
12172 return Err(failure(
12173 NodeFailureCode::InvalidRequest,
12174 "history request was rejected",
12175 ));
12176 }
12177 ControlEventKind::HistoryRequested {
12178 operation: requested,
12179 ..
12180 } if requested == &operation => {
12181 accepted_after = Some(envelope.sequence);
12182 continue;
12183 }
12184 _ => {}
12185 }
12186 }
12187 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12188 match (&operation, &event.event) {
12189 (
12190 HistoryOperation::Discover { .. },
12191 ControlEventKind::HistoryDiscovered { .. },
12192 )
12193 | (
12194 HistoryOperation::Load { .. },
12195 ControlEventKind::HistoryLoaded { .. },
12196 ) => return self.internal_session_snapshot(address),
12197 (_, ControlEventKind::HistoryFailed { .. }) => {
12198 return Err(failure(
12199 NodeFailureCode::BackendOperationFailed,
12200 "history operation failed",
12201 ));
12202 }
12203 _ => {}
12204 }
12205 }
12206 }
12207 if Instant::now() >= deadline {
12208 return Err(failure(
12209 NodeFailureCode::BackendBusy,
12210 if accepted_after.is_some() {
12211 "history operation did not settle before the bounded deadline"
12212 } else {
12213 "history operation was not accepted before the bounded deadline"
12214 },
12215 ));
12216 }
12217 sleep(Duration::from_millis(2)).await;
12218 }
12219 }
12220
12221 async fn discover_history(
12222 &self,
12223 address: &SessionAddress,
12224 limit: u16,
12225 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12226 let query = HistoryQuery {
12227 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12228 limit,
12229 };
12230 query
12231 .validate()
12232 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12233 let snapshot = self
12234 .dispatch_history_bounded(
12235 address,
12236 ControlCommand::DiscoverHistory {
12237 instance_id: address.session.instance_id,
12238 query: query.clone(),
12239 },
12240 HistoryOperation::Discover { query },
12241 )
12242 .await?;
12243 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12244 return Err(failure(
12245 NodeFailureCode::BackendOperationFailed,
12246 "history discovery settled into an invalid state",
12247 ));
12248 }
12249 Ok(snapshot.history.candidates)
12250 }
12251
12252 async fn load_history(
12253 &self,
12254 address: &SessionAddress,
12255 candidate_id: String,
12256 ) -> Result<HistorySessionRecord, NodeFailure> {
12257 validate_candidate_id(&candidate_id)
12258 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12259 let snapshot = self
12260 .dispatch_history_bounded(
12261 address,
12262 ControlCommand::LoadHistory {
12263 instance_id: address.session.instance_id,
12264 candidate_id: candidate_id.clone(),
12265 },
12266 HistoryOperation::Load {
12267 candidate_id: candidate_id.clone(),
12268 },
12269 )
12270 .await?;
12271 if snapshot.history.pending.is_some()
12272 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12273 {
12274 return Err(failure(
12275 NodeFailureCode::BackendOperationFailed,
12276 "history load settled into an invalid state",
12277 ));
12278 }
12279 snapshot.history.loaded.ok_or_else(|| {
12280 failure(
12281 NodeFailureCode::BackendOperationFailed,
12282 "history load produced no bounded session",
12283 )
12284 })
12285 }
12286
12287 async fn export_context_pack_for_session_record(
12294 &self,
12295 record_id: &SessionRecordId,
12296 session: &SessionAddress,
12297 allow_clean_detachment: bool,
12298 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12299 let outcome = self
12305 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12306 .await;
12307 if let Err(failure) = &outcome {
12308 tracing::warn!(
12309 record = %record_id,
12310 code = ?failure.code,
12311 reason = %failure.message,
12312 "session-record context-pack export refused",
12313 );
12314 }
12315 outcome
12316 }
12317
12318 async fn export_context_pack_for_session_record_inner(
12319 &self,
12320 record_id: &SessionRecordId,
12321 session: &SessionAddress,
12322 allow_clean_detachment: bool,
12323 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12324 let step = |name: &'static str| move |failure: NodeFailure| {
12329 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12330 failure
12331 };
12332 let record = self.record(record_id).map_err(step("record-lookup"))?;
12333 let identity = match record.provider_session.clone() {
12349 Some(identity) => {
12350 if identity.key != ProviderSessionKey::SessionId {
12351 return Err(step("provider-identity-key")(failure(
12352 NodeFailureCode::BackendOperationFailed,
12353 "managed session provider identity cannot bind history exactly",
12354 )));
12355 }
12356 Some(identity)
12357 }
12358 None if record.state == ManagedSessionState::IdentityPending => {
12359 return Err(step("provider-session-identity")(failure(
12360 NodeFailureCode::BackendOperationFailed,
12361 "managed session has not yet observed its provider session identity",
12362 )));
12363 }
12364 None => None,
12365 };
12366 self.revalidate_session_record_context_export(
12367 &record,
12368 identity.as_ref(),
12369 session,
12370 allow_clean_detachment,
12371 ).map_err(step("revalidate-before-discovery"))?;
12372 let candidates = self
12373 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12374 .await
12375 .map_err(step("discover-history"))?;
12376 let candidate_id = select_history_candidate(
12381 &candidates,
12382 identity.as_ref().map(|identity| identity.id.as_str()),
12383 ).map_err(step("unique-candidate"))?;
12384 let loaded = self.load_history(session, candidate_id.clone()).await
12385 .map_err(step("load-history"))?;
12386 if let Some(identity) = identity.as_ref() {
12387 if loaded.session_id != identity.id {
12388 return Err(failure(
12389 NodeFailureCode::SessionRecordConflict,
12390 "loaded history identity does not match the managed session record",
12391 ));
12392 }
12393 }
12394 self.revalidate_session_record_context_export(
12395 &record,
12396 identity.as_ref(),
12397 session,
12398 allow_clean_detachment,
12399 )?;
12400 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12401 .map_err(step("revalidate-loaded-history"))?;
12402 let pack = self.materialize_context_pack(session).await
12403 .map_err(step("materialize-context-pack"))?;
12404 self.commit_context_pack_for_session_record(
12405 &record,
12406 identity.as_ref(),
12407 session,
12408 &candidate_id,
12409 &loaded,
12410 pack,
12411 allow_clean_detachment,
12412 )
12413 }
12414
12415 async fn export_context_pack(
12416 &self,
12417 address: &SessionAddress,
12418 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12419 let pack = self.materialize_context_pack(address).await?;
12420 self.commit_context_pack(pack)
12421 }
12422
12423 async fn materialize_context_pack(
12424 &self,
12425 address: &SessionAddress,
12426 ) -> Result<NodeContextPack, NodeFailure> {
12427 let snapshot = self.internal_session_snapshot(address)?;
12428 if !context_pack_source_status_is_usable(&snapshot.status) {
12429 return Err(failure(
12430 NodeFailureCode::InvalidRequest,
12431 "context export requires a running or successfully exited source session",
12432 ));
12433 }
12434 if snapshot.history.pending.is_some()
12435 || snapshot.history.loaded_candidate_id.is_none()
12436 {
12437 return Err(failure(
12438 NodeFailureCode::InvalidRequest,
12439 "context export requires settled loaded history",
12440 ));
12441 }
12442 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12443 failure(
12444 NodeFailureCode::InvalidRequest,
12445 "context export requires settled loaded history",
12446 )
12447 })?;
12448 let source_provider = snapshot.agent_id.clone();
12449 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12450 let repository = self
12451 .context_pack_repository(&address.workspace_id)
12452 .await?;
12453 let current = self.internal_session_snapshot(address)?;
12454 if !context_pack_source_status_is_usable(¤t.status)
12455 || current.agent_id != source_provider
12456 || current.history.pending.is_some()
12457 || current.history.loaded_candidate_id != loaded_candidate_id
12458 || current.history.loaded.as_ref() != Some(&loaded)
12459 {
12460 return Err(failure(
12461 NodeFailureCode::ContextPackMaterializationFailed,
12462 "context source changed during bounded repository capture",
12463 ));
12464 }
12465 NodeContextPack::export_with_repository(
12466 ContextPackLineageReceipt {
12467 source_node_id: self.node_id.clone(),
12468 source_session: address.clone(),
12469 source_provider,
12470 },
12471 &loaded,
12472 Some(repository),
12473 )
12474 .map_err(|_| {
12475 failure(
12476 NodeFailureCode::ContextPackMaterializationFailed,
12477 "bounded context pack export failed",
12478 )
12479 })
12480 }
12481
12482 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12488 let mut store = self
12489 .context_pack_store
12490 .lock()
12491 .unwrap_or_else(|poisoned| poisoned.into_inner());
12492 let Some(store) = store.as_mut() else {
12493 return Ok(());
12494 };
12495 store.commit(pack).map_err(|_| {
12496 failure(
12497 NodeFailureCode::ContextPackMaterializationFailed,
12498 "durable context pack commit failed",
12499 )
12500 })
12501 }
12502
12503 fn commit_context_pack(
12504 &self,
12505 pack: NodeContextPack,
12506 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12507 self.commit_context_pack_durably(&pack)?;
12508 self.context_catalog
12509 .write()
12510 .unwrap_or_else(|poisoned| poisoned.into_inner())
12511 .insert(pack)
12512 .map_err(|_| {
12513 failure(
12514 NodeFailureCode::ContextPackMaterializationFailed,
12515 "context pack catalog is full",
12516 )
12517 })
12518 }
12519
12520 fn commit_context_pack_for_session_record(
12521 &self,
12522 expected: &ManagedSessionRecord,
12523 identity: Option<&ProviderSessionIdentity>,
12524 session: &SessionAddress,
12525 candidate_id: &str,
12526 loaded: &HistorySessionRecord,
12527 pack: NodeContextPack,
12528 allow_clean_detachment: bool,
12529 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12530 let current_root = self.workspace_root(&expected.workspace_id)?;
12531 let _transaction = self
12532 .state_transaction
12533 .lock()
12534 .unwrap_or_else(|poisoned| poisoned.into_inner());
12535 let records = self
12536 .session_records
12537 .lock()
12538 .unwrap_or_else(|poisoned| poisoned.into_inner());
12539 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12540 failure(
12541 NodeFailureCode::SessionRecordConflict,
12542 "managed session record disappeared during context export",
12543 )
12544 })?;
12545 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12546 return Err(failure(
12547 NodeFailureCode::SessionRecordConflict,
12548 "managed session record changed during context export",
12549 ));
12550 }
12551 let bindings = self
12552 .session_bindings
12553 .lock()
12554 .unwrap_or_else(|poisoned| poisoned.into_inner());
12555 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12556 failure(
12557 NodeFailureCode::SessionRecordConflict,
12558 "managed session runtime binding disappeared during context export",
12559 )
12560 })?;
12561 let runtime = self
12562 .handle
12563 .snapshot()
12564 .sessions
12565 .iter()
12566 .find(|snapshot| {
12567 snapshot.instance_id == session.session.instance_id
12568 && snapshot.generation == session.session.generation
12569 })
12570 .cloned()
12571 .ok_or_else(|| {
12572 failure(
12573 NodeFailureCode::SessionRecordConflict,
12574 "managed session runtime disappeared during context export",
12575 )
12576 })?;
12577 let receipt = pack.receipt();
12578 if !session_record_context_export_binding_is_exact(
12579 expected,
12580 current,
12581 identity,
12582 session,
12583 binding,
12584 &runtime.agent_id,
12585 ¤t_root,
12586 allow_clean_detachment,
12587 ) || !session_record_context_export_source_is_usable(
12588 current,
12589 &runtime.status,
12590 allow_clean_detachment,
12591 ) || runtime.history.pending.is_some()
12592 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12593 || runtime.history.loaded.as_ref() != Some(loaded)
12594 || receipt.lineage.source_node_id != self.node_id
12595 || receipt.lineage.source_session != *session
12596 || receipt.lineage.source_provider != current.provider
12597 || receipt.source_message_count != loaded.message_count
12598 {
12599 return Err(failure(
12600 NodeFailureCode::SessionRecordConflict,
12601 "managed session source changed before context catalog commit",
12602 ));
12603 }
12604 self.commit_context_pack_durably(&pack)?;
12605 self.context_catalog
12606 .write()
12607 .unwrap_or_else(|poisoned| poisoned.into_inner())
12608 .insert(pack)
12609 .map_err(|_| {
12610 failure(
12611 NodeFailureCode::ContextPackMaterializationFailed,
12612 "context pack catalog is full",
12613 )
12614 })
12615 }
12616
12617 async fn context_pack_repository(
12618 &self,
12619 workspace_id: &WorkspaceId,
12620 ) -> Result<ContextPackRepository, NodeFailure> {
12621 let first = self
12622 .context_pack_repository_observation(workspace_id)
12623 .await?;
12624 let second = self
12625 .context_pack_repository_observation(workspace_id)
12626 .await?;
12627 stable_context_pack_repository(first, second)
12628 }
12629
12630 async fn context_pack_repository_observation(
12631 &self,
12632 workspace_id: &WorkspaceId,
12633 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12634 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12635 let head = if git.is_repository {
12636 let root = self.workspace_root(workspace_id)?;
12637 match resolve_base_commit_with_timeout(
12638 &root,
12639 "HEAD",
12640 GIT_COMMAND_TIMEOUT_MS,
12641 )
12642 .await
12643 {
12644 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12645 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12646 ContextPackRepositoryHead::Unborn
12647 }
12648 Err(_) => {
12649 return Err(failure(
12650 NodeFailureCode::ContextPackMaterializationFailed,
12651 "context pack HEAD observation failed",
12652 ));
12653 }
12654 }
12655 } else {
12656 ContextPackRepositoryHead::NotRepository
12657 };
12658 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12659 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12660 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12661 failure(
12662 NodeFailureCode::InvalidRepositoryPath,
12663 "context pack selected file path is invalid",
12664 )
12665 })?;
12666 match self.read_workspace_file(workspace_id.clone(), path).await {
12667 Ok(WorkspaceFileRead {
12668 content: WorkspaceFileContent::Utf8 { text, byte_len },
12669 ..
12670 }) => files.push(ContextPackRepositoryFileSource::utf8(
12671 selected_path,
12672 text,
12673 byte_len,
12674 )),
12675 Ok(WorkspaceFileRead {
12676 content: WorkspaceFileContent::NonUtf8 { byte_len },
12677 ..
12678 }) => files.push(ContextPackRepositoryFileSource::skipped(
12679 selected_path,
12680 ContextPackSelectedFileSkipReason::NonUtf8,
12681 Some(byte_len),
12682 )),
12683 Ok(WorkspaceFileRead {
12684 content: WorkspaceFileContent::TooLarge { .. },
12685 ..
12686 }) => files.push(ContextPackRepositoryFileSource::skipped(
12687 selected_path,
12688 ContextPackSelectedFileSkipReason::TooLarge,
12689 None,
12690 )),
12691 Err(error) => {
12692 let reason = if matches!(
12693 error.code,
12694 NodeFailureCode::InvalidRepositoryPath
12695 | NodeFailureCode::RepositoryFileNotRegular
12696 | NodeFailureCode::RepositoryPathUnsafe
12697 ) {
12698 ContextPackSelectedFileSkipReason::Unsafe
12699 } else {
12700 ContextPackSelectedFileSkipReason::Unavailable
12701 };
12702 files.push(ContextPackRepositoryFileSource::skipped(
12703 selected_path,
12704 reason,
12705 None,
12706 ));
12707 }
12708 }
12709 }
12710 Ok(ContextPackRepositoryObservation { head, git, files })
12711 }
12712
12713 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12714 let referenced_by_binding = self
12715 .session_bindings
12716 .lock()
12717 .unwrap_or_else(|poisoned| poisoned.into_inner())
12718 .values()
12719 .any(|binding| {
12720 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12721 });
12722 let referenced_by_record = self
12723 .session_records
12724 .lock()
12725 .unwrap_or_else(|poisoned| poisoned.into_inner())
12726 .records
12727 .values()
12728 .any(|record| record.context_id.as_ref() == Some(context_id));
12729 let referenced_by_materialization = self
12730 .materializations
12731 .lock()
12732 .unwrap_or_else(|poisoned| poisoned.into_inner())
12733 .values()
12734 .any(|ownership| {
12735 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12736 });
12737 if referenced_by_binding
12738 || referenced_by_record
12739 || referenced_by_materialization
12740 {
12741 return Err(failure(
12742 NodeFailureCode::ContextPackBusy,
12743 "context pack is still referenced",
12744 ));
12745 }
12746 let mut catalog = self.context_catalog
12747 .write()
12748 .unwrap_or_else(|poisoned| poisoned.into_inner());
12749 if catalog.get(context_id).is_none() {
12750 return Err(failure(
12751 NodeFailureCode::UnknownContextPack,
12752 "context pack is unavailable",
12753 ));
12754 }
12755 self.spawn_idempotency
12756 .lock()
12757 .unwrap_or_else(|poisoned| poisoned.into_inner())
12758 .entries
12759 .retain(|_, entry| !entry.value.references_context(context_id));
12760 let removed = catalog.remove(context_id);
12761 debug_assert!(removed.is_some(), "validated context pack remains present");
12762 Ok(())
12763 }
12764
12765 async fn dispatch_input_bounded(
12766 &self,
12767 address: &SessionAddress,
12768 action: InputAction,
12769 ) -> Result<(), NodeFailure> {
12770 let started_after = self.current_sequence();
12771 self.pty_input_marks
12772 .lock()
12773 .unwrap_or_else(|poisoned| poisoned.into_inner())
12774 .insert(address.session.instance_id, (Instant::now(), false));
12775 self.pty_input_wake.notify_one();
12776 let command_id = self
12777 .dispatch_bounded(
12778 ControlCommand::SendInput {
12779 instance_id: address.session.instance_id,
12780 action,
12781 },
12782 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12783 )
12784 .await?;
12785 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12789 let mut scan_after = started_after;
12790 let mut accepted_after = None;
12791 loop {
12792 let events = self
12793 .history
12794 .lock()
12795 .unwrap_or_else(|poisoned| poisoned.into_inner())
12796 .events
12797 .iter()
12798 .filter(|envelope| envelope.sequence > scan_after)
12799 .cloned()
12800 .collect::<Vec<_>>();
12801 for envelope in events {
12802 scan_after = scan_after.max(envelope.sequence);
12803 let NodeEvent::Control {
12804 address: event_address,
12805 event,
12806 } = envelope.event
12807 else {
12808 continue;
12809 };
12810 if event_address != *address {
12811 continue;
12812 }
12813 if event.command_id == Some(command_id) {
12814 match &event.event {
12815 ControlEventKind::CommandRejected { message } => {
12816 return Err(failure(NodeFailureCode::InvalidRequest, message));
12817 }
12818 ControlEventKind::InputRequested { .. } => {
12819 accepted_after = Some(envelope.sequence);
12820 continue;
12821 }
12822 _ => {}
12823 }
12824 }
12825 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12826 match &event.event {
12827 ControlEventKind::InputCompleted { .. } => return Ok(()),
12828 ControlEventKind::InputFailed { message, .. } => {
12829 return Err(failure(
12830 NodeFailureCode::BackendOperationFailed,
12831 message,
12832 ));
12833 }
12834 _ => {}
12835 }
12836 }
12837 }
12838 if Instant::now() >= deadline {
12839 return Err(failure(
12840 NodeFailureCode::BackendBusy,
12841 if accepted_after.is_some() {
12842 "input was accepted but did not settle before the bounded deadline"
12843 } else {
12844 "input was not accepted before the bounded deadline"
12845 },
12846 ));
12847 }
12848 sleep(Duration::from_millis(2)).await;
12849 }
12850 }
12851
12852 async fn dispatch_resize_bounded(
12853 &self,
12854 address: &SessionAddress,
12855 size: gate4agent_types::TerminalSize,
12856 ) -> Result<(), NodeFailure> {
12857 let started_after = self.current_sequence();
12858 let command_id = self
12859 .dispatch_bounded(
12860 ControlCommand::Resize {
12861 instance_id: address.session.instance_id,
12862 size,
12863 },
12864 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12865 )
12866 .await?;
12867 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12868 let mut scan_after = started_after;
12869 let mut accepted_after = None;
12870 loop {
12871 let events = self
12872 .history
12873 .lock()
12874 .unwrap_or_else(|poisoned| poisoned.into_inner())
12875 .events
12876 .iter()
12877 .filter(|envelope| envelope.sequence > scan_after)
12878 .cloned()
12879 .collect::<Vec<_>>();
12880 for envelope in events {
12881 scan_after = scan_after.max(envelope.sequence);
12882 let NodeEvent::Control {
12883 address: event_address,
12884 event,
12885 } = envelope.event
12886 else {
12887 continue;
12888 };
12889 if event_address != *address {
12890 continue;
12891 }
12892 if event.command_id == Some(command_id) {
12893 match &event.event {
12894 ControlEventKind::CommandRejected { message } => {
12895 return Err(failure(NodeFailureCode::InvalidRequest, message));
12896 }
12897 ControlEventKind::ResizeRequested {
12898 size: requested_size,
12899 ..
12900 } if *requested_size == size => {
12901 accepted_after = Some(envelope.sequence);
12902 continue;
12903 }
12904 _ => {}
12905 }
12906 }
12907 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12908 match &event.event {
12909 ControlEventKind::Resized { size: settled_size }
12910 if *settled_size == size =>
12911 {
12912 return Ok(());
12913 }
12914 ControlEventKind::ResizeFailed { message } => {
12915 return Err(failure(
12916 NodeFailureCode::BackendOperationFailed,
12917 message,
12918 ));
12919 }
12920 _ => {}
12921 }
12922 }
12923 }
12924 if Instant::now() >= deadline {
12925 return Err(failure(
12926 NodeFailureCode::BackendBusy,
12927 if accepted_after.is_some() {
12928 "resize was accepted but did not settle before the bounded deadline"
12929 } else {
12930 "resize was not accepted before the bounded deadline"
12931 },
12932 ));
12933 }
12934 sleep(Duration::from_millis(2)).await;
12935 }
12936 }
12937
12938 async fn spawn_session(
12939 &self,
12940 workspace_id: WorkspaceId,
12941 provider: AgentId,
12942 mode: SessionMode,
12943 terminal_size: gate4agent_types::TerminalSize,
12944 initial_prompt: Option<String>,
12945 ) -> Result<SessionAddress, NodeFailure> {
12946 if self.managed_worktrees.lock()
12947 .unwrap_or_else(|poisoned| poisoned.into_inner())
12948 .lease_for_workspace(&workspace_id)
12949 .is_some()
12950 {
12951 return Err(failure(
12952 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12953 "managed worktree targets may only be spawned through their lease request",
12954 ));
12955 }
12956 self
12957 .spawn_session_with_deadline(
12958 workspace_id,
12959 provider.clone(),
12960 mode,
12961 terminal_size,
12962 initial_prompt,
12963 None,
12964 None,
12965 None,
12966 None,
12967 None,
12968 SpawnRecordPolicy::ProviderIdentityOnly,
12969 None,
12970 &[],
12971 None,
12972 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
12979 None,
12980 None,
12981 )
12982 .await
12983 .map(|(session, _runtime_policy)| session)
12984 }
12985
12986 async fn spawn_session_with_deadline(
12987 &self,
12988 workspace_id: WorkspaceId,
12989 provider: AgentId,
12990 mode: SessionMode,
12991 terminal_size: gate4agent_types::TerminalSize,
12992 initial_prompt: Option<String>,
12993 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12994 bundle: Option<ResolvedBundleReceipt>,
12995 context: Option<ResolvedContextPackReceipt>,
12996 managed_authority: Option<ManagedWorktreeLeaseId>,
12997 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12998 record_policy: SpawnRecordPolicy,
12999 deadline: Option<Instant>,
13000 required_capabilities: &[ProviderRuntimeCapability],
13001 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13002 approval_level: ApprovalLevel,
13003 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13004 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13010 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13011 let reserved_lease = self.managed_worktrees.lock()
13012 .unwrap_or_else(|poisoned| poisoned.into_inner())
13013 .lease_for_workspace(&workspace_id);
13014 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13015 (Some(expected), Some(provided)) if expected == provided => {}
13016 (None, None) => {}
13017 _ => {
13018 return Err(failure(
13019 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13020 "spawn lacks exact managed worktree lease authority",
13021 ));
13022 }
13023 }
13024 self.ensure_workspace_environment_profile_compatible(
13028 &workspace_id,
13029 environment_profile.as_ref(),
13030 )?;
13031 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13032 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13033 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13034 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13035 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13036 };
13037 let transport = match mode {
13043 SessionMode::Pty => TransportKind::Pty,
13044 SessionMode::Inline => TransportKind::Pipe,
13045 SessionMode::Acp => TransportKind::Acp,
13046 };
13047 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13048 let acp_transport = self.provider_supports_acp_transport(&provider);
13049 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13050 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13051 NodeFailureCode::UnsupportedSpawnCapability,
13052 "pre-admitted provider runtime no longer satisfies spawn requirements",
13053 ))?;
13054 runtime_policy
13055 } else if let Some(deadline) = deadline {
13056 let remaining = spawn_deadline_remaining(deadline)?;
13057 timeout(
13058 remaining,
13059 self.admit_provider_runtime(&provider, runtime_requirement),
13060 )
13061 .await
13062 .map_err(|_| {
13063 failure(
13064 NodeFailureCode::SpawnDeadlineExceeded,
13065 "provider admission exceeded the spawn deadline",
13066 )
13067 })??
13068 } else {
13069 self.admit_provider_runtime(&provider, runtime_requirement).await?
13070 };
13071 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13072 let runtime_policy =
13084 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13085 if required_capabilities
13086 .iter()
13087 .any(|capability| !runtime_policy.admits(*capability))
13088 {
13089 return Err(failure(
13090 NodeFailureCode::UnsupportedSpawnCapability,
13091 "provider runtime does not admit a required spawn capability",
13092 ));
13093 }
13094 if let Some(deadline) = deadline {
13095 spawn_deadline_remaining(deadline)?;
13096 }
13097 self.ensure_binding_capacity()?;
13098 let mut working_directory = self.workspace_root(&workspace_id)?;
13099 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13100 let session = SessionKey {
13101 instance_id,
13102 generation: SessionGeneration(1),
13103 };
13104 let address = SessionAddress {
13105 workspace_id: workspace_id.clone(),
13106 session,
13107 };
13108 tracing::info!(
13116 node_id = %self.node_id,
13117 workspace_id = %workspace_id,
13118 provider = %provider,
13119 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13120 session = ?address.session,
13121 "session spawn request accepted",
13122 );
13123 let mut prepared_spawn = SpawnPreparer::prepare(
13124 self,
13125 SpawnPrepareRequest {
13126 instance_id,
13127 address: &address,
13128 provider: &provider,
13129 mode,
13130 environment_profile: environment_profile.as_ref(),
13131 bundle: bundle.as_ref(),
13132 context: context.as_ref(),
13133 managed_authority: managed_authority.as_ref(),
13134 harness_mcp,
13135 approval_level,
13136 network_allowlist: network_allowlist.as_ref(),
13137 browser_profile_id,
13138 },
13139 )?;
13140 let materialization_id = prepared_spawn.materialization_id.take();
13141 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13142 let record_id = self.bind_spawn_session_with_materialization(
13143 &address,
13144 provider.clone(),
13145 mode,
13146 runtime_policy,
13147 record_policy,
13148 environment_profile,
13149 bundle,
13150 context,
13151 materialization_id,
13152 )?;
13153 prepared_spawn.retain_session_guards();
13154 let session_provider = provider.clone();
13158 let agent_id = provider;
13159 let register_command_id = match self.dispatch_bounded(
13160 ControlCommand::Register {
13161 instance_id,
13162 agent_id,
13163 transport,
13164 },
13165 dispatch_timeout,
13166 )
13167 .await {
13168 Ok(command_id) => command_id,
13169 Err(error) => {
13170 if let Some(binding) = self.remove_binding(&address) {
13171 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13172 }
13173 if let Some(record_id) = record_id.as_ref() {
13174 self.discard_record(record_id)?;
13175 }
13176 return Err(spawn_dispatch_error(error, deadline));
13177 }
13178 };
13179 let start_timeout = match spawn_dispatch_timeout(deadline) {
13180 Ok(timeout) => timeout,
13181 Err(error) => {
13182 let recovery = self
13183 .rollback_spawn(
13184 &address,
13185 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13186 )
13187 .await;
13188 return match recovery {
13189 Ok(()) => {
13190 if let Some(record_id) = record_id.as_ref() {
13191 self.discard_record(record_id)?;
13192 }
13193 Err(error)
13194 }
13195 Err(recovery_error) => {
13196 if let Some(record_id) = record_id.as_ref() {
13197 self.mark_record_error(
13198 record_id,
13199 "spawn deadline elapsed and registration recovery failed",
13200 )?;
13201 }
13202 Err(recovery_error)
13203 }
13204 };
13205 }
13206 };
13207 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13208 if let Err(recovery_error) = self
13209 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13210 .await
13211 {
13212 return Err(recovery_error);
13213 }
13214 if let Some(record_id) = record_id.as_ref() {
13215 self.discard_record(record_id)?;
13216 }
13217 return Err(start_error);
13218 }
13219 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13221 working_directory = dir.to_string_lossy().into_owned();
13222 }
13223 let start_result = self
13224 .dispatch_bounded(
13225 ControlCommand::Start {
13226 instance_id,
13227 runtime_policy,
13228 request: StartRequest {
13229 working_directory,
13230 terminal_size,
13231 initial_prompt,
13232 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13233 approval_level,
13234 },
13235 },
13236 start_timeout,
13237 )
13238 .await;
13239 let start_command_id = match start_result {
13240 Ok(command_id) => command_id,
13241 Err(start_error) => {
13242 let start_error = spawn_dispatch_error(start_error, deadline);
13243 let recovery = self
13244 .rollback_spawn(
13245 &address,
13246 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13247 )
13248 .await;
13249 return match recovery {
13250 Ok(()) => {
13251 if let Some(record_id) = record_id.as_ref() {
13252 self.discard_record(record_id)?;
13253 }
13254 Err(start_error)
13255 }
13256 Err(recovery_error) => {
13257 let diagnostic = format!(
13258 "start failed and registration recovery failed: {}",
13259 recovery_error.message,
13260 );
13261 if let Some(record_id) = record_id.as_ref() {
13262 self.mark_record_error(record_id, &diagnostic)?;
13263 }
13264 Err(failure(recovery_error.code, &diagnostic))
13265 }
13266 };
13267 }
13268 };
13269
13270 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13271 loop {
13272 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13281 .or_else(|| self.take_rejected_command_reason(start_command_id))
13282 {
13283 self.rollback_spawn(
13284 &address,
13285 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13286 ).await.map_err(|error| {
13287 failure(
13288 error.code,
13289 &format!(
13290 "spawn was rejected ({}) and registration recovery failed: {}",
13291 reason.message, error.message
13292 ),
13293 )
13294 })?;
13295 if let Some(record_id) = record_id.as_ref() {
13296 self.discard_record(record_id)?;
13297 }
13298 let code = if reason.unsupported_transport {
13299 NodeFailureCode::UnsupportedTransport
13300 } else {
13301 NodeFailureCode::BackendOperationFailed
13302 };
13303 return Err(failure(code, &reason.message));
13304 }
13305 if let Some(current) = self
13306 .handle
13307 .snapshot()
13308 .sessions
13309 .iter()
13310 .find(|current| current.instance_id == instance_id)
13311 {
13312 if current.generation != session.generation {
13313 let divergence = failure(
13314 NodeFailureCode::StaleGeneration,
13315 "spawn generation diverged",
13316 );
13317 return match self
13318 .rollback_spawn(
13319 &address,
13320 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13321 )
13322 .await
13323 {
13324 Ok(()) => {
13325 if let Some(record_id) = record_id.as_ref() {
13326 self.discard_record(record_id)?;
13327 }
13328 Err(divergence)
13329 }
13330 Err(recovery_error) => {
13331 if let Some(record_id) = record_id.as_ref() {
13332 self.mark_record_error(
13333 record_id,
13334 "spawn generation diverged and recovery failed",
13335 )?;
13336 }
13337 Err(recovery_error)
13338 }
13339 };
13340 }
13341 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13342 prepared_spawn.retain_launch_guards();
13343 return Ok((address, runtime_policy));
13344 }
13345 }
13346 if Instant::now() >= commit_deadline {
13347 self.rollback_spawn(
13348 &address,
13349 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13350 ).await.map_err(|error| {
13351 failure(
13352 error.code,
13353 &format!(
13354 "start did not commit and registration recovery failed: {}",
13355 error.message
13356 ),
13357 )
13358 })?;
13359 if let Some(record_id) = record_id.as_ref() {
13360 self.discard_record(record_id)?;
13361 }
13362 return Err(failure(
13363 if deadline.is_some() {
13364 NodeFailureCode::SpawnDeadlineExceeded
13365 } else {
13366 NodeFailureCode::BackendBusy
13367 },
13368 "start did not commit before the bounded deadline; registration was removed",
13369 ));
13370 }
13371 sleep(Duration::from_millis(2)).await;
13372 }
13373 }
13374
13375 fn effective_spawn_runtime_policy(
13376 &self,
13377 runtime_policy: ProviderRuntimePolicy,
13378 ) -> ProviderRuntimePolicy {
13379 #[cfg(feature = "fixture")]
13380 let runtime_policy = if self.fixture_semantic_hook_policy {
13381 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13382 .expect("monitoring Hook fixture policy is internally valid")
13383 } else {
13384 runtime_policy
13385 };
13386 runtime_policy
13387 }
13388
13389 fn verify_harness_mcp_before_start(
13390 &self,
13391 prepared: Option<&PreparedHarnessMcpSpawn>,
13392 ) -> Result<(), NodeFailure> {
13393 prepared
13394 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13395 .transpose()
13396 .map(|_| ())
13397 .map_err(harness_mcp_failure)
13398 }
13399
13400 async fn rollback_spawn(
13401 &self,
13402 address: &SessionAddress,
13403 commit_timeout: Duration,
13404 ) -> Result<(), NodeFailure> {
13405 self.dispatch_bounded(
13406 ControlCommand::Remove {
13407 instance_id: address.session.instance_id,
13408 },
13409 commit_timeout,
13410 )
13411 .await?;
13412 self.wait_until_removed(address, commit_timeout).await?;
13413 if let Some(binding) = self.remove_binding(address) {
13414 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13415 }
13416 Ok(())
13417 }
13418
13419 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13420 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13421 self.dispatch_bounded(
13422 ControlCommand::Remove {
13423 instance_id: address.session.instance_id,
13424 },
13425 commit_timeout,
13426 )
13427 .await?;
13428 self.wait_until_removed(address, commit_timeout).await?;
13429 let removed = self.remove_binding(address);
13430 if let Some(binding) = removed.as_ref() {
13431 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13432 }
13433 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13434 let snapshot = self.managed_worktrees.lock()
13435 .unwrap_or_else(|poisoned| poisoned.into_inner())
13436 .release_session(
13437 &lease_id,
13438 self.incarnation_id,
13439 address.session.instance_id,
13440 address.session.generation,
13441 unix_time_ms(),
13442 );
13443 if self.persist_state().is_err() {
13444 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13445 }
13446 if let Some(snapshot) = snapshot {
13447 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13448 }
13449 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13450 }
13451 Ok(())
13452 }
13453
13454 async fn wait_until_removed(
13500 &self,
13501 address: &SessionAddress,
13502 commit_timeout: Duration,
13503 ) -> Result<(), NodeFailure> {
13504 let deadline = Instant::now() + commit_timeout;
13505 let mut next_dispatch = Instant::now();
13506 loop {
13507 let snapshot = self.handle.snapshot();
13508 let status = snapshot
13509 .sessions
13510 .iter()
13511 .find(|session| session.instance_id == address.session.instance_id)
13512 .map(|session| session.status.clone());
13513 let Some(status) = status else {
13514 return Ok(());
13515 };
13516 let now = Instant::now();
13517 if now >= deadline {
13518 return Err(failure(
13519 NodeFailureCode::BackendBusy,
13520 "remove did not commit before the bounded deadline; session binding was retained",
13521 ));
13522 }
13523 if status.allows_remove() && now >= next_dispatch {
13524 let _ = self.dispatch(ControlCommand::Remove {
13525 instance_id: address.session.instance_id,
13526 });
13527 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13528 }
13529 sleep(Duration::from_millis(2)).await;
13530 }
13531 }
13532
13533 fn resync(&self, after_sequence: u64) -> NodeResponse {
13534 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13535 let event_sequence = history.last_sequence;
13536 let oldest_available_sequence = history.replay_floor_sequence;
13537 let events = history
13538 .events
13539 .iter()
13540 .filter(|event| event.sequence > after_sequence)
13541 .cloned()
13542 .collect();
13543 drop(history);
13544 NodeResponse::Resync {
13545 event_sequence,
13546 oldest_available_sequence,
13547 snapshot: self.snapshot(),
13548 events,
13549 }
13550 }
13551}
13552
13553fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13558 match kind {
13559 AgentStreamChunkKindV1::Text { .. } => "text",
13560 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13561 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13562 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13563 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13564 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13565 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13566 }
13567}
13568
13569async fn accept_connections(
13583 endpoint: &str,
13584 call_home: Option<std::net::SocketAddr>,
13585 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13586 shared: Arc<NodeShared>,
13587) -> Result<(), NodeServerError> {
13588 let mut connections = JoinSet::new();
13589 let result = match call_home {
13590 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13591 Some(relay) => {
13592 let listener =
13593 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13594 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13595 tokio::pin!(listener);
13596 tokio::pin!(dialler);
13597 tokio::select! {
13598 result = &mut listener => result,
13599 result = &mut dialler => result,
13600 }
13601 }
13602 };
13603 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13604 while !connections.is_empty() {
13605 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13606 if remaining.is_zero()
13607 || timeout(remaining, connections.join_next()).await.is_err()
13608 {
13609 break;
13610 }
13611 }
13612 connections.abort_all();
13613 while connections.join_next().await.is_some() {}
13614 result
13615}
13616
13617const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13621
13622async fn call_home_loop(
13636 relay: std::net::SocketAddr,
13637 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13638 shared: Arc<NodeShared>,
13639) -> Result<(), NodeServerError> {
13640 let mut failures = 0_usize;
13641 loop {
13642 if shared.shutdown.load(Ordering::Acquire) {
13643 return Ok(());
13644 }
13645 let permit = tokio::select! {
13646 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13647 permit.map_err(|_| NodeServerError::PreauthClosed)?
13648 }
13649 _ = shared.shutdown_notify.notified() => return Ok(()),
13650 };
13651 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13652 Ok(()) => {
13653 failures = 0;
13657 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13658 }
13659 Err(error) => {
13660 failures = failures.saturating_add(1);
13661 tracing::warn!(
13662 node_id = %shared.node_id,
13663 %relay,
13664 cause = %error,
13665 attempt = failures,
13666 "call-home connection failed",
13667 );
13668 let backoff = call_home_backoff(failures);
13669 tokio::select! {
13670 _ = sleep(backoff) => {}
13671 _ = shared.shutdown_notify.notified() => return Ok(()),
13672 }
13673 }
13674 }
13675 }
13676}
13677
13678const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13680const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13682
13683fn call_home_keepalive() -> socket2::TcpKeepalive {
13684 let keepalive = socket2::TcpKeepalive::new()
13685 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13686 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13687 #[cfg(not(windows))]
13688 let keepalive = keepalive.with_retries(3);
13689 keepalive
13690}
13691
13692fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13693 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13694}
13695
13696pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13698 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13699}
13700
13701fn call_home_backoff(attempt: usize) -> Duration {
13703 Duration::from_millis(
13704 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13705 )
13706}
13707
13708
13709async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13712 #[cfg(feature = "wireguard")]
13713 {
13714 let std_stream = tokio::task::spawn_blocking(move || {
13715 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13716 })
13717 .await
13718 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13719 .map_err(NodeServerError::CallHomeTransport)?;
13720 std_stream
13721 .set_nonblocking(true)
13722 .map_err(NodeServerError::CallHomeTransport)?;
13723 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13724 }
13725 #[cfg(not(feature = "wireguard"))]
13726 {
13727 tokio::net::TcpStream::connect(relay)
13728 .await
13729 .map_err(NodeServerError::CallHomeTransport)
13730 }
13731}
13732
13733async fn dial_relay_and_serve(
13735 relay: std::net::SocketAddr,
13736 identity: Option<&hatchery_enroll::NodeIdentity>,
13737 shared: Arc<NodeShared>,
13738 preauth_permit: OwnedSemaphorePermit,
13739) -> Result<(), NodeServerError> {
13740 let mut stream = dial_call_home(relay).await?;
13741 let _ = stream.set_nodelay(true);
13745 if let Err(error) = enable_call_home_keepalive(&stream) {
13751 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13752 }
13753 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13754 .await
13755 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13756 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13757 let mut session_token = None;
13758 if let Some(identity) = identity {
13759 let mut magic = [0_u8; 4];
13763 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13764 .await
13765 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13766 .map_err(NodeServerError::CallHomeTransport)?;
13767 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13768 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13769 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13770 .await
13771 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13772 .map_err(NodeServerError::CallHomeTransport)?;
13773 let (answer, token) = identity
13774 .answer_challenge(shared.node_id.as_str(), &frame)
13775 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13776 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13777 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13778 session_token = Some(token);
13779 }
13780 }
13781 serve_connection(stream, shared, session_token, preauth_permit).await
13782}
13783
13784async fn accept_connections_inner(
13785 endpoint: &str,
13786 shared: Arc<NodeShared>,
13787 connections: &mut JoinSet<Result<(), NodeServerError>>,
13788) -> Result<(), NodeServerError> {
13789 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13790 loop {
13791 while connections.try_join_next().is_some() {}
13792 if shared.shutdown.load(Ordering::Acquire) {
13793 return Ok(());
13794 }
13795 let preauth_permit = tokio::select! {
13796 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13797 permit.map_err(|_| NodeServerError::PreauthClosed)?
13798 }
13799 _ = shared.shutdown_notify.notified() => return Ok(()),
13800 };
13801 let server = tokio::select! {
13802 result = listener.accept() => result?,
13803 _ = shared.shutdown_notify.notified() => return Ok(()),
13804 };
13805 let connection_shared = Arc::clone(&shared);
13806 connections.spawn(async move {
13807 serve_connection(server, connection_shared, None, preauth_permit).await
13808 });
13809 if shared.shutdown.load(Ordering::Acquire) {
13810 return Ok(());
13811 }
13812 }
13813}
13814
13815async fn serve_connection<S>(
13823 mut pipe: S,
13824 shared: Arc<NodeShared>,
13825 session_token: Option<String>,
13826 preauth_permit: OwnedSemaphorePermit,
13827) -> Result<(), NodeServerError>
13828where
13829 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13830{
13831 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13833 let hello_frame = timeout(
13834 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13835 read_json_frame_limited_body_timeout(
13836 &mut pipe,
13837 MAX_NODE_HELLO_FRAME_BYTES,
13838 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13839 ),
13840 )
13841 .await
13842 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13843 let ClientFrame::Hello(hello) = hello_frame else {
13844 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13845 };
13846 if hello.build_stamp != BUILD_STAMP {
13847 return Err(NodeServerError::Handshake(format!(
13848 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13849 hello.build_stamp,
13850 )));
13851 }
13852 let compatibility = match hello.compatibility.as_ref() {
13853 Some(offer) => {
13854 let mut selected = node_compatibility_support(&shared)?
13855 .negotiate(offer)
13856 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13857 project_negotiated_provider_ids(&mut selected);
13858 Some(selected)
13859 }
13860 None => None,
13861 };
13862 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13863 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13864 (Some(offer), Some(selected)) => negotiated_auth_proof(
13865 auth_token,
13866 AuthDirection::Server,
13867 hello.role,
13868 &hello.client_nonce,
13869 &server_nonce,
13870 offer,
13871 selected,
13872 ),
13873 (None, None) => auth_proof(
13874 auth_token,
13875 AuthDirection::Server,
13876 hello.role,
13877 &hello.client_nonce,
13878 &server_nonce,
13879 ),
13880 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13881 }
13882 .map_err(NodeServerError::Authentication)?;
13883 write_json_frame_limited(
13884 &mut pipe,
13885 &ServerFrame::Challenge(ServerChallenge {
13886 build_stamp: BUILD_STAMP.to_owned(),
13887 server_nonce,
13888 server_proof,
13889 compatibility: compatibility.clone(),
13890 }),
13891 MAX_NODE_HELLO_FRAME_BYTES,
13892 )
13893 .await?;
13894 let authentication = timeout(
13895 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13896 read_json_frame_limited_body_timeout(
13897 &mut pipe,
13898 MAX_NODE_HELLO_FRAME_BYTES,
13899 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13900 ),
13901 )
13902 .await
13903 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13904 let ClientFrame::Authenticate(authentication) = authentication else {
13905 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13906 };
13907 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13908 (Some(offer), Some(selected)) => negotiated_auth_proof(
13909 auth_token,
13910 AuthDirection::Client,
13911 hello.role,
13912 &hello.client_nonce,
13913 &server_nonce,
13914 offer,
13915 selected,
13916 ),
13917 (None, None) => auth_proof(
13918 auth_token,
13919 AuthDirection::Client,
13920 hello.role,
13921 &hello.client_nonce,
13922 &server_nonce,
13923 ),
13924 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13925 }
13926 .map_err(NodeServerError::Authentication)?;
13927 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13928 return Err(NodeServerError::Handshake("access denied".to_owned()));
13929 }
13930 let selected_capabilities = compatibility
13931 .as_ref()
13932 .map(|selected| selected.capabilities.clone())
13933 .unwrap_or_default();
13934 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13935 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13936 });
13937 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13938 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13939 });
13940 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13941 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13942 });
13943 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13944 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13945 });
13946 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13947 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13948 });
13949 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13950 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13951 }) && selected_capabilities.iter().any(|capability| {
13952 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13953 });
13954 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13955 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13956 });
13957 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13958 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13959 });
13960 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13961 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13962 });
13963 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13964 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13965 });
13966 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13967 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13968 });
13969 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13970 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13971 });
13972 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13973 .try_acquire_owned()
13974 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13975 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13976 let _release_guard = ControllerReleaseGuard {
13977 shared: Arc::clone(&shared),
13978 connection_id,
13979 };
13980 drop(preauth_permit);
13981 let _authenticated_permit = authenticated_permit;
13982 let mut event_rx = shared.event_tx.subscribe();
13983 let mut terminal_event_rx = terminal_event_subscription(
13984 &shared,
13985 include_terminal_frame_events,
13986 );
13987 let mut agent_stream_event_rx = agent_stream_event_subscription(
13988 &shared,
13989 include_agent_stream_events,
13990 );
13991 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13992 &shared,
13993 include_harness_mcp_proxy,
13994 );
13995 write_json_frame(
13996 &mut pipe,
13997 &ServerFrame::Hello(NodeHello {
13998 build_stamp: BUILD_STAMP.to_owned(),
13999 incarnation_id: shared.incarnation_id,
14000 connection_id,
14001 role: hello.role,
14002 event_sequence: shared.current_sequence(),
14003 controller: shared.controller_state(),
14004 snapshot: snapshot_for_wire(
14005 &shared,
14006 include_provider_runtime_status,
14007 include_open_provider_ids,
14008 include_spawn_profiles,
14009 include_managed_worktrees,
14010 include_child_environment_profiles,
14011 include_session_bundles,
14012 include_history_context_packs,
14013 include_agent_progress,
14014 include_session_task_correlation,
14015 ),
14016 compatibility,
14017 }),
14018 )
14019 .await?;
14020 let (mut reader, mut writer) = tokio::io::split(pipe);
14021 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14022 let reader_task = tokio::spawn(async move {
14023 loop {
14024 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14025 &mut reader,
14026 MAX_NODE_CLIENT_FRAME_BYTES,
14027 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14028 )
14029 .await;
14030 let terminal = frame.is_err();
14031 if frame_tx.send(frame).await.is_err() || terminal {
14032 break;
14033 }
14034 }
14035 });
14036 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14037 let mut pending_events = Vec::new();
14038 let mut resync_required = false;
14039 let mut discard_events_through = 0_u64;
14040 loop {
14041 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14046 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14047 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14048 for _ in 0..durable_drain_budget {
14049 match event_rx.try_recv() {
14050 Ok(event) => queue_connection_event(
14051 &mut pending_events,
14052 event,
14053 discard_events_through,
14054 ),
14055 Err(broadcast::error::TryRecvError::Empty) => break,
14056 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14057 resync_required = true;
14058 break;
14059 }
14060 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14061 }
14062 }
14063 if let Some(receiver) = terminal_event_rx.as_mut() {
14064 match receiver.try_recv() {
14065 Ok(events) => queue_connection_event_batch(
14066 &mut pending_events,
14067 &events,
14068 discard_events_through,
14069 ),
14070 Err(broadcast::error::TryRecvError::Empty) => {}
14071 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14072 resync_required = true;
14073 }
14074 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14075 }
14076 }
14077 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14078 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14088 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14089 for _ in 0..agent_stream_drain_budget {
14090 match receiver.try_recv() {
14091 Ok(events) => queue_connection_event_batch(
14092 &mut pending_events,
14093 &events,
14094 discard_events_through,
14095 ),
14096 Err(broadcast::error::TryRecvError::Empty) => break,
14097 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14098 tracing::warn!(
14099 connection_id,
14100 dropped,
14101 "agent stream event subscription lagged; forcing a resync",
14102 );
14103 resync_required = true;
14104 break;
14105 }
14106 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14107 }
14108 }
14109 }
14110 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14111 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14117 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14118 for _ in 0..harness_mcp_proxy_drain_budget {
14119 match receiver.try_recv() {
14120 Ok(events) => queue_connection_event_batch(
14121 &mut pending_events,
14122 &events,
14123 discard_events_through,
14124 ),
14125 Err(broadcast::error::TryRecvError::Empty) => break,
14126 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14127 tracing::warn!(
14128 connection_id,
14129 dropped,
14130 "harness MCP proxy event subscription lagged; forcing a resync",
14131 );
14132 resync_required = true;
14133 break;
14134 }
14135 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14136 }
14137 }
14138 }
14139 if resync_required {
14140 let event = resync_required_event(&shared);
14141 apply_connection_resync_watermark(
14142 &mut pending_events,
14143 &mut discard_events_through,
14144 event.sequence,
14145 );
14146 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14147 resync_required = false;
14148 continue;
14149 }
14150 if !pending_events.is_empty() {
14151 pending_events.sort_unstable_by_key(|event| event.sequence);
14152 let burst_len = connection_event_burst_len(pending_events.len());
14153 shared
14154 .connection_events_sent
14155 .fetch_add(burst_len as u64, Ordering::Relaxed);
14156 for event in pending_events.drain(..burst_len) {
14157 let event = if include_managed_worktrees {
14158 Some(event)
14159 } else {
14160 project_event_without_managed_worktrees(event)
14161 };
14162 let event = event.and_then(|event| if include_open_provider_ids {
14163 Some(event)
14164 } else {
14165 project_event_legacy_provider_ids(&shared, event)
14166 });
14167 let event = event.map(|event| if include_child_environment_profiles {
14168 event
14169 } else {
14170 project_event_without_child_environment_profile(event)
14171 });
14172 let event = event.map(|event| if include_session_bundles {
14173 event
14174 } else {
14175 project_event_without_session_bundle(event)
14176 });
14177 let event = event.map(|event| {
14178 project_event_history_for_wire(event, include_history_context_packs)
14179 });
14180 let event = event.map(|event| if include_history_context_packs {
14181 event
14182 } else {
14183 project_event_without_context_pack(event)
14184 });
14185 let event = event.map(|event| if include_session_task_correlation {
14186 event
14187 } else {
14188 project_event_without_session_task_binding(event)
14189 });
14190 let exact_harness_controller = hello.role == ClientRole::Operator
14191 && shared.controller_state().is_some_and(|controller| {
14192 controller.connection_id == connection_id
14193 });
14194 let event = event.and_then(|event| {
14195 if include_harness_mcp_proxy && exact_harness_controller {
14196 Some(event)
14197 } else {
14198 project_event_without_harness_mcp_proxy(event)
14199 }
14200 });
14201 if let Some(event) = event {
14202 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14203 }
14204 }
14205 }
14206 tokio::select! {
14207 biased;
14208 frame = frame_rx.recv() => {
14209 let frame = match frame {
14210 Some(frame) => frame,
14211 None => break,
14212 };
14213 let frame = match frame {
14214 Ok(frame) => frame,
14215 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14216 Err(error) => return Err(error.into()),
14217 };
14218 let ClientFrame::Request(request) = frame else {
14219 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14220 };
14221 #[cfg(feature = "fixture")]
14222 let probe_spawn_managed_worktree_v2 = matches!(
14223 &request.request,
14224 NodeRequest::SpawnManagedWorktreeV2 { .. }
14225 );
14226 let requires_open_provider_ids =
14227 request_requires_open_provider_ids(&shared, &request.request);
14228 let requires_child_environment_profile =
14229 request_requires_child_environment_profile(&shared, &request.request);
14230 let requires_session_bundle =
14231 request_requires_session_bundle(&shared, &request.request);
14232 let requires_history_context_pack =
14233 request_requires_history_context_pack(&shared, &request.request);
14234 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14235 ResponseEnvelope {
14236 request_id: request.request_id,
14237 result: Err(failure(
14238 NodeFailureCode::UnsupportedCapability,
14239 "open provider IDs were not negotiated",
14240 )),
14241 }
14242 } else if requires_child_environment_profile
14243 && !include_child_environment_profiles
14244 {
14245 ResponseEnvelope {
14246 request_id: request.request_id,
14247 result: Err(failure(
14248 NodeFailureCode::UnsupportedCapability,
14249 "child environment profiles were not negotiated",
14250 )),
14251 }
14252 } else if requires_session_bundle && !include_session_bundles {
14253 ResponseEnvelope {
14254 request_id: request.request_id,
14255 result: Err(failure(
14256 NodeFailureCode::UnsupportedCapability,
14257 "session bundles were not negotiated",
14258 )),
14259 }
14260 } else if requires_history_context_pack && !include_history_context_packs {
14261 ResponseEnvelope {
14262 request_id: request.request_id,
14263 result: Err(failure(
14264 NodeFailureCode::UnsupportedCapability,
14265 "history context packs were not negotiated",
14266 )),
14267 }
14268 } else if request_uses_unnegotiated_capability(
14269 &request.request,
14270 &selected_capabilities,
14271 )
14272 {
14273 ResponseEnvelope {
14274 request_id: request.request_id,
14275 result: Err(failure(
14276 NodeFailureCode::UnsupportedCapability,
14277 "request capability was not negotiated",
14278 )),
14279 }
14280 } else {
14281 process_request(&shared, connection_id, hello.role, request).await
14282 };
14283 #[cfg(feature = "fixture")]
14284 if probe_spawn_managed_worktree_v2 {
14285 shared
14286 .fixture_spawn_managed_worktree_v2_failure_probe
14287 .record(reply.result.as_ref().err().map(|failure| failure.code));
14288 }
14289 if !include_provider_runtime_status {
14290 clear_response_provider_runtime_status(&mut reply);
14291 }
14292 if !include_managed_worktrees {
14293 project_response_without_managed_worktrees(&mut reply);
14294 }
14295 if !include_open_provider_ids {
14296 project_response_legacy_provider_ids(&shared, &mut reply);
14297 }
14298 if !include_child_environment_profiles {
14299 project_response_without_child_environment_profile(&mut reply);
14300 }
14301 if !include_session_bundles {
14302 project_response_without_session_bundle(&mut reply);
14303 }
14304 project_response_history_for_wire(
14305 &mut reply,
14306 include_history_context_packs,
14307 );
14308 if !include_history_context_packs {
14309 project_response_without_context_pack(&mut reply);
14310 }
14311 if !include_agent_progress {
14312 project_response_without_agent_progress(&mut reply);
14313 }
14314 if !include_session_task_correlation {
14315 project_response_without_session_task_binding(&mut reply);
14316 }
14317 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14318 }
14319 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14320 event = event_rx.recv() => {
14321 match event {
14322 Ok(event) => queue_connection_event(
14323 &mut pending_events,
14324 event,
14325 discard_events_through,
14326 ),
14327 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14328 Err(broadcast::error::RecvError::Closed) => break,
14329 }
14330 }
14331 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14332 match events {
14333 Ok(events) => queue_connection_event_batch(
14334 &mut pending_events,
14335 &events,
14336 discard_events_through,
14337 ),
14338 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14339 Err(broadcast::error::RecvError::Closed) => break,
14340 }
14341 }
14342 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14343 match events {
14344 Ok(events) => queue_connection_event_batch(
14345 &mut pending_events,
14346 &events,
14347 discard_events_through,
14348 ),
14349 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14350 Err(broadcast::error::RecvError::Closed) => break,
14351 }
14352 }
14353 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14354 match events {
14355 Ok(events) => queue_connection_event_batch(
14356 &mut pending_events,
14357 &events,
14358 discard_events_through,
14359 ),
14360 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14361 Err(broadcast::error::RecvError::Closed) => break,
14362 }
14363 }
14364 _ = shared.shutdown_notify.notified() => break,
14365 }
14366 }
14367 Ok(())
14368}
14369
14370async fn receive_terminal_event_batch(
14371 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14372) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14373 match receiver {
14374 Some(receiver) => receiver.recv().await,
14375 None => std::future::pending().await,
14376 }
14377}
14378
14379fn terminal_event_subscription(
14380 shared: &NodeShared,
14381 enabled: bool,
14382) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14383 enabled.then(|| shared.terminal_event_tx.subscribe())
14384}
14385
14386fn agent_stream_event_subscription(
14390 shared: &NodeShared,
14391 enabled: bool,
14392) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14393 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14394}
14395
14396fn harness_mcp_proxy_event_subscription(
14401 shared: &NodeShared,
14402 enabled: bool,
14403) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14404 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14405}
14406
14407fn queue_connection_event(
14408 pending: &mut Vec<NodeEventEnvelope>,
14409 event: NodeEventEnvelope,
14410 discard_through: u64,
14411) {
14412 if event.sequence <= discard_through
14413 && !event.event.requires_harness_mcp_proxy_capability()
14414 {
14415 return;
14416 }
14417 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14418 if let Some(index) = pending.iter().position(|current| {
14419 matches!(
14420 ¤t.event,
14421 NodeEvent::TerminalFrame {
14422 address: current_address,
14423 ..
14424 } if current_address == address
14425 )
14426 }) {
14427 pending.remove(index);
14428 }
14429 }
14430 pending.push(event);
14431}
14432
14433fn project_event_without_harness_mcp_proxy(
14434 envelope: NodeEventEnvelope,
14435) -> Option<NodeEventEnvelope> {
14436 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14437}
14438
14439fn queue_connection_event_batch(
14440 pending: &mut Vec<NodeEventEnvelope>,
14441 events: &[NodeEventEnvelope],
14442 discard_through: u64,
14443) {
14444 for event in events {
14445 queue_connection_event(pending, event.clone(), discard_through);
14446 }
14447}
14448
14449fn apply_connection_resync_watermark(
14450 pending: &mut Vec<NodeEventEnvelope>,
14451 discard_through: &mut u64,
14452 marker_sequence: u64,
14453) {
14454 *discard_through = (*discard_through).max(marker_sequence);
14455 pending.retain(|event| event.sequence > *discard_through);
14456}
14457
14458fn connection_event_burst_len(pending_len: usize) -> usize {
14459 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14460}
14461
14462fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14463 let history = shared
14464 .history
14465 .lock()
14466 .unwrap_or_else(|poisoned| poisoned.into_inner());
14467 let sequence = history.last_sequence;
14468 let oldest_available_sequence = history.replay_floor_sequence;
14469 NodeEventEnvelope {
14470 sequence,
14471 event: NodeEvent::ResyncRequired {
14472 oldest_available_sequence,
14473 },
14474 }
14475}
14476
14477fn node_compatibility_support(
14478 shared: &NodeShared,
14479) -> Result<NodeCompatibilitySupport, NodeServerError> {
14480 let mut support = node_compatibility_support_for_manifest(
14481 &shared.provider_contracts,
14482 &shared.provider_adapter_contracts,
14483 )?;
14484 if shared
14485 .delivery_store
14486 .lock()
14487 .unwrap_or_else(|poisoned| poisoned.into_inner())
14488 .is_some()
14489 {
14490 support.capabilities.push(
14491 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14492 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14493 );
14494 }
14495 if shared.harness_mcp_registry.is_some() {
14496 support.capabilities.push(
14497 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14498 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14499 );
14500 }
14501 Ok(support)
14502}
14503
14504fn node_compatibility_support_for_manifest(
14505 provider_contracts: &[ProviderContractSupport],
14506 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14507) -> Result<NodeCompatibilitySupport, NodeServerError> {
14508 Ok(NodeCompatibilitySupport {
14509 build_stamp: BUILD_STAMP.to_owned(),
14510 capabilities: baseline_capabilities()?,
14511 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14512 path_semantics: platform::path_semantics(),
14513 local_transport: platform::local_transport(),
14514 state_schema: StateSchemaSupport {
14515 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14516 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14517 },
14518 provider_contracts: provider_contracts.to_vec(),
14519 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14520 })
14521}
14522
14523fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14524 let capabilities = [
14525 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14526 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14527 NODE_REPOSITORY_PATH_CAPABILITY,
14528 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14529 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14530 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14531 NODE_GIT_READ_CAPABILITY,
14532 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14533 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14534 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14535 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14536 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14537 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14538 NODE_ACP_CONTROL_CAPABILITY,
14539 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14540 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14541 NODE_WORKTREE_SELECTION_CAPABILITY,
14542 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14543 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14544 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14545 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14546 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14547 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14548 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14549 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14550 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14551 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14552 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14553 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14554 ]
14555 .into_iter()
14556 .map(|capability| {
14557 CapabilityId::new(capability)
14558 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14559 })
14560 .collect::<Result<Vec<_>, _>>()?;
14561 #[cfg(windows)]
14562 let capabilities = {
14563 let mut capabilities = capabilities;
14564 capabilities.push(
14565 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14566 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14567 );
14568 capabilities
14569 };
14570 Ok(capabilities)
14571}
14572
14573fn request_uses_unnegotiated_capability(
14574 request: &NodeRequest,
14575 selected_capabilities: &[CapabilityId],
14576) -> bool {
14577 let selected = |required: &str| {
14578 selected_capabilities
14579 .iter()
14580 .any(|capability| capability.as_str() == required)
14581 };
14582 request
14583 .required_capability()
14584 .is_some_and(|required| !selected(required))
14585 || (request.requires_worktree_selection_capability()
14586 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14587 || (request.requires_spawn_spec_defaults_overrides_capability()
14588 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14589 || (request.requires_spawn_profile_revision_capability()
14590 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14591 || (request.requires_child_environment_profile_capability()
14592 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14593 || (request.requires_session_bundle_materialization_capability()
14594 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14595 || (request.requires_history_context_pack_capability()
14596 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14597}
14598
14599fn snapshot_for_wire(
14600 shared: &NodeShared,
14601 include_provider_runtime_status: bool,
14602 include_open_provider_ids: bool,
14603 include_spawn_profiles: bool,
14604 include_managed_worktrees: bool,
14605 include_child_environment_profiles: bool,
14606 include_session_bundles: bool,
14607 include_history_context_packs: bool,
14608 include_agent_progress: bool,
14609 include_session_task_correlation: bool,
14610) -> NodeSnapshot {
14611 let mut snapshot = shared.snapshot();
14612 if !include_provider_runtime_status {
14613 snapshot.provider_runtime_statuses.clear();
14614 }
14615 if !include_open_provider_ids {
14616 project_snapshot_legacy_provider_ids(&mut snapshot);
14617 }
14618 if !include_managed_worktrees {
14619 snapshot.managed_worktrees.clear();
14620 for workspace in &mut snapshot.workspaces {
14621 workspace.managed_worktree_profiles = None;
14622 }
14623 }
14624 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14625 if !include_spawn_profiles {
14626 inventory.spawn_profiles = None;
14627 }
14628 if !include_session_bundles {
14629 inventory.bundles = None;
14630 }
14631 if inventory.spawn_profiles.is_none()
14632 && inventory.bundles.is_none()
14633 && inventory.network_allowlists.is_none()
14634 {
14635 snapshot.launch_inventory = None;
14636 }
14637 }
14638 if !include_child_environment_profiles {
14639 clear_snapshot_child_environment_profiles(&mut snapshot);
14640 }
14641 if !include_session_bundles {
14642 clear_snapshot_session_bundles(&mut snapshot);
14643 }
14644 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14645 if !include_history_context_packs {
14646 clear_snapshot_context_packs(&mut snapshot);
14647 }
14648 if !include_agent_progress {
14649 snapshot.agent_progress.clear();
14650 }
14651 if !include_session_task_correlation {
14652 clear_snapshot_session_task_bindings(&mut snapshot);
14653 }
14654 snapshot
14655}
14656
14657fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14658 for record in &mut snapshot.session_records {
14659 record.task_binding = None;
14660 }
14661}
14662
14663fn project_event_without_session_task_binding(
14664 mut envelope: NodeEventEnvelope,
14665) -> NodeEventEnvelope {
14666 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14667 record.task_binding = None;
14668 }
14669 envelope
14670}
14671
14672fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14673 let Ok(response) = reply.result.as_mut() else { return };
14674 match response {
14675 NodeResponse::Snapshot { snapshot, .. } => {
14676 clear_snapshot_session_task_bindings(snapshot);
14677 }
14678 NodeResponse::Resync { snapshot, events, .. } => {
14679 clear_snapshot_session_task_bindings(snapshot);
14680 for event in events {
14681 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14682 record.task_binding = None;
14683 }
14684 }
14685 }
14686 NodeResponse::SessionRecordUpdated { record }
14687 | NodeResponse::ProviderSessionIndexed { record }
14688 | NodeResponse::NativeSessionIndexed { record, .. }
14689 | NodeResponse::SessionRecordResumed { record, .. } => {
14690 record.task_binding = None;
14691 }
14692 _ => {}
14693 }
14694}
14695
14696fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14697 let Ok(response) = reply.result.as_mut() else { return };
14698 match response {
14699 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14700 snapshot.agent_progress.clear();
14701 }
14702 _ => {}
14703 }
14704}
14705
14706fn project_event_without_managed_worktrees(
14707 mut envelope: NodeEventEnvelope,
14708) -> Option<NodeEventEnvelope> {
14709 if matches!(
14710 envelope.event,
14711 NodeEvent::ManagedWorktreeUpserted { .. }
14712 | NodeEvent::ManagedWorktreeRemoved { .. }
14713 ) {
14714 return None;
14715 }
14716 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14717 workspace.managed_worktree_profiles = None;
14718 }
14719 Some(envelope)
14720}
14721
14722fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14723 if matches!(
14724 reply.result,
14725 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14726 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14727 ) {
14728 reply.result = Err(failure(
14729 NodeFailureCode::UnsupportedCapability,
14730 "managed worktree capability was not negotiated",
14731 ));
14732 return;
14733 }
14734 let Ok(response) = reply.result.as_mut() else { return };
14735 match response {
14736 NodeResponse::Snapshot { snapshot, .. } => {
14737 snapshot.managed_worktrees.clear();
14738 for workspace in &mut snapshot.workspaces {
14739 workspace.managed_worktree_profiles = None;
14740 }
14741 }
14742 NodeResponse::Resync { snapshot, events, .. } => {
14743 snapshot.managed_worktrees.clear();
14744 for workspace in &mut snapshot.workspaces {
14745 workspace.managed_worktree_profiles = None;
14746 }
14747 *events = std::mem::take(events)
14748 .into_iter()
14749 .filter_map(project_event_without_managed_worktrees)
14750 .collect();
14751 }
14752 NodeResponse::WorkspaceRegistered { workspace }
14753 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14754 | NodeResponse::WorktreeCreated { workspace, .. } => {
14755 workspace.managed_worktree_profiles = None;
14756 }
14757 NodeResponse::WorkspaceInspected { inspection } => {
14758 inspection.git.managed_worktree = None;
14759 }
14760 _ => {}
14761 }
14762}
14763
14764fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14765 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14766 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14767 });
14768 if includes_open_provider_ids {
14769 return;
14770 }
14771 compatibility
14772 .provider_contracts
14773 .retain(|contract| provider_id_is_legacy(&contract.provider));
14774 compatibility
14775 .provider_adapter_contracts
14776 .retain(|contract| provider_id_is_legacy(&contract.provider));
14777}
14778
14779fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14780 if let Some(spec) = match request {
14781 NodeRequest::SpawnSpec { spec }
14782 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14783 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14784 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14785 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14786 _ => None,
14787 } {
14788 return shared
14789 .resolve_spawn_spec(spec)
14790 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14791 }
14792 request_requires_open_provider_ids_with(
14793 request,
14794 |session| shared.validate_address(session).ok(),
14795 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14796 )
14797}
14798
14799fn request_requires_child_environment_profile(
14800 shared: &NodeShared,
14801 request: &NodeRequest,
14802) -> bool {
14803 if let Some(spec) = match request {
14804 NodeRequest::SpawnSpec { spec }
14805 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14806 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14807 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14808 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14809 _ => None,
14810 } {
14811 return shared
14812 .resolve_spawn_spec(spec)
14813 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14814 }
14815 match request {
14816 NodeRequest::Resume { session, .. }
14817 | NodeRequest::Prompt { session, .. }
14818 | NodeRequest::Paste { session, .. }
14819 | NodeRequest::Input { session, .. }
14820 | NodeRequest::TerminalBytes { session, .. }
14821 | NodeRequest::TerminalControl { session, .. }
14822 | NodeRequest::Resize { session, .. }
14823 | NodeRequest::Interrupt { session }
14824 | NodeRequest::Stop { session, .. }
14825 | NodeRequest::Remove { session }
14826 | NodeRequest::ResolveInteraction { session, .. }
14827 | NodeRequest::SetSessionMode { session, .. }
14828 | NodeRequest::SetSessionConfigOption { session, .. }
14829 | NodeRequest::SetSessionModel { session, .. }
14830 | NodeRequest::DiscoverHistory { session, .. }
14831 | NodeRequest::LoadHistory { session, .. }
14832 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14833 | NodeRequest::ExportContextPack { session } => shared
14834 .session_bindings
14835 .lock()
14836 .unwrap_or_else(|poisoned| poisoned.into_inner())
14837 .get(&session.session.instance_id)
14838 .filter(|binding| {
14839 binding.workspace_id == session.workspace_id
14840 && binding.generation == session.session.generation
14841 })
14842 .map_or(true, |binding| binding.environment_profile.is_some()),
14843 NodeRequest::RenameSessionRecord { record_id, .. }
14844 | NodeRequest::SetSessionTask { record_id, .. }
14845 | NodeRequest::ResumeSessionRecord { record_id, .. }
14846 | NodeRequest::ForgetSessionRecord { record_id } => shared
14847 .record(record_id)
14848 .map_or(true, |record| record.environment_profile.is_some()),
14849 NodeRequest::ArmHarnessMcpReservation { .. }
14850 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14851 | NodeRequest::ActivateHarnessMcpReservation { .. }
14852 | NodeRequest::AbortHarnessMcpReservation { .. }
14853 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14854 | NodeRequest::RejectHarnessMcpCall { .. }
14855 | NodeRequest::Snapshot
14856 | NodeRequest::Resync { .. }
14857 | NodeRequest::BeginDeliveryStage { .. }
14858 | NodeRequest::PutDeliveryBlobChunk { .. }
14859 | NodeRequest::CommitDeliveryStage { .. }
14860 | NodeRequest::AbortDeliveryStage { .. }
14861 | NodeRequest::BrowseHostDirectories { .. }
14862 | NodeRequest::InspectWorkspace { .. }
14863 | NodeRequest::ReadWorkspaceFile { .. }
14864 | NodeRequest::WriteWorkspaceFile { .. }
14865 | NodeRequest::CreateWorkspaceFile { .. }
14866 | NodeRequest::CreateWorkspaceDirectory { .. }
14867 | NodeRequest::ReadGitHistory { .. }
14868 | NodeRequest::ReadGitDiff { .. }
14869 | NodeRequest::AcquireController { .. }
14870 | NodeRequest::ReleaseController
14871 | NodeRequest::Spawn { .. }
14872 | NodeRequest::SpawnWithOptions { .. }
14873 | NodeRequest::SpawnSpec { .. }
14874 | NodeRequest::RegisterWorkspace { .. }
14875 | NodeRequest::CreateStandaloneWorkspace { .. }
14876 | NodeRequest::UnregisterWorkspace { .. }
14877 | NodeRequest::CreateWorktree { .. }
14878 | NodeRequest::RemoveWorktree { .. }
14879 | NodeRequest::SpawnManagedWorktree { .. }
14880 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14881 | NodeRequest::CleanupManagedWorktree { .. }
14882 | NodeRequest::IndexProviderSession { .. }
14883 | NodeRequest::IndexNativeSession { .. }
14884 | NodeRequest::CatalogNativeSessions { .. }
14885 | NodeRequest::PageNativeSessions { .. }
14886 | NodeRequest::PreviewNativeSession { .. }
14887 | NodeRequest::PreviewSessionRecord { .. }
14888 | NodeRequest::ForgetContextPack { .. }
14889 | NodeRequest::ResolveDurableContextPack { .. }
14890 | NodeRequest::ReadContextPack { .. }
14891 | NodeRequest::Shutdown => false,
14892 }
14893}
14894
14895fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14896 if let Some(spec) = match request {
14897 NodeRequest::SpawnSpec { spec }
14898 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14899 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14900 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14901 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14902 _ => None,
14903 } {
14904 return shared
14905 .resolve_spawn_spec(spec)
14906 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14907 }
14908 match request {
14909 NodeRequest::Resume { session, .. }
14910 | NodeRequest::Prompt { session, .. }
14911 | NodeRequest::Paste { session, .. }
14912 | NodeRequest::Input { session, .. }
14913 | NodeRequest::TerminalBytes { session, .. }
14914 | NodeRequest::TerminalControl { session, .. }
14915 | NodeRequest::Resize { session, .. }
14916 | NodeRequest::Interrupt { session }
14917 | NodeRequest::Stop { session, .. }
14918 | NodeRequest::Remove { session }
14919 | NodeRequest::ResolveInteraction { session, .. }
14920 | NodeRequest::SetSessionMode { session, .. }
14921 | NodeRequest::SetSessionConfigOption { session, .. }
14922 | NodeRequest::SetSessionModel { session, .. }
14923 | NodeRequest::DiscoverHistory { session, .. }
14924 | NodeRequest::LoadHistory { session, .. }
14925 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14926 | NodeRequest::ExportContextPack { session } => shared
14927 .session_bindings
14928 .lock()
14929 .unwrap_or_else(|poisoned| poisoned.into_inner())
14930 .get(&session.session.instance_id)
14931 .filter(|binding| {
14932 binding.workspace_id == session.workspace_id
14933 && binding.generation == session.session.generation
14934 })
14935 .map_or(true, |binding| binding.bundle.is_some()),
14936 NodeRequest::RenameSessionRecord { record_id, .. }
14937 | NodeRequest::SetSessionTask { record_id, .. }
14938 | NodeRequest::ResumeSessionRecord { record_id, .. }
14939 | NodeRequest::ForgetSessionRecord { record_id } => shared
14940 .record(record_id)
14941 .map_or(true, |record| record.bundle.is_some()),
14942 NodeRequest::ArmHarnessMcpReservation { .. }
14943 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14944 | NodeRequest::ActivateHarnessMcpReservation { .. }
14945 | NodeRequest::AbortHarnessMcpReservation { .. }
14946 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14947 | NodeRequest::RejectHarnessMcpCall { .. }
14948 | NodeRequest::Snapshot
14949 | NodeRequest::Resync { .. }
14950 | NodeRequest::BeginDeliveryStage { .. }
14951 | NodeRequest::PutDeliveryBlobChunk { .. }
14952 | NodeRequest::CommitDeliveryStage { .. }
14953 | NodeRequest::AbortDeliveryStage { .. }
14954 | NodeRequest::BrowseHostDirectories { .. }
14955 | NodeRequest::InspectWorkspace { .. }
14956 | NodeRequest::ReadWorkspaceFile { .. }
14957 | NodeRequest::WriteWorkspaceFile { .. }
14958 | NodeRequest::CreateWorkspaceFile { .. }
14959 | NodeRequest::CreateWorkspaceDirectory { .. }
14960 | NodeRequest::ReadGitHistory { .. }
14961 | NodeRequest::ReadGitDiff { .. }
14962 | NodeRequest::AcquireController { .. }
14963 | NodeRequest::ReleaseController
14964 | NodeRequest::Spawn { .. }
14965 | NodeRequest::SpawnWithOptions { .. }
14966 | NodeRequest::SpawnSpec { .. }
14967 | NodeRequest::RegisterWorkspace { .. }
14968 | NodeRequest::CreateStandaloneWorkspace { .. }
14969 | NodeRequest::UnregisterWorkspace { .. }
14970 | NodeRequest::CreateWorktree { .. }
14971 | NodeRequest::RemoveWorktree { .. }
14972 | NodeRequest::SpawnManagedWorktree { .. }
14973 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14974 | NodeRequest::CleanupManagedWorktree { .. }
14975 | NodeRequest::IndexProviderSession { .. }
14976 | NodeRequest::IndexNativeSession { .. }
14977 | NodeRequest::CatalogNativeSessions { .. }
14978 | NodeRequest::PageNativeSessions { .. }
14979 | NodeRequest::PreviewNativeSession { .. }
14980 | NodeRequest::PreviewSessionRecord { .. }
14981 | NodeRequest::ForgetContextPack { .. }
14982 | NodeRequest::ResolveDurableContextPack { .. }
14983 | NodeRequest::ReadContextPack { .. }
14984 | NodeRequest::Shutdown => false,
14985 }
14986}
14987
14988fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14989 if request.requires_history_context_pack_capability() {
14990 return true;
14991 }
14992 if let Some(spec) = match request {
14993 NodeRequest::SpawnSpec { spec }
14994 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14995 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14996 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14997 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14998 _ => None,
14999 } {
15000 return shared
15001 .resolve_spawn_spec(spec)
15002 .map_or(true, |resolved| resolved.context_id.is_some());
15003 }
15004 match request {
15005 NodeRequest::Resume { session, .. }
15006 | NodeRequest::Prompt { session, .. }
15007 | NodeRequest::Paste { session, .. }
15008 | NodeRequest::Input { session, .. }
15009 | NodeRequest::TerminalBytes { session, .. }
15010 | NodeRequest::TerminalControl { session, .. }
15011 | NodeRequest::Resize { session, .. }
15012 | NodeRequest::Interrupt { session }
15013 | NodeRequest::Stop { session, .. }
15014 | NodeRequest::Remove { session }
15015 | NodeRequest::ResolveInteraction { session, .. }
15016 | NodeRequest::SetSessionMode { session, .. }
15017 | NodeRequest::SetSessionConfigOption { session, .. }
15018 | NodeRequest::SetSessionModel { session, .. } => shared
15019 .session_bindings
15020 .lock()
15021 .unwrap_or_else(|poisoned| poisoned.into_inner())
15022 .get(&session.session.instance_id)
15023 .filter(|binding| {
15024 binding.workspace_id == session.workspace_id
15025 && binding.generation == session.session.generation
15026 })
15027 .map_or(true, |binding| binding.context.is_some()),
15028 NodeRequest::RenameSessionRecord { record_id, .. }
15029 | NodeRequest::SetSessionTask { record_id, .. }
15030 | NodeRequest::ResumeSessionRecord { record_id, .. }
15031 | NodeRequest::ForgetSessionRecord { record_id } => shared
15032 .record(record_id)
15033 .map_or(true, |record| record.context.is_some()),
15034 NodeRequest::ArmHarnessMcpReservation { .. }
15035 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15036 | NodeRequest::ActivateHarnessMcpReservation { .. }
15037 | NodeRequest::AbortHarnessMcpReservation { .. }
15038 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15039 | NodeRequest::RejectHarnessMcpCall { .. }
15040 | NodeRequest::Snapshot
15041 | NodeRequest::Resync { .. }
15042 | NodeRequest::BeginDeliveryStage { .. }
15043 | NodeRequest::PutDeliveryBlobChunk { .. }
15044 | NodeRequest::CommitDeliveryStage { .. }
15045 | NodeRequest::AbortDeliveryStage { .. }
15046 | NodeRequest::BrowseHostDirectories { .. }
15047 | NodeRequest::InspectWorkspace { .. }
15048 | NodeRequest::ReadWorkspaceFile { .. }
15049 | NodeRequest::WriteWorkspaceFile { .. }
15050 | NodeRequest::CreateWorkspaceFile { .. }
15051 | NodeRequest::CreateWorkspaceDirectory { .. }
15052 | NodeRequest::ReadGitHistory { .. }
15053 | NodeRequest::ReadGitDiff { .. }
15054 | NodeRequest::AcquireController { .. }
15055 | NodeRequest::ReleaseController
15056 | NodeRequest::Spawn { .. }
15057 | NodeRequest::SpawnWithOptions { .. }
15058 | NodeRequest::SpawnSpec { .. }
15059 | NodeRequest::RegisterWorkspace { .. }
15060 | NodeRequest::CreateStandaloneWorkspace { .. }
15061 | NodeRequest::UnregisterWorkspace { .. }
15062 | NodeRequest::CreateWorktree { .. }
15063 | NodeRequest::RemoveWorktree { .. }
15064 | NodeRequest::SpawnManagedWorktree { .. }
15065 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15066 | NodeRequest::CleanupManagedWorktree { .. }
15067 | NodeRequest::IndexProviderSession { .. }
15068 | NodeRequest::IndexNativeSession { .. }
15069 | NodeRequest::CatalogNativeSessions { .. }
15070 | NodeRequest::PageNativeSessions { .. }
15071 | NodeRequest::PreviewNativeSession { .. }
15072 | NodeRequest::PreviewSessionRecord { .. }
15073 | NodeRequest::DiscoverHistory { .. }
15074 | NodeRequest::LoadHistory { .. }
15075 | NodeRequest::ExportContextPackForSessionRecord { .. }
15076 | NodeRequest::ExportContextPack { .. }
15077 | NodeRequest::ForgetContextPack { .. }
15078 | NodeRequest::ResolveDurableContextPack { .. }
15079 | NodeRequest::ReadContextPack { .. }
15080 | NodeRequest::Shutdown => false,
15081 }
15082}
15083
15084fn request_requires_open_provider_ids_with(
15085 request: &NodeRequest,
15086 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15087 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15088) -> bool {
15089 match request {
15090 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15091 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15092 NodeRequest::CatalogNativeSessions { route, .. }
15093 | NodeRequest::PageNativeSessions { route, .. } => {
15094 !provider_id_is_legacy(&route.provider)
15095 }
15096 NodeRequest::PreviewNativeSession { selection, .. }
15097 | NodeRequest::IndexNativeSession { selection, .. } => {
15098 !provider_id_is_legacy(&selection.route.provider)
15099 }
15100 NodeRequest::SpawnSpec { .. }
15101 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15102 NodeRequest::SpawnManagedWorktree { .. }
15103 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15104 NodeRequest::Resume { session, .. }
15105 | NodeRequest::Prompt { session, .. }
15106 | NodeRequest::Paste { session, .. }
15107 | NodeRequest::Input { session, .. }
15108 | NodeRequest::TerminalBytes { session, .. }
15109 | NodeRequest::TerminalControl { session, .. }
15110 | NodeRequest::Resize { session, .. }
15111 | NodeRequest::Interrupt { session }
15112 | NodeRequest::Stop { session, .. }
15113 | NodeRequest::Remove { session }
15114 | NodeRequest::ResolveInteraction { session, .. }
15115 | NodeRequest::SetSessionMode { session, .. }
15116 | NodeRequest::SetSessionConfigOption { session, .. }
15117 | NodeRequest::SetSessionModel { session, .. }
15118 | NodeRequest::DiscoverHistory { session, .. }
15119 | NodeRequest::LoadHistory { session, .. }
15120 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15121 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15122 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15123 NodeRequest::RenameSessionRecord { record_id, .. }
15124 | NodeRequest::SetSessionTask { record_id, .. }
15125 | NodeRequest::ResumeSessionRecord { record_id, .. }
15126 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15127 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15128 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15129 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15130 NodeRequest::ArmHarnessMcpReservation { .. }
15131 | NodeRequest::ActivateHarnessMcpReservation { .. }
15132 | NodeRequest::AbortHarnessMcpReservation { .. }
15133 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15134 | NodeRequest::RejectHarnessMcpCall { .. }
15135 | NodeRequest::Snapshot
15136 | NodeRequest::Resync { .. }
15137 | NodeRequest::BeginDeliveryStage { .. }
15138 | NodeRequest::PutDeliveryBlobChunk { .. }
15139 | NodeRequest::CommitDeliveryStage { .. }
15140 | NodeRequest::AbortDeliveryStage { .. }
15141 | NodeRequest::BrowseHostDirectories { .. }
15142 | NodeRequest::InspectWorkspace { .. }
15143 | NodeRequest::ReadWorkspaceFile { .. }
15144 | NodeRequest::WriteWorkspaceFile { .. }
15145 | NodeRequest::CreateWorkspaceFile { .. }
15146 | NodeRequest::CreateWorkspaceDirectory { .. }
15147 | NodeRequest::ReadGitHistory { .. }
15148 | NodeRequest::ReadGitDiff { .. }
15149 | NodeRequest::AcquireController { .. }
15150 | NodeRequest::ReleaseController
15151 | NodeRequest::RegisterWorkspace { .. }
15152 | NodeRequest::CreateStandaloneWorkspace { .. }
15153 | NodeRequest::UnregisterWorkspace { .. }
15154 | NodeRequest::CreateWorktree { .. }
15155 | NodeRequest::RemoveWorktree { .. }
15156 | NodeRequest::CleanupManagedWorktree { .. }
15157 | NodeRequest::Shutdown => false,
15158 NodeRequest::ForgetContextPack { .. } => true,
15159 NodeRequest::ResolveDurableContextPack { .. }
15165 | NodeRequest::ReadContextPack { .. } => true,
15166 }
15167}
15168
15169fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15170 snapshot.enabled_providers.retain(provider_id_is_legacy);
15171 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15172 snapshot
15173 .provider_runtime_statuses
15174 .as_slice()
15175 .iter()
15176 .filter(|status| provider_id_is_legacy(status.provider()))
15177 .cloned(),
15178 )
15179 .expect("a filtered provider runtime projection remains valid");
15180 snapshot
15181 .session_records
15182 .retain(|record| provider_id_is_legacy(&record.provider));
15183 for workspace in &mut snapshot.workspaces {
15184 workspace
15185 .sessions
15186 .retain(|session| provider_id_is_legacy(&session.agent_id));
15187 }
15188}
15189
15190fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15191 for record in &mut snapshot.session_records {
15192 record.environment_profile = None;
15193 }
15194 if let Some(profiles) = snapshot
15195 .launch_inventory
15196 .as_mut()
15197 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15198 {
15199 for profile in profiles {
15200 profile.environment_profile = None;
15201 }
15202 }
15203}
15204
15205fn project_event_without_child_environment_profile(
15206 mut envelope: NodeEventEnvelope,
15207) -> NodeEventEnvelope {
15208 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15209 record.environment_profile = None;
15210 }
15211 envelope
15212}
15213
15214fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15215 let contains_environment_profile = match reply.result.as_ref() {
15216 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15217 receipt.environment_profile.is_some()
15218 }
15219 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15220 receipt.spawn.environment_profile.is_some()
15221 }
15222 _ => false,
15223 };
15224 if contains_environment_profile {
15225 reply.result = Err(failure(
15226 NodeFailureCode::UnsupportedCapability,
15227 "child environment profiles were not negotiated",
15228 ));
15229 return;
15230 }
15231 let Ok(response) = reply.result.as_mut() else {
15232 return;
15233 };
15234 match response {
15235 NodeResponse::Snapshot { snapshot, .. } => {
15236 clear_snapshot_child_environment_profiles(snapshot);
15237 }
15238 NodeResponse::Resync { snapshot, events, .. } => {
15239 clear_snapshot_child_environment_profiles(snapshot);
15240 for event in events {
15241 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15242 record.environment_profile = None;
15243 }
15244 }
15245 }
15246 NodeResponse::SessionRecordUpdated { record }
15247 | NodeResponse::ProviderSessionIndexed { record }
15248 | NodeResponse::NativeSessionIndexed { record, .. }
15249 | NodeResponse::SessionRecordResumed { record, .. } => {
15250 record.environment_profile = None;
15251 }
15252 NodeResponse::Armed { .. }
15253 | NodeResponse::Spawned { .. }
15254 | NodeResponse::Activated { .. }
15255 | NodeResponse::Aborted { .. }
15256 | NodeResponse::ReplyChunkAccepted { .. }
15257 | NodeResponse::CallRejected { .. }
15258 | NodeResponse::DeliveryStageBegun { .. }
15259 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15260 | NodeResponse::DeliveryCommitted { .. }
15261 | NodeResponse::DeliveryStageAborted { .. }
15262 | NodeResponse::WorkspaceInspected { .. }
15263 | NodeResponse::HostDirectoriesBrowsed { .. }
15264 | NodeResponse::WorkspaceFileRead { .. }
15265 | NodeResponse::WorkspaceFileWritten { .. }
15266 | NodeResponse::WorkspaceFileCreated { .. }
15267 | NodeResponse::WorkspaceDirectoryCreated { .. }
15268 | NodeResponse::GitHistoryRead { .. }
15269 | NodeResponse::GitDiffRead { .. }
15270 | NodeResponse::Controller { .. }
15271 | NodeResponse::SpawnAccepted { .. }
15272 | NodeResponse::SpawnSpecAccepted { .. }
15273 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15274 | NodeResponse::ManagedWorktreeCleanup { .. }
15275 | NodeResponse::NativeSessionsCataloged { .. }
15276 | NodeResponse::NativeSessionsPaged { .. }
15277 | NodeResponse::NativeSessionPreviewed { .. }
15278 | NodeResponse::SessionRecordPreviewed { .. }
15279 | NodeResponse::HistoryDiscovered { .. }
15280 | NodeResponse::HistoryLoaded { .. }
15281 | NodeResponse::ContextPackForSessionRecordExported { .. }
15282 | NodeResponse::ContextPackExported { .. }
15283 | NodeResponse::ContextPackForgotten { .. }
15284 | NodeResponse::DurableContextPackResolved { .. }
15285 | NodeResponse::ContextPackBytesRead { .. }
15286 | NodeResponse::SessionRecordForgotten { .. }
15287 | NodeResponse::WorkspaceRegistered { .. }
15288 | NodeResponse::StandaloneWorkspaceCreated { .. }
15289 | NodeResponse::WorkspaceUnregistered { .. }
15290 | NodeResponse::WorktreeCreated { .. }
15291 | NodeResponse::WorktreeRemoved { .. }
15292 | NodeResponse::Accepted
15293 | NodeResponse::ShuttingDown => {}
15294 }
15295}
15296
15297fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15298 for record in &mut snapshot.session_records {
15299 record.bundle = None;
15300 }
15301}
15302
15303fn project_event_without_session_bundle(
15304 mut envelope: NodeEventEnvelope,
15305) -> NodeEventEnvelope {
15306 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15307 record.bundle = None;
15308 }
15309 envelope
15310}
15311
15312fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15313 let contains_bundle = match reply.result.as_ref() {
15314 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15315 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15316 receipt.spawn.bundle.is_some()
15317 }
15318 _ => false,
15319 };
15320 if contains_bundle {
15321 reply.result = Err(failure(
15322 NodeFailureCode::UnsupportedCapability,
15323 "session bundles were not negotiated",
15324 ));
15325 return;
15326 }
15327 let Ok(response) = reply.result.as_mut() else {
15328 return;
15329 };
15330 match response {
15331 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15332 NodeResponse::Resync { snapshot, events, .. } => {
15333 clear_snapshot_session_bundles(snapshot);
15334 for event in events {
15335 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15336 record.bundle = None;
15337 }
15338 }
15339 }
15340 NodeResponse::SessionRecordUpdated { record }
15341 | NodeResponse::ProviderSessionIndexed { record }
15342 | NodeResponse::NativeSessionIndexed { record, .. }
15343 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15344 NodeResponse::Armed { .. }
15345 | NodeResponse::Spawned { .. }
15346 | NodeResponse::Activated { .. }
15347 | NodeResponse::Aborted { .. }
15348 | NodeResponse::ReplyChunkAccepted { .. }
15349 | NodeResponse::CallRejected { .. }
15350 | NodeResponse::DeliveryStageBegun { .. }
15351 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15352 | NodeResponse::DeliveryCommitted { .. }
15353 | NodeResponse::DeliveryStageAborted { .. }
15354 | NodeResponse::WorkspaceInspected { .. }
15355 | NodeResponse::HostDirectoriesBrowsed { .. }
15356 | NodeResponse::WorkspaceFileRead { .. }
15357 | NodeResponse::WorkspaceFileWritten { .. }
15358 | NodeResponse::WorkspaceFileCreated { .. }
15359 | NodeResponse::WorkspaceDirectoryCreated { .. }
15360 | NodeResponse::GitHistoryRead { .. }
15361 | NodeResponse::GitDiffRead { .. }
15362 | NodeResponse::Controller { .. }
15363 | NodeResponse::SpawnAccepted { .. }
15364 | NodeResponse::SpawnSpecAccepted { .. }
15365 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15366 | NodeResponse::ManagedWorktreeCleanup { .. }
15367 | NodeResponse::NativeSessionsCataloged { .. }
15368 | NodeResponse::NativeSessionsPaged { .. }
15369 | NodeResponse::NativeSessionPreviewed { .. }
15370 | NodeResponse::SessionRecordPreviewed { .. }
15371 | NodeResponse::HistoryDiscovered { .. }
15372 | NodeResponse::HistoryLoaded { .. }
15373 | NodeResponse::ContextPackForSessionRecordExported { .. }
15374 | NodeResponse::ContextPackExported { .. }
15375 | NodeResponse::ContextPackForgotten { .. }
15376 | NodeResponse::DurableContextPackResolved { .. }
15377 | NodeResponse::ContextPackBytesRead { .. }
15378 | NodeResponse::SessionRecordForgotten { .. }
15379 | NodeResponse::WorkspaceRegistered { .. }
15380 | NodeResponse::StandaloneWorkspaceCreated { .. }
15381 | NodeResponse::WorkspaceUnregistered { .. }
15382 | NodeResponse::WorktreeCreated { .. }
15383 | NodeResponse::WorktreeRemoved { .. }
15384 | NodeResponse::Accepted
15385 | NodeResponse::ShuttingDown => {}
15386 }
15387}
15388
15389fn project_snapshot_history_for_wire(
15390 snapshot: &mut NodeSnapshot,
15391 include_history_metadata: bool,
15392) {
15393 for workspace in &mut snapshot.workspaces {
15394 for session in &mut workspace.sessions {
15395 if include_history_metadata {
15396 session.history.loaded = None;
15397 } else {
15398 session.history = Default::default();
15399 }
15400 }
15401 }
15402}
15403
15404fn project_event_history_for_wire(
15405 mut envelope: NodeEventEnvelope,
15406 include_history_metadata: bool,
15407) -> NodeEventEnvelope {
15408 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15409 for session in &mut workspace.sessions {
15410 if include_history_metadata {
15411 session.history.loaded = None;
15412 } else {
15413 session.history = Default::default();
15414 }
15415 }
15416 }
15417 envelope
15418}
15419
15420fn project_response_history_for_wire(
15421 reply: &mut ResponseEnvelope,
15422 include_history_metadata: bool,
15423) {
15424 let Ok(response) = reply.result.as_mut() else {
15425 return;
15426 };
15427 match response {
15428 NodeResponse::Snapshot { snapshot, .. } => {
15429 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15430 }
15431 NodeResponse::Resync {
15432 snapshot, events, ..
15433 } => {
15434 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15435 for event in events {
15436 *event = project_event_history_for_wire(
15437 event.clone(),
15438 include_history_metadata,
15439 );
15440 }
15441 }
15442 NodeResponse::WorkspaceRegistered { workspace }
15443 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15444 | NodeResponse::WorktreeCreated { workspace, .. } => {
15445 for session in &mut workspace.sessions {
15446 if include_history_metadata {
15447 session.history.loaded = None;
15448 } else {
15449 session.history = Default::default();
15450 }
15451 }
15452 }
15453 _ => {}
15454 }
15455}
15456
15457fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15458 for record in &mut snapshot.session_records {
15459 record.context_id = None;
15460 record.context = None;
15461 record.exported_context = None;
15462 }
15463}
15464
15465fn project_event_without_context_pack(
15466 mut envelope: NodeEventEnvelope,
15467) -> NodeEventEnvelope {
15468 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15469 record.context_id = None;
15470 record.context = None;
15471 record.exported_context = None;
15472 }
15473 envelope
15474}
15475
15476fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15477 let contains_context = match reply.result.as_ref() {
15478 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15479 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15480 receipt.spawn.context.is_some()
15481 }
15482 Ok(NodeResponse::HistoryDiscovered { .. })
15483 | Ok(NodeResponse::HistoryLoaded { .. })
15484 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15485 | Ok(NodeResponse::ContextPackExported { .. })
15486 | Ok(NodeResponse::ContextPackForgotten { .. })
15487 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15488 _ => false,
15489 };
15490 if contains_context {
15491 reply.result = Err(failure(
15492 NodeFailureCode::UnsupportedCapability,
15493 "history context packs were not negotiated",
15494 ));
15495 return;
15496 }
15497 let Ok(response) = reply.result.as_mut() else {
15498 return;
15499 };
15500 match response {
15501 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15502 NodeResponse::Resync {
15503 snapshot, events, ..
15504 } => {
15505 clear_snapshot_context_packs(snapshot);
15506 for event in events {
15507 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15508 record.context_id = None;
15509 record.context = None;
15510 record.exported_context = None;
15511 }
15512 }
15513 }
15514 NodeResponse::SessionRecordUpdated { record }
15515 | NodeResponse::ProviderSessionIndexed { record }
15516 | NodeResponse::NativeSessionIndexed { record, .. }
15517 | NodeResponse::SessionRecordResumed { record, .. } => {
15518 record.context_id = None;
15519 record.context = None;
15520 record.exported_context = None;
15521 }
15522 _ => {}
15523 }
15524}
15525
15526fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15527 let contains_open_record = match reply.result.as_ref() {
15528 Ok(NodeResponse::SessionRecordUpdated { record })
15529 | Ok(NodeResponse::ProviderSessionIndexed { record })
15530 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15531 !provider_id_is_legacy(&record.provider)
15532 }
15533 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15534 !provider_id_is_legacy(&selection.route.provider)
15535 || !provider_id_is_legacy(&record.provider)
15536 }
15537 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15538 !provider_id_is_legacy(&receipt.provider)
15539 }
15540 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15541 !provider_id_is_legacy(&receipt.spawn.provider)
15542 }
15543 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15544 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15545 !provider_id_is_legacy(&route.provider)
15546 }
15547 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15548 !provider_id_is_legacy(&selection.route.provider)
15549 }
15550 _ => false,
15551 };
15552 if contains_open_record {
15553 reply.result = Err(failure(
15554 NodeFailureCode::UnsupportedCapability,
15555 "open provider IDs were not negotiated",
15556 ));
15557 return;
15558 }
15559 let Ok(response) = reply.result.as_mut() else {
15560 return;
15561 };
15562 match response {
15563 NodeResponse::Snapshot { snapshot, .. } => {
15564 project_snapshot_legacy_provider_ids(snapshot);
15565 }
15566 NodeResponse::Resync { snapshot, events, .. } => {
15567 project_snapshot_legacy_provider_ids(snapshot);
15568 *events = std::mem::take(events)
15569 .into_iter()
15570 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15571 .collect();
15572 }
15573 NodeResponse::WorkspaceRegistered { workspace }
15574 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15575 | NodeResponse::WorktreeCreated { workspace, .. } => {
15576 workspace
15577 .sessions
15578 .retain(|session| provider_id_is_legacy(&session.agent_id));
15579 }
15580 NodeResponse::Armed { .. }
15581 | NodeResponse::Spawned { .. }
15582 | NodeResponse::Activated { .. }
15583 | NodeResponse::Aborted { .. }
15584 | NodeResponse::ReplyChunkAccepted { .. }
15585 | NodeResponse::CallRejected { .. }
15586 | NodeResponse::DeliveryStageBegun { .. }
15587 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15588 | NodeResponse::DeliveryCommitted { .. }
15589 | NodeResponse::DeliveryStageAborted { .. }
15590 | NodeResponse::WorkspaceInspected { .. }
15591 | NodeResponse::HostDirectoriesBrowsed { .. }
15592 | NodeResponse::WorkspaceFileRead { .. }
15593 | NodeResponse::WorkspaceFileWritten { .. }
15594 | NodeResponse::WorkspaceFileCreated { .. }
15595 | NodeResponse::WorkspaceDirectoryCreated { .. }
15596 | NodeResponse::GitHistoryRead { .. }
15597 | NodeResponse::GitDiffRead { .. }
15598 | NodeResponse::Controller { .. }
15599 | NodeResponse::SpawnAccepted { .. }
15600 | NodeResponse::SpawnSpecAccepted { .. }
15601 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15602 | NodeResponse::ManagedWorktreeCleanup { .. }
15603 | NodeResponse::NativeSessionsCataloged { .. }
15604 | NodeResponse::NativeSessionsPaged { .. }
15605 | NodeResponse::NativeSessionPreviewed { .. }
15606 | NodeResponse::SessionRecordPreviewed { .. }
15607 | NodeResponse::HistoryDiscovered { .. }
15608 | NodeResponse::HistoryLoaded { .. }
15609 | NodeResponse::ContextPackForSessionRecordExported { .. }
15610 | NodeResponse::ContextPackExported { .. }
15611 | NodeResponse::ContextPackForgotten { .. }
15612 | NodeResponse::DurableContextPackResolved { .. }
15613 | NodeResponse::ContextPackBytesRead { .. }
15614 | NodeResponse::SessionRecordUpdated { .. }
15615 | NodeResponse::ProviderSessionIndexed { .. }
15616 | NodeResponse::NativeSessionIndexed { .. }
15617 | NodeResponse::SessionRecordResumed { .. }
15618 | NodeResponse::SessionRecordForgotten { .. }
15619 | NodeResponse::WorkspaceUnregistered { .. }
15620 | NodeResponse::WorktreeRemoved { .. }
15621 | NodeResponse::Accepted
15622 | NodeResponse::ShuttingDown => {}
15623 }
15624}
15625
15626fn project_event_legacy_provider_ids(
15627 shared: &NodeShared,
15628 mut envelope: NodeEventEnvelope,
15629) -> Option<NodeEventEnvelope> {
15630 let include = match &mut envelope.event {
15631 NodeEvent::HarnessMcpReadCall { .. } => true,
15632 NodeEvent::Control { address, .. }
15633 | NodeEvent::AgentStream { address, .. }
15634 | NodeEvent::TerminalFrame { address, .. } => shared
15635 .validate_address(address)
15636 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15637 NodeEvent::WorkspaceAdded { workspace } => {
15638 workspace
15639 .sessions
15640 .retain(|session| provider_id_is_legacy(&session.agent_id));
15641 true
15642 }
15643 NodeEvent::SessionRecordUpserted { record } => {
15644 provider_id_is_legacy(&record.provider)
15645 }
15646 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15647 NodeEvent::SessionRecordRemoved { .. } => shared
15648 .history
15649 .lock()
15650 .unwrap_or_else(|poisoned| poisoned.into_inner())
15651 .removed_record_providers
15652 .get(&envelope.sequence)
15653 .is_some_and(provider_id_is_legacy),
15654 NodeEvent::ControllerChanged { .. }
15655 | NodeEvent::ManagedWorktreeUpserted { .. }
15656 | NodeEvent::ManagedWorktreeRemoved { .. }
15657 | NodeEvent::WorkspaceRemoved { .. }
15658 | NodeEvent::ResyncRequired { .. } => true,
15659 };
15660 include.then_some(envelope)
15661}
15662
15663fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15664 let Ok(response) = reply.result.as_mut() else {
15665 return;
15666 };
15667 match response {
15668 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15669 snapshot.provider_runtime_statuses.clear();
15670 }
15671 NodeResponse::Armed { .. }
15672 | NodeResponse::Spawned { .. }
15673 | NodeResponse::Activated { .. }
15674 | NodeResponse::Aborted { .. }
15675 | NodeResponse::ReplyChunkAccepted { .. }
15676 | NodeResponse::CallRejected { .. }
15677 | NodeResponse::DeliveryStageBegun { .. }
15678 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15679 | NodeResponse::DeliveryCommitted { .. }
15680 | NodeResponse::DeliveryStageAborted { .. }
15681 | NodeResponse::WorkspaceInspected { .. }
15682 | NodeResponse::HostDirectoriesBrowsed { .. }
15683 | NodeResponse::WorkspaceFileRead { .. }
15684 | NodeResponse::WorkspaceFileWritten { .. }
15685 | NodeResponse::WorkspaceFileCreated { .. }
15686 | NodeResponse::WorkspaceDirectoryCreated { .. }
15687 | NodeResponse::GitHistoryRead { .. }
15688 | NodeResponse::GitDiffRead { .. }
15689 | NodeResponse::Controller { .. }
15690 | NodeResponse::SpawnAccepted { .. }
15691 | NodeResponse::SpawnSpecAccepted { .. }
15692 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15693 | NodeResponse::ManagedWorktreeCleanup { .. }
15694 | NodeResponse::NativeSessionsCataloged { .. }
15695 | NodeResponse::NativeSessionsPaged { .. }
15696 | NodeResponse::NativeSessionPreviewed { .. }
15697 | NodeResponse::SessionRecordPreviewed { .. }
15698 | NodeResponse::HistoryDiscovered { .. }
15699 | NodeResponse::HistoryLoaded { .. }
15700 | NodeResponse::ContextPackForSessionRecordExported { .. }
15701 | NodeResponse::ContextPackExported { .. }
15702 | NodeResponse::ContextPackForgotten { .. }
15703 | NodeResponse::DurableContextPackResolved { .. }
15704 | NodeResponse::ContextPackBytesRead { .. }
15705 | NodeResponse::SessionRecordUpdated { .. }
15706 | NodeResponse::ProviderSessionIndexed { .. }
15707 | NodeResponse::NativeSessionIndexed { .. }
15708 | NodeResponse::SessionRecordResumed { .. }
15709 | NodeResponse::SessionRecordForgotten { .. }
15710 | NodeResponse::WorkspaceRegistered { .. }
15711 | NodeResponse::StandaloneWorkspaceCreated { .. }
15712 | NodeResponse::WorkspaceUnregistered { .. }
15713 | NodeResponse::WorktreeCreated { .. }
15714 | NodeResponse::WorktreeRemoved { .. }
15715 | NodeResponse::Accepted
15716 | NodeResponse::ShuttingDown => {}
15717 }
15718}
15719
15720struct ControllerReleaseGuard {
15721 shared: Arc<NodeShared>,
15722 connection_id: u64,
15723}
15724
15725impl Drop for ControllerReleaseGuard {
15726 fn drop(&mut self) {
15727 self.shared.release_controller(self.connection_id);
15728 }
15729}
15730
15731struct AbortTaskOnDrop(AbortHandle);
15732
15733impl Drop for AbortTaskOnDrop {
15734 fn drop(&mut self) {
15735 self.0.abort();
15736 }
15737}
15738
15739fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15744 std::env::var(name)
15745 .ok()
15746 .and_then(|value| value.parse::<u64>().ok())
15747 .filter(|value| *value > 0)
15748 .unwrap_or(default)
15749 .min(max)
15750}
15751
15752fn workspace_inspection_time_budget_ms() -> u64 {
15753 env_config_bounded(
15754 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15755 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15756 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15757 )
15758}
15759
15760fn workspace_inspection_entry_cap() -> usize {
15761 env_config_bounded(
15762 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15763 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15764 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15765 ) as usize
15766}
15767
15768struct WorkspaceWalkBudget {
15774 deadline: Instant,
15775 entry_cap: usize,
15776 entries_visited: u64,
15777 time_budget_exceeded: bool,
15778 entry_cap_exceeded: bool,
15779}
15780
15781impl WorkspaceWalkBudget {
15782 fn new(deadline: Instant, entry_cap: usize) -> Self {
15783 Self {
15784 deadline,
15785 entry_cap,
15786 entries_visited: 0,
15787 time_budget_exceeded: false,
15788 entry_cap_exceeded: false,
15789 }
15790 }
15791
15792 fn is_exhausted(&self) -> bool {
15793 self.time_budget_exceeded || self.entry_cap_exceeded
15794 }
15795
15796 fn record_visit(&mut self) -> bool {
15799 self.entries_visited += 1;
15800 if self.entries_visited as usize >= self.entry_cap {
15801 self.entry_cap_exceeded = true;
15802 }
15803 if Instant::now() >= self.deadline {
15804 self.time_budget_exceeded = true;
15805 }
15806 self.is_exhausted()
15807 }
15808}
15809
15810fn collect_workspace_entries(
15811 root: &Path,
15812 deadline: Instant,
15813 entry_cap: usize,
15814) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15815 let mut entries = Vec::new();
15816 let mut truncated = false;
15817 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15818 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15819 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15823 (entries, truncated, budget)
15824}
15825
15826fn workspace_inspection_truncation(
15830 walk_budget: &WorkspaceWalkBudget,
15831 git_time_budget_exceeded: bool,
15832 elapsed: Duration,
15833) -> Option<WorkspaceInspectionTruncationV1> {
15834 if !walk_budget.time_budget_exceeded
15835 && !walk_budget.entry_cap_exceeded
15836 && !git_time_budget_exceeded
15837 {
15838 return None;
15839 }
15840 Some(WorkspaceInspectionTruncationV1 {
15841 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15842 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15843 git_time_budget_exceeded,
15844 entries_visited: walk_budget.entries_visited,
15845 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15846 })
15847}
15848
15849fn walk_workspace_directory(
15850 directory: &Path,
15851 relative_directory: Option<&RepositoryPath>,
15852 depth: usize,
15853 entries: &mut Vec<WorkspaceEntry>,
15854 truncated: &mut bool,
15855 budget: &mut WorkspaceWalkBudget,
15856) {
15857 if budget.is_exhausted() {
15858 *truncated = true;
15859 return;
15860 }
15861 let read_dir = match std::fs::read_dir(directory) {
15862 Ok(read_dir) => read_dir,
15863 Err(_) => {
15864 *truncated = true;
15865 return;
15866 }
15867 };
15868 let mut children = Vec::new();
15869 for child in read_dir {
15870 match child {
15871 Ok(child) => children.push(child),
15872 Err(_) => *truncated = true,
15873 }
15874 }
15875 children.sort_by_key(|child| child.file_name());
15876 for child in children {
15877 if budget.record_visit() {
15878 *truncated = true;
15879 return;
15880 }
15881 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15882 *truncated = true;
15883 return;
15884 }
15885 let file_type = match child.file_type() {
15886 Ok(file_type) => file_type,
15887 Err(_) => {
15888 *truncated = true;
15889 continue;
15890 }
15891 };
15892 if file_type.is_symlink() {
15893 continue;
15894 }
15895 let name = match child.file_name().into_string() {
15896 Ok(name) => name,
15897 Err(_) => {
15898 *truncated = true;
15899 continue;
15900 }
15901 };
15902 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15903 continue;
15904 }
15905 let relative_path = match relative_directory {
15906 None => windows_repository_path(name),
15907 Some(parent) => parent
15908 .as_utf8()
15909 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15910 };
15911 let Some(relative_path) = relative_path else {
15912 *truncated = true;
15913 continue;
15914 };
15915 let kind = if file_type.is_dir() {
15916 WorkspaceEntryKind::Directory
15917 } else if file_type.is_file() {
15918 WorkspaceEntryKind::File
15919 } else {
15920 continue;
15921 };
15922 entries.push(WorkspaceEntry {
15923 relative_path: relative_path.clone(),
15924 kind,
15925 });
15926 if file_type.is_dir() {
15927 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15928 walk_workspace_directory(
15929 &child.path(),
15930 Some(&relative_path),
15931 depth + 1,
15932 entries,
15933 truncated,
15934 budget,
15935 );
15936 } else {
15937 *truncated = true;
15938 }
15939 }
15940 }
15941}
15942
15943fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15944 if value.contains('\\') {
15945 return None;
15946 }
15947 RepositoryPath::utf8(value).ok()
15948}
15949
15950fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15951 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15952}
15953
15954fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15963 if name.eq_ignore_ascii_case(".git")
15964 || name.eq_ignore_ascii_case("target")
15965 || name.eq_ignore_ascii_case("node_modules")
15966 {
15967 return true;
15968 }
15969 path.join("CACHEDIR.TAG").is_file()
15970}
15971
15972fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15973 matches!(
15974 status,
15975 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15976 )
15977}
15978
15979#[derive(Clone, Debug, Eq, PartialEq)]
15980enum ContextPackRepositoryHead {
15981 NotRepository,
15982 Unborn,
15983 Commit(String),
15984}
15985
15986#[derive(Clone, Debug, Eq, PartialEq)]
15987struct ContextPackRepositoryObservation {
15988 head: ContextPackRepositoryHead,
15989 git: GitSnapshot,
15990 files: Vec<ContextPackRepositoryFileSource>,
15991}
15992
15993fn stable_context_pack_repository(
15994 first: ContextPackRepositoryObservation,
15995 second: ContextPackRepositoryObservation,
15996) -> Result<ContextPackRepository, NodeFailure> {
15997 if first != second {
15998 return Err(failure(
15999 NodeFailureCode::ContextPackMaterializationFailed,
16000 "context repository changed during bounded capture",
16001 ));
16002 }
16003 Ok(ContextPackRepository::from_sources(
16004 &second.git,
16005 second.files,
16006 ))
16007}
16008
16009fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16016 if Instant::now() < deadline {
16017 return false;
16018 }
16019 append_git_diagnostic(
16020 snapshot,
16021 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16022 );
16023 true
16024}
16025
16026async fn run_git_bounded_with_deadline(
16035 root: &str,
16036 arguments: &[&str],
16037 output_limit: usize,
16038 deadline: Instant,
16039) -> io::Result<GitCommandOutput> {
16040 let remaining = deadline.checked_duration_since(Instant::now());
16041 let timeout_ms = remaining
16042 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16043 .unwrap_or(0)
16044 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16045 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16046}
16047
16048async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16055 let mut snapshot = GitSnapshot {
16056 is_repository: false,
16057 branch: None,
16058 status: Vec::new(),
16059 recent_commits: Vec::new(),
16060 worktrees: Vec::new(),
16061 managed_worktree: None,
16062 truncated: false,
16063 diagnostic: None,
16064 };
16065 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16066 return (snapshot, true);
16067 }
16068 let repository = match run_git_bounded_with_deadline(
16069 root,
16070 &["rev-parse", "--is-inside-work-tree"],
16071 4 * 1_024,
16072 deadline,
16073 )
16074 .await
16075 {
16076 Ok(output) => output,
16077 Err(error) => {
16078 append_git_diagnostic(
16079 &mut snapshot,
16080 &format!("git inspection unavailable: {error}"),
16081 );
16082 return (snapshot, false);
16083 }
16084 };
16085 snapshot.truncated |= repository.truncated;
16086 if repository.timed_out {
16087 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16088 return (snapshot, false);
16089 }
16090 if !repository.success
16091 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16092 {
16093 let stderr = String::from_utf8_lossy(&repository.stderr);
16094 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16095 append_git_diagnostic(&mut snapshot, stderr.trim());
16096 }
16097 return (snapshot, false);
16098 }
16099 snapshot.is_repository = true;
16100
16101 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16102 return (snapshot, true);
16103 }
16104 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16105 Ok(output) => {
16106 snapshot.truncated |= output.truncated;
16107 if output.timed_out {
16108 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16109 } else if output.success {
16110 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16111 if !branch.is_empty() {
16112 snapshot.branch = Some(truncate_git_field(
16113 &branch,
16114 MAX_REPOSITORY_PATH_BYTES,
16115 &mut snapshot.truncated,
16116 ));
16117 }
16118 }
16119 }
16120 Err(error) => append_git_diagnostic(
16121 &mut snapshot,
16122 &format!("git branch query failed: {error}"),
16123 ),
16124 }
16125 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16126 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16127 snapshot.truncated |= output.truncated;
16128 if output.success && !output.timed_out {
16129 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16130 if !head.is_empty() {
16131 snapshot.branch = Some(format!("detached:{head}"));
16132 }
16133 }
16134 }
16135 }
16136
16137 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16138 return (snapshot, true);
16139 }
16140 match run_git_bounded_with_deadline(
16141 root,
16142 &[
16143 "status",
16144 "--porcelain=v1",
16145 "-z",
16146 "--renames",
16147 "--untracked-files=normal",
16148 "--",
16149 ".",
16150 ],
16151 GIT_OUTPUT_MAX_BYTES,
16152 deadline,
16153 )
16154 .await
16155 {
16156 Ok(output) => {
16157 snapshot.truncated |= output.truncated;
16158 if output.timed_out {
16159 append_git_diagnostic(&mut snapshot, "git status timed out");
16160 } else if output.success {
16161 parse_git_status(&output.stdout, &mut snapshot);
16162 } else {
16163 append_git_diagnostic(
16164 &mut snapshot,
16165 String::from_utf8_lossy(&output.stderr).trim(),
16166 );
16167 }
16168 }
16169 Err(error) => append_git_diagnostic(
16170 &mut snapshot,
16171 &format!("git status failed: {error}"),
16172 ),
16173 }
16174
16175 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16176 return (snapshot, true);
16177 }
16178 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16179 match run_git_bounded_with_deadline(
16180 root,
16181 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16182 16 * 1_024,
16183 deadline,
16184 )
16185 .await
16186 {
16187 Ok(output) => {
16188 snapshot.truncated |= output.truncated;
16189 if output.timed_out {
16190 append_git_diagnostic(&mut snapshot, "git log timed out");
16191 } else if output.success {
16192 parse_git_commits(&output.stdout, &mut snapshot);
16193 } else {
16194 let stderr = String::from_utf8_lossy(&output.stderr);
16195 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16196 append_git_diagnostic(&mut snapshot, stderr.trim());
16197 }
16198 }
16199 }
16200 Err(error) => append_git_diagnostic(
16201 &mut snapshot,
16202 &format!("git log failed: {error}"),
16203 ),
16204 }
16205
16206 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16207 return (snapshot, true);
16208 }
16209 match list_git_worktrees_with_deadline(root, deadline).await {
16210 Ok(worktrees) => {
16211 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16212 }
16213 Err(error) => append_git_diagnostic(
16214 &mut snapshot,
16215 &format!("git worktree list failed: {}", error.message),
16216 ),
16217 }
16218 (snapshot, false)
16219}
16220
16221fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16222 let mut remaining = output;
16223 while !remaining.is_empty() {
16224 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16225 snapshot.truncated = true;
16226 break;
16227 }
16228 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16229 snapshot.truncated = true;
16230 break;
16231 };
16232 let record = &remaining[..record_end];
16233 remaining = &remaining[record_end + 1..];
16234 let has_previous_path = record
16235 .get(..2)
16236 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16237 let previous_path_bytes = if has_previous_path {
16238 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16239 snapshot.truncated = true;
16240 break;
16241 };
16242 let previous = &remaining[..previous_end];
16243 remaining = &remaining[previous_end + 1..];
16244 Some(previous)
16245 } else {
16246 None
16247 };
16248 if record.len() < 4
16249 || record[2] != b' '
16250 || !is_git_status_code(record[0])
16251 || !is_git_status_code(record[1])
16252 {
16253 snapshot.truncated = true;
16254 continue;
16255 }
16256 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16257 snapshot.truncated = true;
16258 continue;
16259 };
16260 let previous_path = match previous_path_bytes {
16261 Some(previous) => {
16262 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16263 snapshot.truncated = true;
16264 continue;
16265 };
16266 Some(previous)
16267 }
16268 None => None,
16269 };
16270 snapshot.status.push(GitStatusEntry {
16271 index_status: (record[0] as char).to_string(),
16272 worktree_status: (record[1] as char).to_string(),
16273 path,
16274 previous_path,
16275 });
16276 }
16277}
16278
16279fn is_git_status_code(byte: u8) -> bool {
16280 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16281}
16282
16283fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16284 for line in String::from_utf8_lossy(output).lines() {
16285 let Some((id, summary)) = line.split_once('\u{1f}') else {
16286 continue;
16287 };
16288 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16289 snapshot.truncated = true;
16290 break;
16291 }
16292 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16293 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16294 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16295 }
16296}
16297
16298fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16299 if value.len() <= max_bytes {
16300 return value.to_owned();
16301 }
16302 *truncated = true;
16303 let mut boundary = max_bytes;
16304 while boundary > 0 && !value.is_char_boundary(boundary) {
16305 boundary -= 1;
16306 }
16307 value[..boundary].to_owned()
16308}
16309
16310fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16311 if message.is_empty() {
16312 return;
16313 }
16314 let normalized = message
16315 .chars()
16316 .map(|character| if character.is_control() { ' ' } else { character })
16317 .collect::<String>();
16318 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16319 if !diagnostic.is_empty() {
16320 diagnostic.push_str("; ");
16321 }
16322 diagnostic.push_str(normalized.trim());
16323 snapshot.diagnostic = Some(truncate_git_field(
16324 &diagnostic,
16325 GIT_DIAGNOSTIC_MAX_BYTES,
16326 &mut snapshot.truncated,
16327 ));
16328}
16329
16330async fn run_git_bounded(
16331 root: &str,
16332 arguments: &[&str],
16333 output_limit: usize,
16334) -> io::Result<GitCommandOutput> {
16335 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16336}
16337
16338async fn read_git_history_bounded(
16339 root: &str,
16340 path: Option<&RepositoryPath>,
16341 before: Option<&GitObjectId>,
16342 limit: u16,
16343) -> Result<GitHistoryPage, NodeFailure> {
16344 let path = path
16345 .map(|path| {
16346 path.as_utf8().ok_or_else(|| {
16347 failure(
16348 NodeFailureCode::InvalidRepositoryPath,
16349 "git history path must use UTF-8",
16350 )
16351 })
16352 })
16353 .transpose()?
16354 .unwrap_or(".");
16355 let count = (usize::from(limit) + 1).to_string();
16356 let start = before
16357 .map(|revision| format!("{}^", revision.as_str()))
16358 .unwrap_or_else(|| "HEAD".to_owned());
16359 let arguments = [
16360 "--no-pager",
16361 "log",
16362 "--no-decorate",
16363 "--date=iso-strict",
16364 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16365 "-n",
16366 count.as_str(),
16367 start.as_str(),
16368 "--",
16369 path,
16370 ];
16371 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16372 .await
16373 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16374 if output.timed_out {
16375 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16376 }
16377 if !output.success {
16378 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16379 }
16380 let mut commits = parse_git_history(&output.stdout);
16381 let has_more = commits.len() > usize::from(limit) || output.truncated;
16382 commits.truncate(usize::from(limit));
16383 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16384 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16385}
16386
16387fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16388 String::from_utf8_lossy(output)
16389 .split('\u{1e}')
16390 .filter_map(|record| {
16391 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16392 if fields.len() != 9 {
16393 return None;
16394 }
16395 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16396 let parents = if fields[1].is_empty() {
16397 Vec::new()
16398 } else {
16399 fields[1]
16400 .split(' ')
16401 .map(|value| GitObjectId::new(value.to_owned()))
16402 .collect::<Result<Vec<_>, _>>()
16403 .ok()?
16404 };
16405 Some(GitCommitDetails {
16406 id,
16407 parents,
16408 subject: bounded_git_text(fields[2], 512),
16409 author_name: bounded_git_text(fields[3], 256),
16410 author_email: bounded_git_text(fields[4], 320),
16411 authored_at: bounded_git_text(fields[5], 64),
16412 committer_name: bounded_git_text(fields[6], 256),
16413 committer_email: bounded_git_text(fields[7], 320),
16414 committed_at: bounded_git_text(fields[8], 64),
16415 signature_status: GitSignatureStatus::NoSignature,
16416 signer: None,
16417 })
16418 })
16419 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16420 .collect()
16421}
16422
16423fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16424 let normalized = value
16425 .chars()
16426 .map(|character| if character.is_control() { ' ' } else { character })
16427 .collect::<String>();
16428 if normalized.len() <= max_bytes {
16429 return normalized;
16430 }
16431 let mut boundary = max_bytes;
16432 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16433 boundary -= 1;
16434 }
16435 normalized[..boundary].to_owned()
16436}
16437
16438async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16439 let path = request.path.as_ref().map(|path| {
16440 path.as_utf8()
16441 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16442 }).transpose()?;
16443 let mut arguments = match &request.mode {
16444 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16445 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16446 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16447 };
16448 if let GitDiffMode::Commit { revision } = &request.mode {
16449 arguments.push(revision.as_str());
16450 }
16451 arguments.push("--");
16452 if let Some(path) = path {
16453 arguments.push(path);
16454 }
16455 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16456 .await
16457 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16458 if output.timed_out {
16459 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16460 }
16461 if !output.success {
16462 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16463 }
16464 Ok(GitDiff {
16465 mode: request.mode,
16466 path: request.path,
16467 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16468 truncated: output.truncated,
16469 })
16470}
16471
16472async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16473 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16474 ResponseEnvelope { request_id: envelope.request_id, result }
16475}
16476
16477async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16478 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16479 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16480 }
16481 let read_only = matches!(
16482 &request,
16483 NodeRequest::Snapshot
16484 | NodeRequest::Resync { .. }
16485 | NodeRequest::BrowseHostDirectories { .. }
16486 | NodeRequest::InspectWorkspace { .. }
16487 | NodeRequest::ReadWorkspaceFile { .. }
16488 | NodeRequest::ReadGitHistory { .. }
16489 | NodeRequest::ReadGitDiff { .. }
16490 | NodeRequest::CatalogNativeSessions { .. }
16491 | NodeRequest::PageNativeSessions { .. }
16492 | NodeRequest::PreviewNativeSession { .. }
16493 | NodeRequest::PreviewSessionRecord { .. }
16494 );
16495 let _mutation_guard = if read_only {
16496 None
16497 } else {
16498 let guard = shared.mutation_gate.lock().await;
16499 if shared.shutdown.load(Ordering::Acquire) {
16500 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16501 }
16502 Some(guard)
16503 };
16504 match request {
16505 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16506 event_sequence: shared.current_sequence(),
16507 controller: shared.controller_state(),
16508 snapshot: shared.snapshot(),
16509 }),
16510 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16511 NodeRequest::ArmHarnessMcpReservation {
16512 reservation_id,
16513 activation_digest,
16514 spawn_spec,
16515 launch,
16516 expires_at_unix_ms,
16517 } => {
16518 shared.require_controller(connection_id, role)?;
16519 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16520 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16521 })?;
16522 let expires_at_unix_ms = registry
16523 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16524 .await
16525 .map_err(harness_mcp_failure)?;
16526 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16527 }
16528 NodeRequest::SpawnSpecWithHarnessMcp {
16529 reservation_id,
16530 activation_digest,
16531 spec,
16532 deadline_unix_ms,
16533 } => {
16534 shared.require_controller(connection_id, role)?;
16535 let receipt = shared
16536 .spawn_from_spec_with_harness_mcp(
16537 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16538 )
16539 .await?;
16540 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16541 }
16542 NodeRequest::ActivateHarnessMcpReservation {
16543 reservation_id,
16544 activation_digest,
16545 record_id,
16546 session,
16547 } => {
16548 shared.require_controller(connection_id, role)?;
16549 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16550 current.instance_id == session.session.instance_id
16551 && current.generation == session.session.generation
16552 }).and_then(|current| current.process_id).ok_or_else(|| {
16553 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16554 })?;
16555 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16556 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16557 })?.activate(
16558 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16559 ).map_err(harness_mcp_failure)?;
16560 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16561 }
16562 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16563 shared.require_controller(connection_id, role)?;
16564 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16565 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16566 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16567 if let Some(instance_id) = instance_id {
16568 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16569 }
16570 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16571 }
16572 NodeRequest::PutHarnessMcpReplyChunk {
16573 reservation_id, activation_digest, record_id, session, call_id,
16574 offset, final_chunk, chunk_hex,
16575 } => {
16576 shared.require_controller(connection_id, role)?;
16577 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16578 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16579 })?.put_reply_chunk(
16580 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16581 offset, final_chunk, &chunk_hex,
16582 ).map_err(harness_mcp_failure)?;
16583 Ok(NodeResponse::ReplyChunkAccepted {
16584 reservation_id, activation_digest, record_id, session, call_id,
16585 next_offset, completed,
16586 })
16587 }
16588 NodeRequest::RejectHarnessMcpCall {
16589 reservation_id, activation_digest, record_id, session, call_id, reason,
16590 } => {
16591 shared.require_controller(connection_id, role)?;
16592 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16593 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16594 })?.reject_call(
16595 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16596 ).map_err(harness_mcp_failure)?;
16597 Ok(NodeResponse::CallRejected {
16598 reservation_id, activation_digest, record_id, session, call_id,
16599 })
16600 }
16601 NodeRequest::BeginDeliveryStage { manifest } => {
16602 shared.require_controller(connection_id, role)?;
16603 shared.begin_delivery_stage(manifest)
16604 }
16605 NodeRequest::PutDeliveryBlobChunk {
16606 stage_id,
16607 blob_digest,
16608 offset,
16609 chunk_hex,
16610 } => {
16611 shared.require_controller(connection_id, role)?;
16612 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16613 }
16614 NodeRequest::CommitDeliveryStage { stage_id } => {
16615 shared.require_controller(connection_id, role)?;
16616 let receipt = shared.commit_delivery_stage(stage_id)?;
16617 Ok(NodeResponse::DeliveryCommitted { receipt })
16618 }
16619 NodeRequest::AbortDeliveryStage { stage_id } => {
16620 shared.require_controller(connection_id, role)?;
16621 shared.abort_delivery_stage(&stage_id)?;
16622 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16623 }
16624 NodeRequest::BrowseHostDirectories { directory, after } => {
16625 let listing = shared.browse_host_directories(directory, after).await?;
16626 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16627 }
16628 NodeRequest::InspectWorkspace { workspace_id } => {
16629 let inspection = shared.inspect_workspace(workspace_id).await?;
16630 Ok(NodeResponse::WorkspaceInspected { inspection })
16631 }
16632 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16633 let file = shared.read_workspace_file(workspace_id, path).await?;
16634 Ok(NodeResponse::WorkspaceFileRead { file })
16635 }
16636 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16637 shared.require_controller(connection_id, role)?;
16638 let file = shared
16639 .write_workspace_file(workspace_id, path, expected_revision, text)
16640 .await?;
16641 Ok(NodeResponse::WorkspaceFileWritten { file })
16642 }
16643 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16644 shared.require_controller(connection_id, role)?;
16645 let file = shared.create_workspace_file(workspace_id, path).await?;
16646 Ok(NodeResponse::WorkspaceFileCreated { file })
16647 }
16648 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16649 shared.require_controller(connection_id, role)?;
16650 let entry = shared
16651 .create_workspace_directory(workspace_id.clone(), path)
16652 .await?;
16653 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16654 }
16655 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16656 if role != ClientRole::Operator {
16657 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16658 }
16659 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16660 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16661 }
16662 let page = shared
16663 .read_git_history(workspace_id.clone(), path, before, limit)
16664 .await?;
16665 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16666 }
16667 NodeRequest::ReadGitDiff { workspace_id, request } => {
16668 if role != ClientRole::Operator {
16669 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16670 }
16671 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16672 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16673 }
16674 NodeRequest::CatalogNativeSessions { route, limit } => {
16675 if role != ClientRole::Operator {
16676 return Err(failure(
16677 NodeFailureCode::ObserverReadOnly,
16678 "native session catalog requires an operator connection",
16679 ));
16680 }
16681 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16682 return Err(failure(
16683 NodeFailureCode::InvalidRequest,
16684 "native session catalog limit is invalid",
16685 ));
16686 }
16687 let (entries, summary) = shared
16688 .catalog_native_sessions(route.clone(), limit)
16689 .await?;
16690 Ok(NodeResponse::NativeSessionsCataloged {
16691 route,
16692 entries,
16693 summary: Some(summary),
16694 })
16695 }
16696 NodeRequest::PageNativeSessions {
16697 route,
16698 window,
16699 catalog_revision,
16700 recent_cutoff_unix_ms,
16701 after_selection_id,
16702 limit,
16703 } => {
16704 if role != ClientRole::Operator {
16705 return Err(failure(
16706 NodeFailureCode::ObserverReadOnly,
16707 "native session catalog paging requires an operator connection",
16708 ));
16709 }
16710 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16711 || after_selection_id
16712 .as_deref()
16713 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16714 {
16715 return Err(failure(
16716 NodeFailureCode::InvalidRequest,
16717 "native session catalog page request is invalid",
16718 ));
16719 }
16720 let page = shared
16721 .page_native_sessions(
16722 route.clone(),
16723 window,
16724 catalog_revision,
16725 recent_cutoff_unix_ms,
16726 after_selection_id,
16727 limit,
16728 )
16729 .await?;
16730 Ok(NodeResponse::NativeSessionsPaged {
16731 route,
16732 page,
16733 })
16734 }
16735 NodeRequest::PreviewNativeSession {
16736 selection,
16737 message_limit,
16738 } => {
16739 if role != ClientRole::Operator {
16740 return Err(failure(
16741 NodeFailureCode::ObserverReadOnly,
16742 "native session preview requires an operator connection",
16743 ));
16744 }
16745 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16746 .contains(&message_limit)
16747 || selection.validate().is_err()
16748 {
16749 return Err(failure(
16750 NodeFailureCode::InvalidRequest,
16751 "native session preview request is invalid",
16752 ));
16753 }
16754 let preview = shared
16755 .preview_native_session(selection.clone(), message_limit)
16756 .await?;
16757 Ok(NodeResponse::NativeSessionPreviewed {
16758 selection,
16759 preview,
16760 })
16761 }
16762 NodeRequest::IndexNativeSession {
16763 selection,
16764 display_name,
16765 } => {
16766 shared.require_controller(connection_id, role)?;
16767 let record = shared
16768 .index_native_session(selection.clone(), display_name)
16769 .await?;
16770 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16771 }
16772 NodeRequest::PreviewSessionRecord {
16773 record_id,
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 {
16785 return Err(failure(
16786 NodeFailureCode::InvalidRequest,
16787 "native session preview request is invalid",
16788 ));
16789 }
16790 let record = shared.record(&record_id)?;
16791 let working_directory = shared.workspace_root(&record.workspace_id)?;
16792 if !platform::roots_equal(
16793 &working_directory,
16794 windows_path_text(&record.canonical_root),
16795 ) {
16796 return Err(failure(
16797 NodeFailureCode::SessionRecordConflict,
16798 "managed session workspace root changed; refusing to preview another directory",
16799 ));
16800 }
16801 let identity = record.provider_session.clone().ok_or_else(|| {
16802 failure(
16803 NodeFailureCode::BackendOperationFailed,
16804 "native session preview is unavailable for this session record",
16805 )
16806 })?;
16807 if identity.key != ProviderSessionKey::SessionId {
16808 return Err(failure(
16809 NodeFailureCode::BackendOperationFailed,
16810 "native session preview is unavailable for this session identity",
16811 ));
16812 }
16813 let preview = shared
16814 .preview_native_session_by_session_id(
16815 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16816 )
16817 .await?;
16818 if preview.session_id != identity.id {
16819 return Err(failure(
16820 NodeFailureCode::SessionRecordConflict,
16821 "native session preview identity changed while loading",
16822 ));
16823 }
16824 shared.revalidate_session_record_preview(&record, &identity)?;
16825 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16826 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16827 record_id: record_id.clone(),
16828 summary: SessionHistorySummaryV1::from(&preview),
16829 });
16830 Ok(NodeResponse::SessionRecordPreviewed {
16831 record_id,
16832 preview,
16833 })
16834 }
16835 NodeRequest::AcquireController { lease_ms } => {
16836 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16837 Ok(NodeResponse::Controller { controller: Some(controller) })
16838 }
16839 NodeRequest::ReleaseController => {
16840 shared.release_controller(connection_id);
16841 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16842 }
16843 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16844 shared.require_controller(connection_id, role)?;
16845 let root = require_windows_path(root)?;
16846 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16847 let workspace = shared.register_workspace(workspace_id, root).await?;
16848 Ok(NodeResponse::WorkspaceRegistered { workspace })
16849 }
16850 NodeRequest::CreateStandaloneWorkspace {
16851 workspace_id,
16852 root,
16853 initial_branch,
16854 } => {
16855 shared.require_controller(connection_id, role)?;
16856 let root = require_windows_path(root)?;
16857 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16858 let workspace = shared
16859 .create_standalone_workspace(workspace_id, root, initial_branch)
16860 .await?;
16861 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16862 }
16863 NodeRequest::UnregisterWorkspace { workspace_id } => {
16864 shared.require_controller(connection_id, role)?;
16865 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16866 shared.unregister_workspace(&workspace_id)?;
16867 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16868 }
16869 NodeRequest::CreateWorktree {
16870 source_workspace_id,
16871 workspace_id,
16872 target_root,
16873 branch,
16874 base,
16875 } => {
16876 shared.require_controller(connection_id, role)?;
16877 let target_root = require_windows_path(target_root)?;
16878 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16879 let (worktree, workspace) = shared
16880 .create_worktree(
16881 source_workspace_id,
16882 workspace_id,
16883 target_root,
16884 branch,
16885 base,
16886 )
16887 .await?;
16888 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16889 }
16890 NodeRequest::RemoveWorktree {
16891 source_workspace_id,
16892 target_root,
16893 } => {
16894 shared.require_controller(connection_id, role)?;
16895 let native_target_root = require_windows_path(target_root.clone())?;
16896 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16897 let workspace_id = shared
16898 .remove_worktree(source_workspace_id, native_target_root)
16899 .await?;
16900 Ok(NodeResponse::WorktreeRemoved {
16901 target_root,
16902 workspace_id,
16903 })
16904 }
16905 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16906 shared.require_controller(connection_id, role)?;
16907 if !terminal_size.is_valid() {
16908 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16909 }
16910 if let Some(prompt) = initial_prompt.as_deref() {
16911 validate_node_text("spawn initial prompt", prompt)?;
16912 }
16913 match shared
16914 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16915 .await
16916 {
16917 Ok(session) => {
16918 shared.mark_spawn_provisional(session.clone());
16919 Ok(NodeResponse::SpawnAccepted { session })
16920 }
16921 Err(err) => {
16922 tracing::warn!(
16923 %provider,
16924 %workspace_id,
16925 ?mode,
16926 code = ?err.code,
16927 message = %err.message,
16928 "session spawn refused"
16929 );
16930 Err(err)
16931 }
16932 }
16933 }
16934 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16935 shared.require_controller(connection_id, role)?;
16936 if !terminal_size.is_valid() {
16937 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16938 }
16939 if let Some(prompt) = initial_prompt.as_deref() {
16940 validate_node_text("spawn initial prompt", prompt)?;
16941 }
16942 let plan = backend_paths::plan_launch_options(
16943 provider.as_str(),
16944 mode,
16945 options.model.is_some(),
16946 options.effort.is_some(),
16947 )
16948 .map_err(|r| {
16949 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16950 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16951 })?;
16952 let selection = spawn_selection(&options)?;
16953 let pty_selection = match plan {
16954 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16955 _ => None,
16956 };
16957 let spawned = SPAWN_SESSION_OPTIONS
16958 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16959 .await;
16960 match spawned {
16961 Ok(session) => {
16962 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16963 shared
16964 .pending_acp_options
16965 .lock()
16966 .unwrap_or_else(|p| p.into_inner())
16967 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16968 }
16969 shared.mark_spawn_provisional(session.clone());
16970 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16971 Ok(NodeResponse::SpawnAccepted { session })
16972 }
16973 Err(err) => {
16974 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16975 Err(err)
16976 }
16977 }
16978 }
16979 NodeRequest::SpawnSpec { spec } => {
16980 shared.require_controller(connection_id, role)?;
16981 let receipt = shared.spawn_from_spec(spec).await?;
16982 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16983 }
16984 NodeRequest::SpawnManagedWorktree { request } => {
16985 shared.require_controller(connection_id, role)?;
16986 let receipt = shared.spawn_managed_worktree(request).await?;
16987 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16988 }
16989 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16990 shared.require_controller(connection_id, role)?;
16991 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16992 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16993 }
16994 NodeRequest::CleanupManagedWorktree { lease_id } => {
16995 shared.require_controller(connection_id, role)?;
16996 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16997 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16998 }
16999 NodeRequest::DiscoverHistory { session, limit } => {
17000 controlled_session(shared, connection_id, role, &session)?;
17001 let candidates = shared.discover_history(&session, limit).await?;
17002 Ok(NodeResponse::HistoryDiscovered {
17003 session,
17004 candidates,
17005 })
17006 }
17007 NodeRequest::LoadHistory {
17008 session,
17009 candidate_id,
17010 } => {
17011 controlled_session(shared, connection_id, role, &session)?;
17012 let loaded = shared
17013 .load_history(&session, candidate_id)
17014 .await?;
17015 Ok(NodeResponse::HistoryLoaded {
17016 session,
17017 session_id: loaded.session_id,
17018 message_count: loaded.message_count,
17019 completed_turn_count: loaded.completed_turn_count,
17020 })
17021 }
17022 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17023 shared.require_controller(connection_id, role)?;
17024 let context = shared
17025 .export_context_pack_for_session_record(&record_id, &session, false)
17026 .await?;
17027 Ok(NodeResponse::ContextPackForSessionRecordExported {
17028 record_id,
17029 session,
17030 context,
17031 })
17032 }
17033 NodeRequest::ExportContextPack { session } => {
17034 controlled_session(shared, connection_id, role, &session)?;
17035 let context = shared.export_context_pack(&session).await?;
17036 Ok(NodeResponse::ContextPackExported { context })
17037 }
17038 NodeRequest::ForgetContextPack { context_id } => {
17039 shared.require_controller(connection_id, role)?;
17040 shared.forget_context_pack(&context_id)?;
17041 Ok(NodeResponse::ContextPackForgotten { context_id })
17042 }
17043 NodeRequest::ResolveDurableContextPack { context_id } => {
17044 let context = shared
17048 .context_catalog
17049 .read()
17050 .unwrap_or_else(|poisoned| poisoned.into_inner())
17051 .get(&context_id)
17052 .map(|pack| pack.receipt().clone())
17053 .ok_or_else(|| {
17054 failure(
17055 NodeFailureCode::UnknownContextPack,
17056 "context pack is not present",
17057 )
17058 })?;
17059 Ok(NodeResponse::DurableContextPackResolved { context })
17060 }
17061 NodeRequest::ReadContextPack { digest } => {
17062 let (id, byte_len, bytes) = shared
17067 .context_catalog
17068 .read()
17069 .unwrap_or_else(|poisoned| poisoned.into_inner())
17070 .get_by_digest(&digest)
17071 .map(|pack| {
17072 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17073 })
17074 .ok_or_else(|| {
17075 failure(
17076 NodeFailureCode::UnknownContextPack,
17077 &format!("context pack {digest} not on this node"),
17078 )
17079 })?;
17080 tracing::debug!(
17081 digest = digest.as_str(),
17082 byte_len,
17083 "read context pack bytes",
17084 );
17085 Ok(NodeResponse::ContextPackBytesRead {
17086 pack: ContextPackBytesRead {
17087 digest,
17088 id,
17089 byte_len,
17090 bytes,
17091 },
17092 })
17093 }
17094 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17095 let provider = controlled_session(shared, connection_id, role, &session)?;
17096 if let Some(prompt) = initial_prompt.as_deref() {
17097 validate_node_text("resume initial prompt", prompt)?;
17098 }
17099 let runtime_requirement = if initial_prompt.is_some() {
17100 ProviderRuntimeRequirement::ResumeWithPrompt
17101 } else {
17102 ProviderRuntimeRequirement::Resume
17103 };
17104 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17105 let runtime_policy = shared
17106 .admit_provider_runtime(&provider, runtime_requirement)
17107 .await?;
17108 let working_directory = shared.bound_workspace_root(&session)?;
17109 let request = ResumeLaunchRequest {
17110 working_directory,
17111 terminal_size,
17112 initial_prompt,
17113 };
17114 request
17115 .validate()
17116 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17117 let command = shared.prepare_command(ControlCommand::Resume {
17118 instance_id: session.session.instance_id,
17119 target: ResumeTarget::CurrentProvider,
17120 runtime_policy,
17121 request,
17122 });
17123 shared.arm_resume(&session, command.id, runtime_policy)?;
17124 let dispatch = shared.dispatch_envelope(command);
17125 if let Err(error) = dispatch {
17126 shared.clear_armed_resume(&session);
17127 return Err(error);
17128 }
17129 Ok(NodeResponse::Accepted)
17130 }
17131 NodeRequest::RenameSessionRecord {
17132 record_id,
17133 display_name,
17134 } => {
17135 shared.require_controller(connection_id, role)?;
17136 let record = shared.rename_session_record(&record_id, display_name)?;
17137 Ok(NodeResponse::SessionRecordUpdated { record })
17138 }
17139 NodeRequest::SetSessionTask {
17140 record_id,
17141 expected_revision,
17142 target,
17143 } => {
17144 shared.require_controller(connection_id, role)?;
17145 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17146 Ok(NodeResponse::SessionRecordUpdated { record })
17147 }
17148 NodeRequest::IndexProviderSession {
17149 workspace_id,
17150 provider,
17151 identity,
17152 display_name,
17153 } => {
17154 shared.require_controller(connection_id, role)?;
17155 let record = shared.index_provider_session(
17156 workspace_id,
17157 provider,
17158 identity,
17159 display_name,
17160 )?;
17161 Ok(NodeResponse::ProviderSessionIndexed { record })
17162 }
17163 NodeRequest::ResumeSessionRecord {
17164 record_id,
17165 terminal_size,
17166 initial_prompt,
17167 } => {
17168 shared.require_controller(connection_id, role)?;
17169 if let Some(prompt) = initial_prompt.as_deref() {
17170 validate_node_text("managed resume initial prompt", prompt)?;
17171 }
17172 let (record, session) = shared
17173 .resume_session_record(
17174 &record_id,
17175 terminal_size,
17176 initial_prompt,
17177 )
17178 .await?;
17179 Ok(NodeResponse::SessionRecordResumed { record, session })
17180 }
17181 NodeRequest::ForgetSessionRecord { record_id } => {
17182 shared.require_controller(connection_id, role)?;
17183 shared.forget_session_record(&record_id).await?;
17184 Ok(NodeResponse::SessionRecordForgotten { record_id })
17185 }
17186 NodeRequest::Prompt { session, text } => {
17187 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17188 validate_node_text("prompt", &text)?;
17189 shared.require_session_runtime_policy(
17190 &session,
17191 ProviderRuntimeRequirement::SemanticPrompt,
17192 )?;
17193 let framing = prompt_framing(&agent_id);
17194 shared
17195 .dispatch_input_bounded(
17196 &session,
17197 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17198 )
17199 .await?;
17200 Ok(NodeResponse::Accepted)
17201 }
17202 NodeRequest::Paste { session, text } => {
17203 controlled_session(shared, connection_id, role, &session)?;
17204 validate_node_text("paste", &text)?;
17205 shared.require_session_runtime_policy(
17206 &session,
17207 ProviderRuntimeRequirement::SemanticPrompt,
17208 )?;
17209 shared
17210 .dispatch_input_bounded(
17211 &session,
17212 InputAction::InsertDraft(PromptPayload {
17213 text,
17214 framing: PromptFraming::BracketedPaste,
17215 }),
17216 )
17217 .await?;
17218 Ok(NodeResponse::Accepted)
17219 }
17220 NodeRequest::Input { session, text } => {
17221 controlled_session(shared, connection_id, role, &session)?;
17222 validate_node_text("terminal input", &text)?;
17223 shared
17224 .dispatch_input_bounded(
17225 &session,
17226 InputAction::TerminalText(TerminalText { text }),
17227 )
17228 .await?;
17229 Ok(NodeResponse::Accepted)
17230 }
17231 NodeRequest::TerminalBytes { session, bytes } => {
17232 controlled_session(shared, connection_id, role, &session)?;
17233 shared.sessionize_spawn(session.session.instance_id);
17234 let action = terminal_bytes_action(bytes)?;
17235 shared
17236 .dispatch_input_bounded(&session, action)
17237 .await?;
17238 Ok(NodeResponse::Accepted)
17239 }
17240 NodeRequest::TerminalControl { session, control } => {
17241 controlled_session(shared, connection_id, role, &session)?;
17242 shared
17243 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17244 .await?;
17245 Ok(NodeResponse::Accepted)
17246 }
17247 NodeRequest::Resize { session, size } => {
17248 controlled_session(shared, connection_id, role, &session)?;
17249 shared.sessionize_spawn(session.session.instance_id);
17250 if !size.is_valid() {
17251 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17252 }
17253 shared.dispatch_resize_bounded(&session, size).await?;
17254 Ok(NodeResponse::Accepted)
17255 }
17256 NodeRequest::Interrupt { session } => {
17257 controlled_session(shared, connection_id, role, &session)?;
17258 shared
17259 .dispatch_input_bounded(
17260 &session,
17261 InputAction::TerminalControl(TerminalControl::Interrupt),
17262 )
17263 .await?;
17264 Ok(NodeResponse::Accepted)
17265 }
17266 NodeRequest::Stop { session, force } => {
17267 controlled_session(shared, connection_id, role, &session)?;
17268 shared.sessionize_spawn(session.session.instance_id);
17269 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17270 shared.retire_session_grok_leader(session.session.instance_id);
17271 Ok(NodeResponse::Accepted)
17272 }
17273 NodeRequest::Remove { session } => {
17274 controlled_session(shared, connection_id, role, &session)?;
17275 shared.remove_session(&session).await?;
17276 Ok(NodeResponse::Accepted)
17277 }
17278 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17279 shared.require_controller(connection_id, role)?;
17280 let snapshot = shared.require_acp_session(&session)?;
17281 let interaction = snapshot
17282 .provider
17283 .interactions
17284 .iter()
17285 .find(|interaction| {
17286 interaction_correlation(
17287 session.session.instance_id,
17288 session.session.generation,
17289 interaction.id.0,
17290 ) == correlation_id
17291 })
17292 .ok_or_else(|| failure(
17293 NodeFailureCode::InvalidRequest,
17294 "unknown interaction correlation",
17295 ))?;
17296 if interaction.status != ProviderInteractionStatus::Pending {
17297 return Err(failure(
17298 NodeFailureCode::BackendOperationFailed,
17299 "interaction has already been resolved",
17300 ));
17301 }
17302 response
17303 .validate_for(interaction.interaction_kind)
17304 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17305 let interaction_id = interaction.id;
17306 let response_kind = response.kind();
17307 let response_outcome = response.outcome();
17308 shared.dispatch(ControlCommand::ResolveInteraction {
17309 instance_id: session.session.instance_id,
17310 generation: session.session.generation,
17311 interaction_id,
17312 response,
17313 })?;
17314 tracing::info!(
17320 node_id = %shared.node_id,
17321 session = ?session.session,
17322 correlation_id = %correlation_id,
17323 interaction_id = interaction_id.0,
17324 response_kind = ?response_kind,
17325 outcome = ?response_outcome,
17326 "interaction resolved",
17327 );
17328 Ok(NodeResponse::Accepted)
17329 }
17330 NodeRequest::SetSessionMode { session, mode_id } => {
17331 shared.require_controller(connection_id, role)?;
17332 shared.require_acp_session(&session)?;
17333 shared.dispatch(ControlCommand::SetSessionMode {
17334 instance_id: session.session.instance_id,
17335 mode_id,
17336 })?;
17337 Ok(NodeResponse::Accepted)
17338 }
17339 NodeRequest::SetSessionConfigOption {
17340 session,
17341 option_id,
17342 value_json,
17343 } => {
17344 shared.require_controller(connection_id, role)?;
17345 shared.require_acp_session(&session)?;
17346 shared.dispatch(ControlCommand::SetSessionConfigOption {
17347 instance_id: session.session.instance_id,
17348 option_id,
17349 value_json,
17350 })?;
17351 Ok(NodeResponse::Accepted)
17352 }
17353 NodeRequest::SetSessionModel { session, model_id } => {
17354 shared.require_controller(connection_id, role)?;
17355 shared.require_acp_session(&session)?;
17356 shared.dispatch(ControlCommand::SetSessionModel {
17357 instance_id: session.session.instance_id,
17358 model_id,
17359 })?;
17360 Ok(NodeResponse::Accepted)
17361 }
17362 NodeRequest::Shutdown => {
17363 shared.require_controller(connection_id, role)?;
17364 shared.begin_shutdown_locked().await?;
17365 Ok(NodeResponse::ShuttingDown)
17366 }
17367 }
17368}
17369
17370fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17371 shared.require_controller(connection_id, role)?;
17372 shared.validate_address(session)
17373}
17374
17375fn select_history_candidate(
17381 candidates: &[HistoryCandidateSummary],
17382 provider_session_id: Option<&str>,
17383) -> Result<String, NodeFailure> {
17384 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17385 Some(id) => candidate.session_id_hint == id,
17386 None => true,
17387 });
17388 let candidate = matches.next().ok_or_else(|| {
17389 failure(
17390 NodeFailureCode::SessionRecordConflict,
17391 if provider_session_id.is_some() {
17392 "no history candidate matches the managed provider session"
17393 } else {
17394 "no history candidate exists for the managed session"
17395 },
17396 )
17397 })?;
17398 if matches.next().is_some() {
17399 return Err(failure(
17400 NodeFailureCode::SessionRecordConflict,
17401 if provider_session_id.is_some() {
17402 "history identity is ambiguous for the managed provider session"
17403 } else {
17404 "history is ambiguous without a verified provider session identity"
17405 },
17406 ));
17407 }
17408 Ok(candidate.id.clone())
17409}
17410
17411fn resolved_spawn_profile_summary(
17412 profile: &SpawnProfileDefaults,
17413 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17414) -> Option<SpawnProfileSummary> {
17415 let environment_profile = match profile.environment_profile_id.as_ref() {
17416 None => None,
17417 Some(profile_id) => {
17418 let binding = environment_profiles.get(profile_id)?;
17419 if binding.provider != profile.provider
17420 || binding.native_profile_id(profile.mode).is_none()
17421 {
17422 return None;
17423 }
17424 Some(ResolvedEnvironmentProfileReceipt {
17425 profile_id: binding.id.clone(),
17426 profile_revision: binding.revision.clone(),
17427 network_allowlist: None,
17428 browser_profile_id: None,
17429 })
17430 }
17431 };
17432 Some(SpawnProfileSummary {
17433 id: profile.profile_id.clone(),
17434 revision: profile.revision.clone(),
17435 environment_profile,
17436 })
17437}
17438
17439fn session_record_export_target_matches(
17458 expected: &ManagedSessionRecord,
17459 current: &ManagedSessionRecord,
17460 session: &SessionAddress,
17461 allow_clean_detachment: bool,
17462) -> bool {
17463 if current == expected && current.active_session.as_ref() == Some(session) {
17464 return current.state == ManagedSessionState::Live;
17465 }
17466 allow_clean_detachment
17467 && current.state == ManagedSessionState::Dormant
17468 && current.active_session.is_none()
17469 && current.record_id == expected.record_id
17470 && current.display_name == expected.display_name
17471 && current.provider == expected.provider
17472 && current.mode == expected.mode
17473 && current.workspace_id == expected.workspace_id
17474 && current.canonical_root == expected.canonical_root
17475 && current.provider_session == expected.provider_session
17476 && current.environment_profile == expected.environment_profile
17477 && current.bundle == expected.bundle
17478 && current.context_id == expected.context_id
17479 && current.context == expected.context
17480 && current.exported_context == expected.exported_context
17481 && current.task_binding == expected.task_binding
17482 && current.created_at_unix_ms == expected.created_at_unix_ms
17483 && current.last_error == expected.last_error
17484}
17485
17486fn session_record_context_export_binding_is_exact(
17487 expected: &ManagedSessionRecord,
17488 current: &ManagedSessionRecord,
17489 identity: Option<&ProviderSessionIdentity>,
17490 session: &SessionAddress,
17491 binding: &SessionBinding,
17492 provider: &AgentId,
17493 current_root: &str,
17494 allow_clean_detachment: bool,
17495) -> bool {
17496 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17497 && current.provider_session.as_ref() == identity
17498 && current.workspace_id == session.workspace_id
17499 && provider == ¤t.provider
17500 && binding.workspace_id == session.workspace_id
17501 && binding.generation == session.session.generation
17502 && binding.record_id.as_ref() == Some(¤t.record_id)
17503 && platform::roots_equal(
17504 current_root,
17505 windows_path_text(¤t.canonical_root),
17506 )
17507}
17508
17509fn session_record_context_export_source_is_usable(
17510 record: &ManagedSessionRecord,
17511 status: &SessionStatus,
17512 allow_clean_detachment: bool,
17513) -> bool {
17514 (record.state == ManagedSessionState::Live
17515 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17516 && context_pack_source_status_is_usable(status)
17517}
17518
17519fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17520 PromptFraming::BracketedPaste
17521}
17522
17523fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17524 if text.len() > MAX_NODE_TEXT_BYTES {
17525 return Err(failure(
17526 NodeFailureCode::InvalidRequest,
17527 &format!(
17528 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17529 text.len(),
17530 ),
17531 ));
17532 }
17533 Ok(())
17534}
17535
17536fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17537 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17538 return Err(failure(
17539 NodeFailureCode::InvalidRequest,
17540 &format!(
17541 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17542 bytes.len(),
17543 ),
17544 ));
17545 }
17546 Ok(())
17547}
17548
17549fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17550 validate_terminal_bytes(&bytes)?;
17551 Ok(InputAction::TerminalBytes(bytes))
17552}
17553
17554fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17555 if root.is_empty()
17556 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17557 || root.chars().any(char::is_control)
17558 {
17559 return Err(failure(
17560 NodeFailureCode::InvalidWorkspaceRoot,
17561 &format!(
17562 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17563 ),
17564 ));
17565 }
17566 Ok(())
17567}
17568
17569fn canonical_standalone_workspace_candidate(
17570 workspace_id: &WorkspaceId,
17571 root: &str,
17572) -> Result<String, NodeServerError> {
17573 let path = Path::new(root);
17574 match std::fs::symlink_metadata(path) {
17575 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17576 .map(|workspace| workspace.canonical_root),
17577 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17578 if !path.is_absolute() {
17579 return Err(NodeServerError::InvalidWorkspaceRoot {
17580 workspace_id: workspace_id.clone(),
17581 path: root.to_owned(),
17582 message: "path is not absolute".to_owned(),
17583 });
17584 }
17585 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17586 workspace_id: workspace_id.clone(),
17587 path: root.to_owned(),
17588 message: "path has no standalone directory name".to_owned(),
17589 })?;
17590 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17591 workspace_id: workspace_id.clone(),
17592 path: root.to_owned(),
17593 message: "path has no parent directory".to_owned(),
17594 })?;
17595 let canonical = std::fs::canonicalize(parent)
17596 .map(|parent| parent.join(name))
17597 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17598 workspace_id: workspace_id.clone(),
17599 path: root.to_owned(),
17600 message: error.to_string(),
17601 })?;
17602 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17603 NodeServerError::InvalidWorkspaceRoot {
17604 workspace_id: workspace_id.clone(),
17605 path: path.to_string_lossy().into_owned(),
17606 message: "canonical path is not valid Unicode".to_owned(),
17607 }
17608 })?;
17609 let canonical_root = platform::normalize_canonical_root(canonical_root);
17610 validate_workspace_root(workspace_id, &canonical_root)?;
17611 Ok(canonical_root)
17612 }
17613 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17614 workspace_id: workspace_id.clone(),
17615 path: root.to_owned(),
17616 message: error.to_string(),
17617 }),
17618 }
17619}
17620
17621fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17622 let Some(revision) = revision else {
17623 return Ok(());
17624 };
17625 if revision.is_empty()
17626 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17627 || revision.chars().any(char::is_control)
17628 {
17629 return Err(failure(
17630 NodeFailureCode::InvalidRequest,
17631 &format!(
17632 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17633 ),
17634 ));
17635 }
17636 Ok(())
17637}
17638
17639fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17640 let code = match error.kind {
17641 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17642 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17643 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17644 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17645 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17646 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17647 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17648 };
17649 failure(code, &error.message)
17650}
17651
17652fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17653 let code = match error.kind {
17654 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17655 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17656 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17657 NodeFailureCode::BackendOperationFailed
17658 }
17659 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17660 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17661 }
17662 };
17663 failure(code, error.message)
17664}
17665
17666fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17667 let (code, message) = match error.kind {
17668 GitWorktreeErrorKind::Invalid => (
17669 NodeFailureCode::InvalidRequest,
17670 "managed Git input is invalid",
17671 ),
17672 GitWorktreeErrorKind::NotRepository => (
17673 NodeFailureCode::NotGitRepository,
17674 "managed source is not a Git repository",
17675 ),
17676 GitWorktreeErrorKind::Conflict => (
17677 NodeFailureCode::WorktreeConflict,
17678 "managed Git identity conflicts with existing repository state",
17679 ),
17680 GitWorktreeErrorKind::Protected => (
17681 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17682 "managed worktree ownership validation failed",
17683 ),
17684 GitWorktreeErrorKind::Dirty => (
17685 NodeFailureCode::WorktreeDirty,
17686 "managed worktree contains uncommitted changes",
17687 ),
17688 GitWorktreeErrorKind::Locked => (
17689 NodeFailureCode::WorktreeLocked,
17690 "managed worktree is locked",
17691 ),
17692 GitWorktreeErrorKind::Failed => (
17693 NodeFailureCode::BackendOperationFailed,
17694 "managed Git operation failed",
17695 ),
17696 };
17697 NodeFailure { code, message: message.to_owned() }
17698}
17699
17700fn managed_spawn_receipt(
17701 resolved: &ResolvedSpawnSpec,
17702 incarnation_id: NodeIncarnationId,
17703 session: SessionAddress,
17704 lease: ManagedWorktreeLeaseSnapshot,
17705 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17706 bundle: Option<ResolvedBundleReceipt>,
17707 context: Option<ResolvedContextPackReceipt>,
17708) -> ManagedWorktreeSpawnReceipt {
17709 let mut managed = resolved.clone();
17710 managed.target.worktree_id = Some(lease.workspace_id.clone());
17711 ManagedWorktreeSpawnReceipt {
17712 spawn: managed.receipt_with_materialization(
17713 incarnation_id,
17714 session,
17715 environment_profile,
17716 bundle,
17717 context,
17718 ),
17719 lease,
17720 }
17721}
17722
17723fn validate_selected_worktree(
17724 source_root: &str,
17725 selected_root: &str,
17726 worktrees: &[NativeGitWorktreeSnapshot],
17727) -> Result<(), NodeFailure> {
17728 if !worktrees
17729 .iter()
17730 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17731 {
17732 return Err(failure(
17733 NodeFailureCode::WorktreeProtected,
17734 "source workspace is not an authoritative non-bare Git worktree root",
17735 ));
17736 }
17737 if !worktrees.iter().any(|worktree| {
17738 worktree_paths_equal(&worktree.path, selected_root)
17739 && !worktree.is_main
17740 && !worktree.is_bare
17741 }) {
17742 return Err(failure(
17743 NodeFailureCode::WorktreeProtected,
17744 "selected workspace is not an eligible Git worktree in the source repository",
17745 ));
17746 }
17747 Ok(())
17748}
17749
17750fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17751 let code = match error.kind() {
17752 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17753 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17754 NodeFailureCode::RepositoryPathUnsafe
17755 }
17756 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17757 WorkspaceFileReadErrorKind::NotRegularFile => {
17758 NodeFailureCode::RepositoryFileNotRegular
17759 }
17760 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17761 NodeFailureCode::RepositoryFileReadFailed
17762 }
17763 WorkspaceFileReadErrorKind::RevisionConflict => {
17764 NodeFailureCode::RepositoryFileRevisionConflict
17765 }
17766 WorkspaceFileReadErrorKind::AlreadyExists
17767 | WorkspaceFileReadErrorKind::ParentNotFound
17768 | WorkspaceFileReadErrorKind::ParentNotDirectory
17769 | WorkspaceFileReadErrorKind::Canceled => {
17770 NodeFailureCode::RepositoryFileReadFailed
17771 }
17772 };
17773 failure(code, "repository file read failed")
17774}
17775
17776fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17777 let code = match error.kind() {
17778 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17779 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17780 NodeFailureCode::RepositoryPathUnsafe
17781 }
17782 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17783 WorkspaceFileReadErrorKind::NotRegularFile => {
17784 NodeFailureCode::RepositoryFileNotRegular
17785 }
17786 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17787 NodeFailureCode::RepositoryFileWriteFailed
17788 }
17789 WorkspaceFileReadErrorKind::RevisionConflict => {
17790 NodeFailureCode::RepositoryFileRevisionConflict
17791 }
17792 WorkspaceFileReadErrorKind::AlreadyExists
17793 | WorkspaceFileReadErrorKind::ParentNotFound
17794 | WorkspaceFileReadErrorKind::ParentNotDirectory
17795 | WorkspaceFileReadErrorKind::Canceled => {
17796 NodeFailureCode::RepositoryFileWriteFailed
17797 }
17798 };
17799 failure(code, "repository file write failed")
17800}
17801
17802async fn settle_workspace_entry_create<T>(
17803 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17804 commit_state: Arc<AtomicU8>,
17805 deadline: Duration,
17806 timeout_message: &'static str,
17807 task_failure_message: &'static str,
17808) -> Result<T, NodeFailure> {
17809 let joined = match timeout(deadline, &mut task).await {
17810 Ok(joined) => joined,
17811 Err(_) => {
17812 if commit_state
17813 .compare_exchange(
17814 WORKSPACE_ENTRY_CREATE_PENDING,
17815 WORKSPACE_ENTRY_CREATE_CANCELED,
17816 Ordering::AcqRel,
17817 Ordering::Acquire,
17818 )
17819 .is_ok()
17820 {
17821 return Err(failure(
17822 NodeFailureCode::RepositoryEntryCreateTimedOut,
17823 timeout_message,
17824 ));
17825 }
17826 debug_assert_eq!(
17827 commit_state.load(Ordering::Acquire),
17828 WORKSPACE_ENTRY_CREATE_COMMITTING,
17829 );
17830 task.await
17831 }
17832 };
17833 joined
17834 .map_err(|_| {
17835 failure(
17836 NodeFailureCode::RepositoryEntryCreateFailed,
17837 task_failure_message,
17838 )
17839 })?
17840 .map_err(workspace_entry_create_failure)
17841}
17842
17843fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17844 let code = match error.kind() {
17845 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17846 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17847 NodeFailureCode::RepositoryPathUnsafe
17848 }
17849 WorkspaceFileReadErrorKind::AlreadyExists => {
17850 NodeFailureCode::RepositoryEntryAlreadyExists
17851 }
17852 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17853 NodeFailureCode::RepositoryParentNotFound
17854 }
17855 WorkspaceFileReadErrorKind::ParentNotDirectory
17856 | WorkspaceFileReadErrorKind::NotRegularFile => {
17857 NodeFailureCode::RepositoryParentNotDirectory
17858 }
17859 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17860 NodeFailureCode::RepositoryEntryCreateFailed
17861 }
17862 WorkspaceFileReadErrorKind::RevisionConflict => {
17863 NodeFailureCode::RepositoryEntryCreateFailed
17864 }
17865 WorkspaceFileReadErrorKind::Canceled => {
17866 NodeFailureCode::RepositoryEntryCreateTimedOut
17867 }
17868 };
17869 failure(code, "repository entry create failed")
17870}
17871
17872fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17873 let value = digest(&SHA256, bytes)
17874 .as_ref()
17875 .iter()
17876 .map(|byte| format!("{byte:02x}"))
17877 .collect();
17878 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17879}
17880
17881fn managed_spawn_request_digest_v2(
17882 request: &ManagedWorktreeSpawnRequestV2,
17883) -> Result<String, NodeFailure> {
17884 let encoded = serde_json::to_vec(request).map_err(|_| {
17885 failure(
17886 NodeFailureCode::InvalidRequest,
17887 "managed worktree V2 request could not be canonicalized",
17888 )
17889 })?;
17890 let mut value = String::with_capacity(71);
17891 value.push_str("sha256:");
17892 for byte in digest(&SHA256, &encoded).as_ref() {
17893 use std::fmt::Write as _;
17894 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17895 }
17896 Ok(value)
17897}
17898
17899fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17900 let code = match error {
17901 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17902 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17903 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17904 NativeSessionPreviewError::UnsupportedProvider
17905 | NativeSessionPreviewError::PreviewUnavailable
17906 | NativeSessionPreviewError::SessionNotFound
17907 | NativeSessionPreviewError::AmbiguousSession => {
17908 NodeFailureCode::BackendOperationFailed
17909 }
17910 };
17911 failure(code, "native session preview failed")
17912}
17913
17914fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17915 let code = match error.kind() {
17916 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17917 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17918 };
17919 failure(code, "host directory browse failed")
17920}
17921
17922fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17923 let code = match error {
17924 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17925 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17926 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17927 NodeFailureCode::DeliveryStageConflict
17928 }
17929 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17930 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17931 NodeFailureCode::DeliveryChunkOutOfOrder
17932 }
17933 DeliveryStoreError::BlobDigestMismatch => {
17934 NodeFailureCode::DeliveryBlobDigestMismatch
17935 }
17936 DeliveryStoreError::BundleDigestMismatch => {
17937 NodeFailureCode::DeliveryBundleDigestMismatch
17938 }
17939 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17940 DeliveryStoreError::Unavailable
17941 | DeliveryStoreError::Corrupt
17942 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17943 };
17944 failure(code, "delivery operation failed")
17945}
17946
17947fn validate_workspace_root(
17948 workspace_id: &WorkspaceId,
17949 canonical_root: &str,
17950) -> Result<(), NodeServerError> {
17951 #[cfg(windows)]
17952 if canonical_root.starts_with(r"\\") {
17953 return Err(NodeServerError::InvalidWorkspaceRoot {
17954 workspace_id: workspace_id.clone(),
17955 path: canonical_root.to_owned(),
17956 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17957 });
17958 }
17959 if !platform::workspace_root_supported(canonical_root)
17960 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17961 {
17962 return Err(NodeServerError::InvalidWorkspaceRoot {
17963 workspace_id: workspace_id.clone(),
17964 path: canonical_root.to_owned(),
17965 message: "canonical path must be absolute, bounded, and free of control characters"
17966 .to_owned(),
17967 });
17968 }
17969 Ok(())
17970}
17971
17972fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17973 NodeFailure {
17974 code,
17975 message: node_failure_category(code).to_owned(),
17976 }
17977}
17978
17979fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17980 let code = match error {
17981 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17982 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17983 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17984 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17985 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17986 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17987 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17988 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17989 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17990 };
17991 failure(code, "harness MCP proxy operation failed")
17992}
17993
17994static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17998
17999fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18006 let trace = launch.trace.as_ref()?;
18007 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18008 if trace_dir.is_empty() {
18009 return None;
18010 }
18011 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18012 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18013 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18014}
18015
18016fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18017 let remaining = deadline.saturating_duration_since(Instant::now());
18018 if remaining.is_zero() {
18019 Err(failure(
18020 NodeFailureCode::SpawnDeadlineExceeded,
18021 "spawn deadline elapsed",
18022 ))
18023 } else {
18024 Ok(remaining)
18025 }
18026}
18027
18028fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18029 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18030 match deadline {
18031 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18032 None => Ok(bounded),
18033 }
18034}
18035
18036fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18037 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18038 failure(
18039 NodeFailureCode::SpawnDeadlineExceeded,
18040 "spawn dispatch exceeded the spawn deadline",
18041 )
18042 } else {
18043 error
18044 }
18045}
18046
18047fn spawn_runtime_capabilities(
18048 capabilities: &SpawnRequiredCapabilities,
18049) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18050 capabilities
18051 .iter()
18052 .map(|capability| match capability.as_str() {
18053 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18054 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18055 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18056 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18057 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18058 }
18059 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18060 _ => Err(failure(
18061 NodeFailureCode::UnsupportedSpawnCapability,
18062 "spawn required capability is unknown",
18063 )),
18064 })
18065 .collect()
18066}
18067
18068fn persistence_failure(_error: io::Error) -> NodeFailure {
18069 NodeFailure {
18070 code: NodeFailureCode::BackendOperationFailed,
18071 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18072 }
18073}
18074
18075fn node_failure_category(code: NodeFailureCode) -> &'static str {
18076 match code {
18077 NodeFailureCode::InvalidRequest => "invalid-request",
18078 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18079 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18080 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18081 NodeFailureCode::ReservationConflict => "reservation-conflict",
18082 NodeFailureCode::ReservationExpired => "reservation-expired",
18083 NodeFailureCode::BindingMismatch => "binding-mismatch",
18084 NodeFailureCode::NotActivated => "not-activated",
18085 NodeFailureCode::CallNotFound => "call-not-found",
18086 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18087 NodeFailureCode::ResponseTooLarge => "response-too-large",
18088 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18089 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18090 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18091 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18092 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18093 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18094 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18095 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18096 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18097 NodeFailureCode::Unauthorized => "unauthorized",
18098 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18099 NodeFailureCode::ControllerBusy => "controller-busy",
18100 NodeFailureCode::ControllerRequired => "controller-required",
18101 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18102 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18103 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18104 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18105 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18106 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18107 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18108 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18109 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18110 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18111 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18112 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18113 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18114 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18115 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18116 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18117 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18118 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18119 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18120 NodeFailureCode::GitReadFailed => "git-read-failed",
18121 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18122 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18123 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18124 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18125 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18126 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18127 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18128 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18129 NodeFailureCode::TurnInFlight => "turn-in-flight",
18130 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18131 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18132 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18133 "unsupported-network-allowlist-mapping"
18134 }
18135 NodeFailureCode::BrowserStationProbeUnavailable => {
18136 "browser-station-probe-unavailable"
18137 }
18138 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18139 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18140 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18141 "environment-profile-binding-mismatch"
18142 }
18143 NodeFailureCode::UnknownBundle => "unknown-bundle",
18144 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18145 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18146 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18147 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18148 NodeFailureCode::ContextPackMaterializationFailed => {
18149 "context-pack-materialization-failed"
18150 }
18151 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18152 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18153 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18154 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18155 NodeFailureCode::LastWorkspace => "last-workspace",
18156 NodeFailureCode::NotGitRepository => "not-git-repository",
18157 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18158 NodeFailureCode::WorktreeProtected => "worktree-protected",
18159 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18160 NodeFailureCode::WorktreeLocked => "worktree-locked",
18161 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18162 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18163 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18164 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18165 "managed-worktree-profile-revision-mismatch"
18166 }
18167 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18168 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18169 "standalone-workspace-recovery-required"
18170 }
18171 NodeFailureCode::UnknownSession => "unknown-session",
18172 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18173 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18174 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18175 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18176 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18177 NodeFailureCode::WorkspaceRegistrationRequired => {
18178 "workspace-registration-required"
18179 }
18180 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18181 NodeFailureCode::StaleGeneration => "stale-generation",
18182 NodeFailureCode::BackendBusy => "backend-busy",
18183 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18184 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18185 NodeFailureCode::ShuttingDown => "shutting-down",
18186 }
18187}
18188
18189fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18190 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18191}
18192
18193fn durable_state_load_error(error: io::Error) -> NodeServerError {
18194 let category = match session_registry::state_load_refusal(&error) {
18195 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18196 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18197 }
18198 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18199 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18200 }
18201 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18202 DURABLE_STATE_CONFLICT_ERROR
18203 }
18204 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18205 };
18206 durable_state_server_error(error, category)
18207}
18208
18209fn unix_time_ms() -> u64 {
18210 std::time::SystemTime::now()
18211 .duration_since(std::time::UNIX_EPOCH)
18212 .unwrap_or_default()
18213 .as_millis()
18214 .min(u64::MAX as u128) as u64
18215}
18216
18217fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18218 if record.provider_session.is_some() {
18219 ManagedSessionState::Dormant
18220 } else {
18221 ManagedSessionState::Unavailable
18222 }
18223}
18224
18225fn active_record_state(
18226 identity_admitted: bool,
18227 provider_session_is_known: bool,
18228) -> ManagedSessionState {
18229 if identity_admitted && !provider_session_is_known {
18230 ManagedSessionState::IdentityPending
18231 } else {
18232 ManagedSessionState::Live
18233 }
18234}
18235
18236fn managed_worktree_record_holders(
18237 records: &[ManagedSessionRecord],
18238) -> Vec<(SessionRecordId, WorkspaceId)> {
18239 records
18240 .iter()
18241 .filter(|record| record.provider_session.is_some())
18242 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18243 .collect()
18244}
18245
18246fn managed_spawn_record_ownership(
18247 binding: &SessionBinding,
18248 effective_runtime_policy: ProviderRuntimePolicy,
18249) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18250 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18251 if effective_runtime_policy.provider_session_identity {
18252 (binding.record_id.clone(), None)
18253 } else {
18254 (None, binding.record_id.clone())
18255 }
18256}
18257
18258#[derive(Debug, Error)]
18259pub enum NodeServerError {
18260 #[error("node endpoint must be a bounded absolute local transport path")]
18261 InvalidEndpoint,
18262 #[error("node access token must contain 1..=4096 bytes")]
18263 InvalidAccessToken,
18264 #[error("node HTTP observer must listen on a loopback address: {0}")]
18265 InvalidApiListen(std::net::SocketAddr),
18266 #[error("node browser bridge must listen on a loopback address: {0}")]
18267 InvalidBridgeListen(std::net::SocketAddr),
18268 #[error("node browser bridge token must contain 1..=4096 bytes")]
18269 InvalidBridgeToken,
18270 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18271 BridgeUnderlayRequiresBridgeListen,
18272 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18273 BridgeUnderlayRequiresBridgeToken,
18274 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18275 InvalidBridgeUnderlayToken,
18276 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18277 InvalidBridgeUnderlayKey,
18278 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18279 InvalidCallHome(std::net::SocketAddr),
18280 #[error("node could not reach its call-home relay: {0}")]
18281 CallHomeTransport(#[source] std::io::Error),
18282 #[error("node durable state path must be an absolute file path: {0}")]
18283 InvalidStatePath(String),
18284 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18285 InvalidHarnessMcpHelper,
18286 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18287 InvalidNetworkAllowlistCatalogPath(String),
18288 #[error("network allowlist catalog could not be read: {0}")]
18289 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18290 #[error("network allowlist catalog line {line}: {message}")]
18291 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18292 #[error("network allowlist catalog: {0}")]
18293 InvalidNetworkAllowlistCatalog(String),
18294 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18295 NetworkAllowlistCatalogCapacity { max: usize },
18296 #[error("the local state directory is unavailable; supply an explicit state path")]
18297 LocalStateDirectoryUnavailable,
18298 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18299 LocalRuntimeDirectoryUnavailable,
18300 #[error("node durable state failed: {0}")]
18301 DurableState(io::Error),
18302 #[error("node requires at least one configured workspace")]
18303 NoWorkspaces,
18304 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18305 InvalidWorkspaceRoot {
18306 workspace_id: WorkspaceId,
18307 path: String,
18308 message: String,
18309 },
18310 #[error("duplicate workspace ID: {0}")]
18311 DuplicateWorkspaceId(WorkspaceId),
18312 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18313 DuplicateWorkspaceRoot {
18314 first: WorkspaceId,
18315 second: WorkspaceId,
18316 root: String,
18317 },
18318 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18319 ManagedWorktreeProfileRequired(WorkspaceId),
18320 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18321 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18322 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18323 DuplicateManagedWorktreeProfile {
18324 workspace_id: WorkspaceId,
18325 profile_id: WorktreeProfileId,
18326 },
18327 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18328 ManagedWorktreeProfileCapacity {
18329 workspace_id: WorkspaceId,
18330 max: usize,
18331 },
18332 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18333 InvalidManagedWorktreeProfile {
18334 workspace_id: WorkspaceId,
18335 profile_id: WorktreeProfileId,
18336 message: String,
18337 },
18338 #[error("duplicate node environment profile: {0}")]
18339 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18340 #[error("node environment profile provider is unavailable: {0}")]
18341 EnvironmentProfileProviderUnavailable(AgentId),
18342 #[error("node environment profile capacity is {max}")]
18343 EnvironmentProfileCapacity { max: usize },
18344 #[error("node environment profiles reuse native profile ID '{0}'")]
18345 DuplicateNativeEnvironmentProfile(String),
18346 #[error("node native environment profile is invalid: {0}")]
18347 NativeEnvironmentProfile(String),
18348 #[error("node session-environment materialization root must be absolute")]
18349 InvalidSessionEnvironmentRoot,
18350 #[error("node session-environment materializer is unavailable")]
18351 SessionEnvironmentMaterializerRequired,
18352 #[error("node session-environment materializer failed to initialize")]
18353 SessionEnvironmentMaterializer,
18354 #[error("node bundle catalog is invalid: {0}")]
18355 BundleCatalog(String),
18356 #[error("node delivery store failed to initialize: {0}")]
18357 DeliveryStore(String),
18358 #[error("node context pack store failed to initialize")]
18359 ContextPackStore,
18360 #[error("active agent registry failed: {0}")]
18361 Registry(String),
18362 #[error("node provider contract manifest is invalid: {0}")]
18363 ProviderContractManifest(String),
18364 #[error("named pipe I/O failed: {0}")]
18365 Io(#[from] io::Error),
18366 #[error("node HTTP observer failed: {0}")]
18367 HttpApi(io::Error),
18368 #[error("node browser bridge failed: {0}")]
18369 BridgeApi(io::Error),
18370 #[error(transparent)]
18371 Frame(#[from] FrameError),
18372 #[error("node handshake failed: {0}")]
18373 Handshake(String),
18374 #[error("node authentication frame was not received before the bounded deadline")]
18375 AuthenticationTimedOut,
18376 #[error("node authentication primitive failed: {0}")]
18377 Authentication(String),
18378 #[error("node incarnation identity generation failed: {0}")]
18379 IncarnationIdentity(String),
18380 #[error("node pre-authentication limiter is closed")]
18381 PreauthClosed,
18382 #[error("node authenticated connection limit was reached")]
18383 AuthenticatedConnectionLimit,
18384 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18385 SignalStreamClosed,
18386 #[error("node stop-all dispatch failed: {0:?}")]
18387 ShutdownDispatch(NodeFailure),
18388 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18389 ShutdownTimedOut { active_native_sessions: usize },
18390}
18391
18392impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18393 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18394 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18395 match error {
18396 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18397 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18398 E::InvalidEntry { line, message } => {
18399 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18400 }
18401 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18402 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18403 }
18404 }
18405}
18406
18407#[cfg(test)]
18408mod agent_stream_blocked_tests {
18409 use super::*;
18410 use gate4agent_types::{
18411 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18412 ProviderSource,
18413 };
18414
18415 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18416 ProviderSource {
18417 family,
18418 binding: AdapterBinding::new(
18419 AdapterId::new(adapter).unwrap(),
18420 "fixture/v1",
18421 AdapterVerification::SyntheticFixture,
18422 )
18423 .unwrap(),
18424 }
18425 }
18426
18427 fn provider_control_event_at(
18428 family: AdapterFamily,
18429 adapter: &str,
18430 source_sequence: u64,
18431 provider_event: ProviderEvent,
18432 ) -> ControlEvent {
18433 ControlEvent {
18434 sequence: 41,
18435 command_id: None,
18436 instance_id: AgentInstanceId(7),
18437 generation: SessionGeneration(3),
18438 event: ControlEventKind::ProviderEvent {
18439 sequence: 8,
18440 source: provider_source(family, adapter),
18441 source_sequence,
18442 event: provider_event,
18443 },
18444 }
18445 }
18446
18447 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18448 provider_control_event_at(family, "fixture", 9, provider_event)
18449 }
18450
18451 fn denied_host_request(
18452 by: ProviderHostDecisionAuthority,
18453 reason: Option<String>,
18454 ) -> ControlEvent {
18455 provider_control_event_at(
18456 AdapterFamily::Acp,
18457 "claude-code",
18458 2,
18459 ProviderEvent::HostRequestObserved {
18460 method: "terminal/create".to_owned(),
18461 params_json: String::new(),
18462 decision: ProviderHostRequestDecision::Denied { by },
18463 outcome: ProviderHostRequestOutcome::Executed,
18464 reason,
18465 },
18466 )
18467 }
18468
18469 fn blocked(
18470 event: &ControlEvent,
18471 ) -> (
18472 Option<String>,
18473 String,
18474 BlockAuthorityV1,
18475 Option<String>,
18476 String,
18477 Option<String>,
18478 ) {
18479 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18480 chunk.validate().expect("minted chunk must validate");
18481 let AgentStreamChunkKindV1::Blocked {
18482 correlation_id,
18483 tool_class,
18484 authority,
18485 reason_kind,
18486 reason,
18487 help,
18488 } = chunk.kind
18489 else {
18490 panic!("expected AgentStreamChunkKindV1::Blocked");
18491 };
18492 (correlation_id, tool_class, authority, reason_kind, reason, help)
18493 }
18494
18495 #[test]
18499 fn the_enabled_provider_matrix_is_exact() {
18500 let registry = active_registry().unwrap();
18501 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18502 assert_eq!(
18503 providers
18504 .iter()
18505 .map(|contract| contract.provider.as_str())
18506 .collect::<Vec<_>>(),
18507 vec!["shell", "claude", "codex", "grok", "kimi"],
18509 );
18510 assert!(adapters.iter().all(|contract| {
18511 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18512 }));
18513 }
18514
18515 #[test]
18518 fn gate_denied_host_request_mints_a_blocked_chunk() {
18519 let gate_text =
18520 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18521 let event = denied_host_request(
18522 ProviderHostDecisionAuthority::Gate,
18523 Some(gate_text.to_owned()),
18524 );
18525 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18526 assert_eq!(chunk.source_sequence, 2);
18527 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18528 assert_eq!(authority, BlockAuthorityV1::HostGate);
18529 assert_eq!(reason, gate_text);
18530 assert_eq!(correlation_id, None);
18531 assert_eq!(reason_kind, None);
18532 assert_eq!(help, None);
18533 assert_eq!(tool_class, "Write");
18534 }
18535
18536 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.";
18537
18538 #[test]
18544 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18545 let event = provider_control_event(
18546 AdapterFamily::Acp,
18547 ProviderEvent::ToolCompleted {
18548 id: "toolu_9".to_owned(),
18549 output: CLASSIFIER_SAMPLE.to_owned(),
18550 is_error: true,
18551 duration_ms: None,
18552 agent_id: None,
18553 non_execution_kind: Some("permission-rule".to_owned()),
18554 },
18555 );
18556 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18557 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18558 assert_eq!(reason, "Blocked by classifier");
18559 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18560 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18561 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18562 }
18563
18564 #[test]
18569 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18570 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.";
18571 let event = provider_control_event(
18572 AdapterFamily::Acp,
18573 ProviderEvent::SessionEnded {
18574 result: "JSON-RPC error -32603: Internal error".to_owned(),
18575 cost_usd: None,
18576 is_error: true,
18577 stop_reason: Some(ProviderStopReason::ProviderError {
18578 code: -32603,
18579 message: message.to_owned(),
18580 vendor_code: Some("usageLimitExceeded".to_owned()),
18581 }),
18582 },
18583 );
18584 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18585 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18586 assert_eq!(reason, message);
18587 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18588 assert_eq!(tool_class, "Session");
18589 }
18590
18591 #[test]
18594 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18595 let event = provider_control_event(
18596 AdapterFamily::Acp,
18597 ProviderEvent::SessionEnded {
18598 result: "refusal".to_owned(),
18599 cost_usd: None,
18600 is_error: true,
18601 stop_reason: Some(ProviderStopReason::Refusal),
18602 },
18603 );
18604 let (_, _, authority, _, _, _) = blocked(&event);
18605 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18606 }
18607
18608 #[test]
18612 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18613 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18614 let event = provider_control_event(
18615 AdapterFamily::Acp,
18616 ProviderEvent::ToolCompleted {
18617 id: "toolu_1".to_owned(),
18618 output: "command exited with status 1".to_owned(),
18619 is_error: true,
18620 duration_ms: Some(12),
18621 agent_id: None,
18622 non_execution_kind,
18623 },
18624 );
18625 assert!(agent_stream_chunk(&event).is_none());
18626 }
18627 let narrative = provider_control_event(
18628 AdapterFamily::Acp,
18629 ProviderEvent::ToolCompleted {
18630 id: "toolu_2".to_owned(),
18631 output: "the harness blocked the write, approval needed".to_owned(),
18632 is_error: false,
18633 duration_ms: Some(3),
18634 agent_id: None,
18635 non_execution_kind: None,
18636 },
18637 );
18638 assert!(agent_stream_chunk(&narrative).is_none());
18639 }
18640
18641 #[test]
18645 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18646 for (decision, outcome) in [
18647 (
18648 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18649 ProviderHostRequestOutcome::Executed,
18650 ),
18651 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18652 (
18653 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18654 ProviderHostRequestOutcome::Failed {
18655 error: "terminal/create spawn failed: os error 3".to_owned(),
18656 },
18657 ),
18658 ] {
18659 let event = provider_control_event_at(
18660 AdapterFamily::Acp,
18661 "claude-code",
18662 3,
18663 ProviderEvent::HostRequestObserved {
18664 method: "fs/read_text_file".to_owned(),
18665 params_json: String::new(),
18666 decision: decision.clone(),
18667 outcome,
18668 reason: None,
18669 },
18670 );
18671 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18672 }
18673 }
18674
18675 #[test]
18679 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18680 for (by, expected_authority, expected_reason) in [
18681 (
18682 ProviderHostDecisionAuthority::Operator,
18683 BlockAuthorityV1::Operator,
18684 "declined by the operator",
18685 ),
18686 (
18687 ProviderHostDecisionAuthority::DeadlinePolicy,
18688 BlockAuthorityV1::HostDeadline,
18689 "denied by host policy after the operator deadline expired",
18690 ),
18691 ] {
18692 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18693 assert_eq!(authority, expected_authority);
18694 assert_eq!(reason, expected_reason);
18695 }
18696 }
18697
18698 #[test]
18702 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18703 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18704 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18705 ProviderHostDecisionAuthority::Policy,
18706 Some(oversized),
18707 ));
18708 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18709 }
18710}
18711
18712#[cfg(test)]
18713mod standalone_capability_platform_tests {
18714 use super::*;
18715
18716 #[test]
18717 fn standalone_workspace_capability_requires_windows_identity_guards() {
18718 let capabilities = baseline_capabilities().unwrap();
18719 let advertised = capabilities.iter().any(|capability| {
18720 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18721 });
18722 assert_eq!(advertised, cfg!(windows));
18723
18724 let request = NodeRequest::CreateStandaloneWorkspace {
18725 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18726 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18727 initial_branch: None,
18728 };
18729 assert_eq!(
18730 request_uses_unnegotiated_capability(&request, &capabilities),
18731 !cfg!(windows),
18732 );
18733 }
18734}
18735
18736
18737#[cfg(test)]
18738mod station_knobs_resolve_tests {
18739 use super::*;
18740 use gate4agent_catalog::EnvMutation;
18741 use gate4agent_runtime_native::{
18742 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18743 NativeLaunchProfileId,
18744 };
18745 use std::ffi::OsString;
18746 use std::sync::Arc;
18747
18748 struct EmptyEnvironmentResolver;
18749
18750 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18751 fn resolve_child_environment(
18752 &self,
18753 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18754 Ok(vec![EnvMutation {
18755 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18756 value: None,
18757 }])
18758 }
18759 }
18760
18761 fn agent(value: &str) -> AgentId {
18762 AgentId::new(value).unwrap()
18763 }
18764
18765 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18766 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18767 let profile_revision =
18768 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18769 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18770 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18771 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18772 provider: agent("claude"),
18773 mode: SessionMode::Pty,
18774 terminal_size: gate4agent_types::TerminalSize {
18775 rows: 24,
18776 columns: 80,
18777 },
18778 prompt: None,
18779 bundle_id: None,
18780 context_id: None,
18781 environment_profile_id: Some(profile_id.clone()),
18782 };
18783 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18784 let catalog = active_registry().unwrap();
18785 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18786 let control = runtime.native_launch_profile_control();
18787 let node_profile = NodeEnvironmentProfile::new(
18788 profile_id.clone(),
18789 profile_revision,
18790 agent("claude"),
18791 [NativeLaunchProfile::new(
18792 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18793 agent("claude"),
18794 TransportKind::Pty,
18795 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18796 Arc::new(EmptyEnvironmentResolver),
18797 )
18798 .unwrap()],
18799 )
18800 .unwrap();
18801 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18802 for native_profile in native_profiles {
18803 runtime.upsert_native_launch_profile(native_profile).unwrap();
18804 }
18805 let workspace = WorkspaceConfig::new(
18806 WorkspaceId::new("primary").unwrap(),
18807 std::env::current_dir().unwrap(),
18808 )
18809 .unwrap();
18810 let shared = NodeShared::new_with_incarnation(
18811 handle,
18812 "fixture-token".to_owned(),
18813 NodeId::new("node-station-knobs-test").unwrap(),
18814 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18815 vec![workspace],
18816 vec![agent("claude")],
18817 ProviderRuntimeStatuses::default(),
18818 None,
18819 Vec::new(),
18820 Vec::new(),
18821 spawn_profiles,
18822 Some(control),
18823 None,
18824 None,
18825 None,
18826 Vec::new(),
18827 Vec::new(),
18828 Vec::new(),
18829 Vec::new(),
18830 None,
18831 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18832 );
18833 shared
18834 .environment_profiles
18835 .write()
18836 .unwrap_or_else(|poisoned| poisoned.into_inner())
18837 .insert(binding.id.clone(), binding);
18838 (shared, spawn_defaults)
18839 }
18840
18841 #[test]
18844 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18845 let (shared, defaults) = station_knobs_fixture();
18846 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18847 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18848 let spec = crate::protocol::SpawnSpec {
18849 target: crate::protocol::SpawnTarget {
18850 node_id: shared.node_id.clone(),
18851 workspace_id: WorkspaceId::new("primary").unwrap(),
18852 worktree_id: None,
18853 },
18854 profile_id: defaults.profile_id.clone(),
18855 expected_profile_revision: defaults.revision.clone(),
18856 overrides: crate::protocol::SpawnOverrides {
18857 network_allowlist: Some(unknown),
18858 browser_profile_id: Some(browser),
18859 ..crate::protocol::SpawnOverrides::default()
18860 },
18861 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18862 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18863 "station-knobs-unknown-allowlist",
18864 )
18865 .unwrap(),
18866 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18867 };
18868 let resolved = spec.resolve(&defaults).unwrap();
18869 let failure = shared
18870 .resolve_environment_profile(&resolved)
18871 .expect_err("unknown allowlist must refuse");
18872 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18873 }
18874
18875 #[test]
18881 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18882 let (shared, defaults) = station_knobs_fixture();
18883 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18884 #[cfg(not(feature = "dig2-station-probe"))]
18885 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18886 shared
18887 .network_allowlist_catalog
18888 .write()
18889 .unwrap_or_else(|poisoned| poisoned.into_inner())
18890 .insert_id(allowlist.clone())
18891 .unwrap();
18892 let spec = crate::protocol::SpawnSpec {
18893 target: crate::protocol::SpawnTarget {
18894 node_id: shared.node_id.clone(),
18895 workspace_id: WorkspaceId::new("primary").unwrap(),
18896 worktree_id: None,
18897 },
18898 profile_id: defaults.profile_id.clone(),
18899 expected_profile_revision: defaults.revision.clone(),
18900 overrides: crate::protocol::SpawnOverrides {
18901 network_allowlist: Some(allowlist.clone()),
18902 #[cfg(not(feature = "dig2-station-probe"))]
18903 browser_profile_id: Some(browser.clone()),
18904 ..crate::protocol::SpawnOverrides::default()
18905 },
18906 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18907 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18908 .unwrap(),
18909 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18910 };
18911 let resolved = spec.resolve(&defaults).unwrap();
18912 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18913 #[cfg(not(feature = "dig2-station-probe"))]
18914 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18915 let receipt = shared
18916 .resolve_environment_profile(&resolved)
18917 .expect("resolve ok")
18918 .expect("env profile present");
18919 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18920 #[cfg(not(feature = "dig2-station-probe"))]
18921 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18922 #[cfg(feature = "dig2-station-probe")]
18923 assert_eq!(receipt.browser_profile_id, None);
18924 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18925
18926 let omitted = crate::protocol::SpawnSpec {
18927 overrides: crate::protocol::SpawnOverrides::default(),
18928 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18929 .unwrap(),
18930 ..spec
18931 };
18932 let resolved_omit = omitted.resolve(&defaults).unwrap();
18933 let bare = shared
18934 .resolve_environment_profile(&resolved_omit)
18935 .expect("resolve ok")
18936 .expect("env profile present");
18937 assert_eq!(bare.network_allowlist, None);
18938 assert_eq!(bare.browser_profile_id, None);
18939 }
18940
18941 #[cfg(feature = "dig2-station-probe")]
18946 #[test]
18947 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18948 let (shared, defaults) = station_knobs_fixture();
18949 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18950 let spec = crate::protocol::SpawnSpec {
18951 target: crate::protocol::SpawnTarget {
18952 node_id: shared.node_id.clone(),
18953 workspace_id: WorkspaceId::new("primary").unwrap(),
18954 worktree_id: None,
18955 },
18956 profile_id: defaults.profile_id.clone(),
18957 expected_profile_revision: defaults.revision.clone(),
18958 overrides: crate::protocol::SpawnOverrides {
18959 browser_profile_id: Some(browser),
18960 ..crate::protocol::SpawnOverrides::default()
18961 },
18962 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18963 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18964 "station-knobs-probe-refuse",
18965 )
18966 .unwrap(),
18967 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18968 };
18969 let resolved = spec.resolve(&defaults).unwrap();
18970 let failure = shared
18971 .resolve_environment_profile(&resolved)
18972 .expect_err("browser_profile with failing probe must refuse");
18973 #[cfg(windows)]
18974 {
18975 assert!(
18976 matches!(
18977 failure.code,
18978 NodeFailureCode::BrowserStationUnreachable
18979 | NodeFailureCode::BrowserStationProbeUnavailable
18980 ),
18981 "unexpected code {:?}",
18982 failure.code
18983 );
18984 }
18985 #[cfg(not(windows))]
18986 {
18987 assert_eq!(
18988 failure.code,
18989 NodeFailureCode::BrowserStationProbeUnavailable
18990 );
18991 }
18992 }
18993
18994 #[cfg(feature = "dig2-station-probe")]
18996 #[test]
18997 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18998 let (shared, defaults) = station_knobs_fixture();
18999 let spec = crate::protocol::SpawnSpec {
19000 target: crate::protocol::SpawnTarget {
19001 node_id: shared.node_id.clone(),
19002 workspace_id: WorkspaceId::new("primary").unwrap(),
19003 worktree_id: None,
19004 },
19005 profile_id: defaults.profile_id.clone(),
19006 expected_profile_revision: defaults.revision.clone(),
19007 overrides: crate::protocol::SpawnOverrides::default(),
19008 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19009 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19010 "station-knobs-probe-omit",
19011 )
19012 .unwrap(),
19013 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19014 };
19015 let resolved = spec.resolve(&defaults).unwrap();
19016 let receipt = shared
19017 .resolve_environment_profile(&resolved)
19018 .expect("omit browser_profile must not probe")
19019 .expect("env profile present");
19020 assert_eq!(receipt.browser_profile_id, None);
19021 }
19022
19023 #[cfg(feature = "dig2-station-probe")]
19027 #[test]
19028 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19029 let (shared, _defaults) = station_knobs_fixture();
19030 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19031 let workspace_id = WorkspaceId::new("primary").unwrap();
19032 let address_a = SessionAddress {
19033 workspace_id: workspace_id.clone(),
19034 session: SessionKey {
19035 instance_id: AgentInstanceId(101),
19036 generation: SessionGeneration(1),
19037 },
19038 };
19039 let address_b = SessionAddress {
19040 workspace_id: workspace_id.clone(),
19041 session: SessionKey {
19042 instance_id: AgentInstanceId(102),
19043 generation: SessionGeneration(1),
19044 },
19045 };
19046 let guard_a = crate::dig2_station_lease::acquire_lease(
19047 &shared.browser_station_leases,
19048 profile.clone(),
19049 address_a.session.instance_id,
19050 1,
19051 )
19052 .expect("first lease");
19053 let busy = crate::dig2_station_lease::acquire_lease(
19054 &shared.browser_station_leases,
19055 profile.clone(),
19056 address_b.session.instance_id,
19057 2,
19058 )
19059 .expect_err("second holder must refuse");
19060 assert!(matches!(
19061 busy,
19062 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19063 ));
19064 shared.bind_session_with_policy(
19066 &address_a,
19067 ProviderRuntimePolicy::raw_pty(),
19068 None,
19069 );
19070 guard_a.retain();
19071 assert!(shared.remove_binding(&address_a).is_some());
19072 crate::dig2_station_lease::acquire_lease(
19073 &shared.browser_station_leases,
19074 profile,
19075 address_b.session.instance_id,
19076 3,
19077 )
19078 .expect("reacquire after clear")
19079 .retain();
19080 assert!(
19081 crate::dig2_station_lease::release_lease_for_holder(
19082 &shared.browser_station_leases,
19083 address_b.session.instance_id,
19084 )
19085 );
19086 }
19087
19088 #[cfg(not(feature = "dig2-station-probe"))]
19091 #[test]
19092 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19093 let (shared, defaults) = station_knobs_fixture();
19094 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19095 let spec = crate::protocol::SpawnSpec {
19096 target: crate::protocol::SpawnTarget {
19097 node_id: shared.node_id.clone(),
19098 workspace_id: WorkspaceId::new("primary").unwrap(),
19099 worktree_id: None,
19100 },
19101 profile_id: defaults.profile_id.clone(),
19102 expected_profile_revision: defaults.revision.clone(),
19103 overrides: crate::protocol::SpawnOverrides {
19104 browser_profile_id: Some(browser.clone()),
19105 ..crate::protocol::SpawnOverrides::default()
19106 },
19107 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19108 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19109 .unwrap(),
19110 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19111 };
19112 let resolved = spec.resolve(&defaults).unwrap();
19113 let receipt = shared
19114 .resolve_environment_profile(&resolved)
19115 .expect("stub resolve")
19116 .expect("env profile");
19117 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19118 }
19119
19120
19121 #[test]
19124 fn network_allowlist_catalog_loads_from_temp_file() {
19125 let unique = std::time::SystemTime::now()
19126 .duration_since(std::time::UNIX_EPOCH)
19127 .unwrap()
19128 .as_nanos();
19129 let path = std::env::temp_dir().join(format!(
19130 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19131 std::process::id(),
19132 ));
19133 std::fs::write(
19134 &path,
19135 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19136 )
19137 .unwrap();
19138 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19139 .expect("temp catalog file must load");
19140 let _ = std::fs::remove_file(&path);
19141 assert_eq!(loaded.len(), 2);
19142 let (shared, defaults) = station_knobs_fixture();
19143 {
19144 let mut catalog = shared
19145 .network_allowlist_catalog
19146 .write()
19147 .unwrap_or_else(|poisoned| poisoned.into_inner());
19148 *catalog = loaded;
19149 }
19150 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19151 let spec = crate::protocol::SpawnSpec {
19152 target: crate::protocol::SpawnTarget {
19153 node_id: shared.node_id.clone(),
19154 workspace_id: WorkspaceId::new("primary").unwrap(),
19155 worktree_id: None,
19156 },
19157 profile_id: defaults.profile_id.clone(),
19158 expected_profile_revision: defaults.revision.clone(),
19159 overrides: crate::protocol::SpawnOverrides {
19160 network_allowlist: Some(allowlist.clone()),
19161 ..crate::protocol::SpawnOverrides::default()
19162 },
19163 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19164 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19165 "station-knobs-catalog-file",
19166 )
19167 .unwrap(),
19168 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19169 };
19170 let resolved = spec.resolve(&defaults).unwrap();
19171 let receipt = shared
19172 .resolve_environment_profile(&resolved)
19173 .expect("registered via file load")
19174 .expect("env profile present");
19175 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19176 }
19177
19178 #[test]
19181 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19182 let (shared, _defaults) = station_knobs_fixture();
19183 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19184 shared
19185 .network_allowlist_catalog
19186 .write()
19187 .unwrap_or_else(|poisoned| poisoned.into_inner())
19188 .insert_id(allowlist.clone())
19189 .unwrap();
19190 let snapshot = shared.snapshot();
19191 let inventory = snapshot
19192 .launch_inventory
19193 .expect("launch inventory present");
19194 let ids = inventory
19195 .network_allowlists
19196 .expect("non-empty catalog surfaces ids");
19197 assert_eq!(ids, vec![allowlist]);
19198 }
19199
19200 #[test]
19204 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19205 let (shared, defaults) = station_knobs_fixture();
19206 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19207 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19208 id: allowlist.clone(),
19209 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19210 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19211 peer: "127.0.0.1:443".to_owned(),
19212 }],
19213 provider_native: Some(
19214 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19215 codex_network_access: Some(true),
19216 },
19217 ),
19218 };
19219 shared
19220 .network_allowlist_catalog
19221 .write()
19222 .unwrap_or_else(|poisoned| poisoned.into_inner())
19223 .insert(entry)
19224 .unwrap();
19225 let spec = crate::protocol::SpawnSpec {
19226 target: crate::protocol::SpawnTarget {
19227 node_id: shared.node_id.clone(),
19228 workspace_id: WorkspaceId::new("primary").unwrap(),
19229 worktree_id: None,
19230 },
19231 profile_id: defaults.profile_id.clone(),
19232 expected_profile_revision: defaults.revision.clone(),
19233 overrides: crate::protocol::SpawnOverrides {
19234 network_allowlist: Some(allowlist),
19235 ..crate::protocol::SpawnOverrides::default()
19236 },
19237 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19238 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19239 "station-knobs-unsupported-mapping",
19240 )
19241 .unwrap(),
19242 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19243 };
19244 let resolved = spec.resolve(&defaults).unwrap();
19245 assert_eq!(resolved.provider.as_str(), "claude");
19246 let failure = shared
19247 .resolve_environment_profile(&resolved)
19248 .expect_err("claude + codex_network_access must refuse");
19249 assert_eq!(
19250 failure.code,
19251 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19252 );
19253 }
19254
19255 #[test]
19259 fn resolve_codex_network_access_overlay_moderate_only() {
19260 let (shared, defaults) = station_knobs_fixture();
19261 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19262 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19263 id: allowlist.clone(),
19264 permits: Vec::new(),
19265 provider_native: Some(
19266 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19267 codex_network_access: Some(true),
19268 },
19269 ),
19270 };
19271 shared
19272 .network_allowlist_catalog
19273 .write()
19274 .unwrap_or_else(|poisoned| poisoned.into_inner())
19275 .insert(entry)
19276 .unwrap();
19277
19278 let overlay = shared
19279 .resolve_codex_network_access_overlay_args(
19280 &agent("codex"),
19281 SessionMode::Pty,
19282 ApprovalLevel::Moderate,
19283 Some(&allowlist),
19284 )
19285 .expect("Moderate Codex maps network_access");
19286 assert_eq!(
19287 overlay,
19288 vec![
19289 std::ffi::OsString::from("-c"),
19290 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19291 ]
19292 );
19293
19294 let acp = shared
19295 .resolve_codex_network_access_overlay_args(
19296 &agent("codex"),
19297 SessionMode::Acp,
19298 ApprovalLevel::Moderate,
19299 Some(&allowlist),
19300 )
19301 .unwrap();
19302 assert_eq!(acp, overlay);
19303
19304 let inline = shared
19305 .resolve_codex_network_access_overlay_args(
19306 &agent("codex"),
19307 SessionMode::Inline,
19308 ApprovalLevel::Moderate,
19309 Some(&allowlist),
19310 )
19311 .unwrap_err();
19312 assert_eq!(
19313 inline.code,
19314 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19315 );
19316
19317 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19318 let spec = crate::protocol::SpawnSpec {
19319 target: crate::protocol::SpawnTarget {
19320 node_id: shared.node_id.clone(),
19321 workspace_id: WorkspaceId::new("primary").unwrap(),
19322 worktree_id: None,
19323 },
19324 profile_id: defaults.profile_id.clone(),
19325 expected_profile_revision: defaults.revision.clone(),
19326 overrides: crate::protocol::SpawnOverrides {
19327 provider: crate::protocol::SpawnOverride::Set {
19328 value: agent("codex"),
19329 },
19330 approval_level: Some(level),
19331 network_allowlist: Some(allowlist.clone()),
19332 ..crate::protocol::SpawnOverrides::default()
19333 },
19334 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19335 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19336 "codex-net-refuse-{}",
19337 match level {
19338 ApprovalLevel::ReadOnly => "ro",
19339 ApprovalLevel::FullAuto => "fa",
19340 _ => "other",
19341 }
19342 ))
19343 .unwrap(),
19344 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19345 };
19346 let resolved = spec.resolve(&defaults).unwrap();
19347 let failure = shared
19348 .resolve_environment_profile(&resolved)
19349 .expect_err("RO/FullAuto must refuse");
19350 assert_eq!(
19351 failure.code,
19352 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19353 );
19354 }
19355 }
19356
19357 #[test]
19361 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19362 let (shared, _defaults) = station_knobs_fixture();
19363 let allowlist =
19364 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19365 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19366 id: allowlist.clone(),
19367 permits: vec![
19368 crate::network_allowlist_catalog::NetworkPermitSketch {
19369 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19370 peer: "github.com:443".to_owned(),
19371 },
19372 crate::network_allowlist_catalog::NetworkPermitSketch {
19373 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19374 peer: "*.npmjs.org".to_owned(),
19375 },
19376 ],
19377 provider_native: None,
19378 };
19379 shared
19380 .network_allowlist_catalog
19381 .write()
19382 .unwrap_or_else(|poisoned| poisoned.into_inner())
19383 .insert(entry)
19384 .unwrap();
19385
19386 let unique = std::time::SystemTime::now()
19387 .duration_since(std::time::UNIX_EPOCH)
19388 .unwrap()
19389 .as_nanos();
19390 let provider_home = std::env::temp_dir().join(format!(
19391 "gate4agent-claude-provider-home-{}-{unique}",
19392 std::process::id(),
19393 ));
19394 std::fs::create_dir_all(&provider_home).unwrap();
19395
19396 let overlay = shared
19397 .resolve_network_allowlist_overlay_args(
19398 &agent("claude"),
19399 SessionMode::Pty,
19400 ApprovalLevel::Moderate,
19401 Some(&allowlist),
19402 Some(provider_home.as_path()),
19403 )
19404 .expect("Claude PTY settings overlay on unix");
19405 let settings_path = provider_home.join(
19406 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19407 );
19408 assert_eq!(
19409 overlay,
19410 vec![
19411 std::ffi::OsString::from("--settings"),
19412 std::ffi::OsString::from(settings_path.as_os_str()),
19413 ]
19414 );
19415 let body = std::fs::read_to_string(&settings_path).unwrap();
19416 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19417 assert_eq!(value["sandbox"]["enabled"], true);
19418 assert_eq!(
19419 value["sandbox"]["network"]["allowedDomains"],
19420 serde_json::json!(["github.com", "*.npmjs.org"])
19421 );
19422 assert!(settings_path.starts_with(&provider_home));
19423
19424 let acp = shared
19425 .resolve_network_allowlist_overlay_args(
19426 &agent("claude"),
19427 SessionMode::Acp,
19428 ApprovalLevel::Moderate,
19429 Some(&allowlist),
19430 Some(provider_home.as_path()),
19431 )
19432 .expect_err("Claude ACP must refuse settings overlay");
19433 assert_eq!(
19434 acp.code,
19435 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19436 );
19437
19438 let no_home = shared
19439 .resolve_network_allowlist_overlay_args(
19440 &agent("claude"),
19441 SessionMode::Pty,
19442 ApprovalLevel::Moderate,
19443 Some(&allowlist),
19444 None,
19445 )
19446 .expect_err("Claude overlay requires ProviderHome");
19447 assert_eq!(
19448 no_home.code,
19449 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19450 );
19451
19452 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19453 shared
19454 .network_allowlist_catalog
19455 .write()
19456 .unwrap_or_else(|poisoned| poisoned.into_inner())
19457 .insert_id(bare.clone())
19458 .unwrap();
19459 let empty = shared
19460 .resolve_network_allowlist_overlay_args(
19461 &agent("claude"),
19462 SessionMode::Pty,
19463 ApprovalLevel::Moderate,
19464 Some(&bare),
19465 Some(provider_home.as_path()),
19466 )
19467 .unwrap();
19468 assert!(empty.is_empty());
19469
19470 let _ = std::fs::remove_dir_all(&provider_home);
19471 }
19472
19473 #[test]
19475 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19476 let (shared, _defaults) = station_knobs_fixture();
19477 let allowlist =
19478 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19479 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19480 id: allowlist.clone(),
19481 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19482 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19483 peer: "example.com:443".to_owned(),
19484 }],
19485 provider_native: None,
19486 };
19487 shared
19488 .network_allowlist_catalog
19489 .write()
19490 .unwrap_or_else(|poisoned| poisoned.into_inner())
19491 .insert(entry)
19492 .unwrap();
19493 for provider in ["kimi", "grok"] {
19494 let overlay = shared
19495 .resolve_network_allowlist_overlay_args(
19496 &agent(provider),
19497 SessionMode::Pty,
19498 ApprovalLevel::Moderate,
19499 Some(&allowlist),
19500 None,
19501 )
19502 .unwrap();
19503 assert!(
19504 overlay.is_empty(),
19505 "{provider} must not invent network argv from permits"
19506 );
19507 }
19508 }
19509
19510 #[test]
19512 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19513 let unique = std::time::SystemTime::now()
19514 .duration_since(std::time::UNIX_EPOCH)
19515 .unwrap()
19516 .as_nanos();
19517 let path = std::env::temp_dir().join(format!(
19518 "gate4agent-node-allowlist-json-{}-{unique}.json",
19519 std::process::id(),
19520 ));
19521 std::fs::write(
19522 &path,
19523 r#"{
19524 "schema_version": 2,
19525 "entries": [
19526 {
19527 "id": "egress-default",
19528 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19529 },
19530 { "id": "lab-egress" }
19531 ]
19532 }"#,
19533 )
19534 .unwrap();
19535 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19536 .expect("json v2 catalog must load");
19537 let _ = std::fs::remove_file(&path);
19538 assert_eq!(loaded.len(), 2);
19539 let entry = loaded
19540 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19541 .unwrap();
19542 assert_eq!(entry.permits.len(), 1);
19543 let (shared, defaults) = station_knobs_fixture();
19544 {
19545 let mut catalog = shared
19546 .network_allowlist_catalog
19547 .write()
19548 .unwrap_or_else(|poisoned| poisoned.into_inner());
19549 *catalog = loaded;
19550 }
19551 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19552 let spec = crate::protocol::SpawnSpec {
19553 target: crate::protocol::SpawnTarget {
19554 node_id: shared.node_id.clone(),
19555 workspace_id: WorkspaceId::new("primary").unwrap(),
19556 worktree_id: None,
19557 },
19558 profile_id: defaults.profile_id.clone(),
19559 expected_profile_revision: defaults.revision.clone(),
19560 overrides: crate::protocol::SpawnOverrides {
19561 network_allowlist: Some(allowlist.clone()),
19562 ..crate::protocol::SpawnOverrides::default()
19563 },
19564 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19565 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19566 "station-knobs-catalog-json",
19567 )
19568 .unwrap(),
19569 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19570 };
19571 let resolved = spec.resolve(&defaults).unwrap();
19572 let receipt = shared
19573 .resolve_environment_profile(&resolved)
19574 .expect("permits-only entry ok for claude")
19575 .expect("env profile present");
19576 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19577 let inventory = shared
19578 .snapshot()
19579 .launch_inventory
19580 .expect("launch inventory present");
19581 let ids = inventory.network_allowlists.expect("ids surface");
19582 assert_eq!(
19583 ids,
19584 vec![
19585 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19586 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19587 ]
19588 );
19589 }
19590
19591
19592}
19593
19594
19595#[cfg(test)]
19596mod workspace_environment_collision_tests {
19597 use super::*;
19598 use crate::session_environment::{
19599 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19600 };
19601 use gate4agent_catalog::EnvMutation;
19602 use gate4agent_runtime_native::{
19603 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19604 NativeLaunchProfileId,
19605 };
19606 use std::ffi::OsString;
19607 use std::sync::Arc;
19608
19609 struct EmptyEnvironmentResolver;
19610
19611 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19612 fn resolve_child_environment(
19613 &self,
19614 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19615 Ok(vec![EnvMutation {
19616 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19617 value: None,
19618 }])
19619 }
19620 }
19621
19622 fn agent(value: &str) -> AgentId {
19623 AgentId::new(value).unwrap()
19624 }
19625
19626 fn claude_config_dir_collision_fixture() -> (
19628 NodeShared,
19629 ResolvedEnvironmentProfileReceipt,
19630 ResolvedEnvironmentProfileReceipt,
19631 ) {
19632 let catalog = active_registry().unwrap();
19633 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19634 let control = runtime.native_launch_profile_control();
19635 let workspace = WorkspaceConfig::new(
19636 WorkspaceId::new("primary").unwrap(),
19637 std::env::current_dir().unwrap(),
19638 )
19639 .unwrap();
19640 let shared = NodeShared::new_with_incarnation(
19641 handle,
19642 "fixture-token".to_owned(),
19643 NodeId::new("node-env-collision-test").unwrap(),
19644 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19645 vec![workspace],
19646 vec![agent("claude")],
19647 ProviderRuntimeStatuses::default(),
19648 None,
19649 Vec::new(),
19650 Vec::new(),
19651 SpawnProfileRegistry::default(),
19652 Some(control),
19653 None,
19654 None,
19655 None,
19656 Vec::new(),
19657 Vec::new(),
19658 Vec::new(),
19659 Vec::new(),
19660 None,
19661 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19662 );
19663
19664 let mut receipts = Vec::new();
19665 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19666 let profile_id =
19667 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19668 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19669 "claude-config-dir-{suffix}-r1"
19670 ))
19671 .unwrap();
19672 let materialization = NodeSessionMaterializationProfile::new(
19673 Vec::new(),
19674 vec![NodeSessionPathBinding::new(
19675 "CLAUDE_CONFIG_DIR",
19676 NodeSessionPathClass::ProviderHome,
19677 )
19678 .unwrap()],
19679 Vec::new(),
19680 )
19681 .unwrap();
19682 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19683 profile_id.clone(),
19684 profile_revision.clone(),
19685 agent("claude"),
19686 [NativeLaunchProfile::new(
19687 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19688 .unwrap(),
19689 agent("claude"),
19690 TransportKind::Pty,
19691 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19692 Arc::new(EmptyEnvironmentResolver),
19693 )
19694 .unwrap()],
19695 Some(materialization),
19696 )
19697 .unwrap();
19698 let (binding, native_profiles, materialization) = node_profile.into_parts();
19699 for native_profile in native_profiles {
19700 runtime.upsert_native_launch_profile(native_profile).unwrap();
19701 }
19702 shared
19703 .environment_profiles
19704 .write()
19705 .unwrap_or_else(|poisoned| poisoned.into_inner())
19706 .insert(binding.id.clone(), binding);
19707 shared
19708 .environment_materialization_profiles
19709 .write()
19710 .unwrap_or_else(|poisoned| poisoned.into_inner())
19711 .insert(profile_id.clone(), materialization.unwrap());
19712 receipts.push(ResolvedEnvironmentProfileReceipt {
19713 profile_id,
19714 profile_revision,
19715 network_allowlist: None,
19716 browser_profile_id: None,
19717 });
19718 }
19719 let profile_b = receipts.pop().unwrap();
19720 let profile_a = receipts.pop().unwrap();
19721 (shared, profile_a, profile_b)
19722 }
19723
19724 #[test]
19725 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19726 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19727 let primary = WorkspaceId::new("primary").unwrap();
19728 let worktree = WorkspaceId::new("worktree-b").unwrap();
19729 let address = SessionAddress {
19730 workspace_id: primary.clone(),
19731 session: SessionKey {
19732 instance_id: AgentInstanceId(41),
19733 generation: SessionGeneration(1),
19734 },
19735 };
19736 shared.bind_session_with_policy(
19737 &address,
19738 ProviderRuntimePolicy::raw_pty(),
19739 Some(profile_a.clone()),
19740 );
19741
19742 let refused = shared
19743 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19744 .unwrap_err();
19745 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19746
19747 let refused_none = shared
19749 .ensure_workspace_environment_profile_compatible(&primary, None)
19750 .unwrap_err();
19751 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19752
19753 shared
19755 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19756 .unwrap();
19757
19758 shared
19760 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19761 .unwrap();
19762 }
19763
19764 #[tokio::test]
19765 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19766 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19767 let primary = WorkspaceId::new("primary").unwrap();
19768 let address = SessionAddress {
19769 workspace_id: primary.clone(),
19770 session: SessionKey {
19771 instance_id: AgentInstanceId(41),
19772 generation: SessionGeneration(1),
19773 },
19774 };
19775 shared.bind_session_with_policy(
19776 &address,
19777 ProviderRuntimePolicy::raw_pty(),
19778 Some(profile_a),
19779 );
19780
19781 let failure = shared
19782 .spawn_session_with_deadline(
19783 primary,
19784 agent("claude"),
19785 SessionMode::Pty,
19786 gate4agent_types::TerminalSize {
19787 rows: 24,
19788 columns: 80,
19789 },
19790 None,
19791 Some(profile_b),
19792 None,
19793 None,
19794 None,
19795 None,
19796 SpawnRecordPolicy::ProviderIdentityOnly,
19797 Some(Instant::now() + Duration::from_secs(30)),
19798 &[],
19799 None,
19800 ApprovalLevel::default(),
19801 None,
19802 None,
19803 )
19804 .await
19805 .unwrap_err();
19806 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19807 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19809 assert!(shared.handle.snapshot().sessions.is_empty());
19810 }
19811}
19812
19813#[cfg(test)]
19814mod spawn_characterization_tests {
19815 use super::*;
19816 use crate::session_environment::{
19817 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19818 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19819 NodeSessionPathClass,
19820 };
19821 use gate4agent_catalog::EnvMutation;
19822 use gate4agent_runtime_native::{
19823 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19824 NativeLaunchProfile, NativeLaunchProfileId,
19825 };
19826 use std::ffi::OsString;
19827 use std::sync::Arc;
19828
19829 struct EmptyEnvironmentResolver;
19830
19831 struct FixtureSecretResolver {
19832 deny: Arc<AtomicBool>,
19833 }
19834
19835 impl NodeSecretResolver for FixtureSecretResolver {
19836 fn resolve(
19837 &self,
19838 _reference: &NodeSecretReference,
19839 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19840 if self.deny.load(Ordering::Acquire) {
19841 Err(NodeSecretResolveError::Denied)
19842 } else {
19843 NodeSecretValue::text("fixture-secret")
19844 .map_err(|_| NodeSecretResolveError::Unavailable)
19845 }
19846 }
19847 }
19848
19849 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19850 fn resolve_child_environment(
19851 &self,
19852 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19853 Ok(vec![EnvMutation {
19854 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19855 value: None,
19856 }])
19857 }
19858 }
19859
19860 fn agent(value: &str) -> AgentId {
19861 AgentId::new(value).unwrap()
19862 }
19863
19864 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19865 let unique = std::time::SystemTime::now()
19866 .duration_since(std::time::UNIX_EPOCH)
19867 .unwrap()
19868 .as_nanos();
19869 std::env::temp_dir().join(format!(
19870 "gate4agent-node-{label}-{}-{unique}",
19871 std::process::id(),
19872 ))
19873 }
19874
19875 fn materialization_test_shared(
19876 deny_secret: bool,
19877 ) -> (
19878 NodeShared,
19879 NativeRuntime,
19880 ResolvedEnvironmentProfileReceipt,
19881 PathBuf,
19882 Arc<AtomicBool>,
19883 ) {
19884 let workspace = WorkspaceConfig::new(
19885 WorkspaceId::new("primary").unwrap(),
19886 std::env::current_dir().unwrap(),
19887 )
19888 .unwrap();
19889 materialization_test_shared_with_workspace(deny_secret, workspace)
19890 }
19891
19892 fn materialization_test_shared_with_workspace(
19893 deny_secret: bool,
19894 workspace: WorkspaceConfig,
19895 ) -> (
19896 NodeShared,
19897 NativeRuntime,
19898 ResolvedEnvironmentProfileReceipt,
19899 PathBuf,
19900 Arc<AtomicBool>,
19901 ) {
19902 let root = temporary_workspace_root(if deny_secret {
19903 "materialization-denied"
19904 } else {
19905 "materialization-ready"
19906 });
19907 std::fs::create_dir_all(&root).unwrap();
19908 let materialization_root = root.join("session-environments");
19909 let deny = Arc::new(AtomicBool::new(deny_secret));
19910 let resolver = Arc::new(FixtureSecretResolver {
19911 deny: Arc::clone(&deny),
19912 });
19913 let materializer = SessionEnvironmentMaterializer::new(
19914 materialization_root,
19915 resolver,
19916 )
19917 .unwrap();
19918 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19919 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19920 "materialized-claude-r1",
19921 )
19922 .unwrap();
19923 let materialization = NodeSessionMaterializationProfile::new(
19924 vec![NodeSessionEnvironmentMutation::SetSecret {
19925 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19926 reference: NodeSecretReference::new("fixture-token").unwrap(),
19927 }],
19928 vec![NodeSessionPathBinding::new(
19929 "GATE4AGENT_PROVIDER_HOME",
19930 NodeSessionPathClass::ProviderHome,
19931 )
19932 .unwrap()],
19933 vec![NodeSessionFile::secret(
19934 NodeSessionPathClass::Config,
19935 "auth/token",
19936 NodeSecretReference::new("fixture-token").unwrap(),
19937 )
19938 .unwrap()],
19939 )
19940 .unwrap();
19941 let catalog = active_registry().unwrap();
19942 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19943 let control = runtime.native_launch_profile_control();
19944 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19945 profile_id.clone(),
19946 profile_revision.clone(),
19947 agent("claude"),
19948 [NativeLaunchProfile::new(
19949 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19950 agent("claude"),
19951 TransportKind::Pty,
19952 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19953 Arc::new(EmptyEnvironmentResolver),
19954 )
19955 .unwrap()],
19956 Some(materialization),
19957 )
19958 .unwrap();
19959 let (binding, native_profiles, materialization) = node_profile.into_parts();
19960 for native_profile in native_profiles {
19961 runtime.upsert_native_launch_profile(native_profile).unwrap();
19962 }
19963 let shared = NodeShared::new_with_incarnation(
19964 handle,
19965 "fixture-token".to_owned(),
19966 NodeId::new("node-materialization-test").unwrap(),
19967 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19968 vec![workspace],
19969 vec![agent("claude")],
19970 ProviderRuntimeStatuses::default(),
19971 None,
19972 Vec::new(),
19973 Vec::new(),
19974 SpawnProfileRegistry::default(),
19975 Some(control),
19976 None,
19977 Some(materializer),
19978 None,
19979 Vec::new(),
19980 Vec::new(),
19981 Vec::new(),
19982 Vec::new(),
19983 None,
19984 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19985 );
19986 shared
19987 .environment_profiles
19988 .write()
19989 .unwrap_or_else(|poisoned| poisoned.into_inner())
19990 .insert(binding.id.clone(), binding);
19991 shared
19992 .environment_materialization_profiles
19993 .write()
19994 .unwrap_or_else(|poisoned| poisoned.into_inner())
19995 .insert(profile_id.clone(), materialization.unwrap());
19996 (
19997 shared,
19998 runtime,
19999 ResolvedEnvironmentProfileReceipt {
20000 profile_id,
20001 profile_revision,
20002 network_allowlist: None,
20003 browser_profile_id: None,
20004 },
20005 root,
20006 deny,
20007 )
20008 }
20009
20010
20011 async fn characterization_spawn(
20014 shared: &NodeShared,
20015 mode: SessionMode,
20016 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20017 required: &[ProviderRuntimeCapability],
20018 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20019 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20020 shared
20021 .spawn_session_with_deadline(
20022 WorkspaceId::new("primary").unwrap(),
20023 agent("claude"),
20024 mode,
20025 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20026 None,
20027 environment_profile,
20028 None,
20029 None,
20030 None,
20031 Some(ProviderRuntimePolicy::raw_pty()),
20032 SpawnRecordPolicy::ProviderIdentityOnly,
20033 Some(Instant::now() + Duration::from_secs(30)),
20034 required,
20035 harness_mcp,
20036 ApprovalLevel::default(),
20037 None,
20038 None,
20039 )
20040 .await
20041 }
20042
20043 fn assert_no_session_state(shared: &NodeShared) {
20044 assert!(shared.handle.snapshot().sessions.is_empty());
20045 assert!(shared
20046 .session_bindings
20047 .lock()
20048 .unwrap_or_else(|poisoned| poisoned.into_inner())
20049 .is_empty());
20050 assert!(shared
20051 .materializations
20052 .lock()
20053 .unwrap_or_else(|poisoned| poisoned.into_inner())
20054 .is_empty());
20055 }
20056
20057 #[tokio::test]
20058 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20059 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20060 let error = characterization_spawn(
20061 &shared,
20062 SessionMode::Pty,
20063 None,
20064 &[ProviderRuntimeCapability::SemanticReadiness],
20065 None,
20066 )
20067 .await
20068 .unwrap_err();
20069 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20070 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20071 assert_no_session_state(&shared);
20072 drop(shared);
20073 drop(runtime);
20074 std::fs::remove_dir_all(root).unwrap();
20075 }
20076
20077 #[tokio::test]
20078 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20079 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20080 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20081 .await
20082 .unwrap_err();
20083 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20084 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20085 assert_no_session_state(&shared);
20086 drop(shared);
20087 drop(runtime);
20088 std::fs::remove_dir_all(root).unwrap();
20089 }
20090
20091 #[test]
20092 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20093 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20094 let address = SessionAddress {
20095 workspace_id: WorkspaceId::new("primary").unwrap(),
20096 session: SessionKey {
20097 instance_id: AgentInstanceId(77),
20098 generation: SessionGeneration(1),
20099 },
20100 };
20101 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20102 let removed = shared.remove_binding(&address).expect("binding removed");
20103 assert!(removed.environment_profile.is_some());
20104 assert!(shared.remove_binding(&address).is_none());
20105 assert_no_session_state(&shared);
20106 drop(shared);
20107 drop(runtime);
20108 std::fs::remove_dir_all(root).unwrap();
20109 }
20110
20111 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20112 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20113 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20114 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20115 .expect("review current test executable as fixture helper");
20116 let registry = HarnessMcpProxyRegistry::new(helper);
20117 let spec = crate::protocol::SpawnSpec {
20118 target: crate::protocol::SpawnTarget {
20119 node_id: NodeId::new("node-characterization").unwrap(),
20120 workspace_id: WorkspaceId::new("primary").unwrap(),
20121 worktree_id: None,
20122 },
20123 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20124 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20125 overrides: crate::protocol::SpawnOverrides::default(),
20126 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20127 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20128 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20129 };
20130 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20131 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20132 let launch = HarnessMcpLaunchV1 {
20133 server_name: "fixture-harness".to_owned(),
20134 args: vec!["--session-proxy".to_owned()],
20135 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20136 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20137 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20138 trace: None,
20139 scrub_env: Vec::new(),
20140 };
20141 let expires = unix_time_ms().saturating_add(30_000);
20142 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20143 registry
20144 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20145 .unwrap()
20146 }
20147
20148 #[tokio::test]
20149 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20150 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20151 let prepared = characterization_prepared_harness("claude").await;
20152 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20153 .await
20154 .unwrap_err();
20155 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20157 let mismatched = characterization_prepared_harness("codex").await;
20158 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20159 .await
20160 .unwrap_err();
20161 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20162 assert_no_session_state(&shared);
20163 drop(shared);
20164 drop(runtime);
20165 std::fs::remove_dir_all(root).unwrap();
20166 }
20167
20168 #[tokio::test]
20169 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20170 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20171 shared.native_launch_profile_control = None;
20172 let prepared = characterization_prepared_harness("claude").await;
20173 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20174 .await
20175 .unwrap_err();
20176 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20177 assert_no_session_state(&shared);
20178 drop(shared);
20179 drop(runtime);
20180 std::fs::remove_dir_all(root).unwrap();
20181 }
20182}
20183
20184#[cfg(all(test, windows))]
20185mod tests {
20186 use super::*;
20187 use gate4agent_types::ProviderActivity;
20188 use crate::session_environment::{
20189 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20190 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20191 NodeSessionPathClass,
20192 };
20193 use gate4agent_catalog::EnvMutation;
20194 use gate4agent_runtime_native::{
20195 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20196 NativeLaunchProfile, NativeLaunchProfileId,
20197 };
20198 use std::ffi::OsString;
20199 use std::sync::Arc;
20200
20201 struct EmptyEnvironmentResolver;
20202
20203 struct FixtureSecretResolver {
20204 deny: Arc<AtomicBool>,
20205 }
20206
20207 impl NodeSecretResolver for FixtureSecretResolver {
20208 fn resolve(
20209 &self,
20210 _reference: &NodeSecretReference,
20211 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20212 if self.deny.load(Ordering::Acquire) {
20213 Err(NodeSecretResolveError::Denied)
20214 } else {
20215 NodeSecretValue::text("fixture-secret")
20216 .map_err(|_| NodeSecretResolveError::Unavailable)
20217 }
20218 }
20219 }
20220
20221 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20222 fn resolve_child_environment(
20223 &self,
20224 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20225 Ok(vec![EnvMutation {
20226 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20227 value: None,
20228 }])
20229 }
20230 }
20231
20232 fn agent(value: &str) -> AgentId {
20233 AgentId::new(value).unwrap()
20234 }
20235
20236 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20237 let catalog = active_registry().unwrap();
20238 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20239 let workspace = WorkspaceConfig::new(
20240 WorkspaceId::new("primary").unwrap(),
20241 std::env::current_dir().unwrap(),
20242 )
20243 .unwrap()
20244 .with_worktree_service_mode(mode);
20245 NodeShared::new(
20246 handle,
20247 "fixture-token".to_owned(),
20248 NodeId::new("node-terminal-test").unwrap(),
20249 vec![workspace],
20250 vec![agent("claude")],
20251 )
20252 }
20253
20254 fn terminal_test_shared() -> NodeShared {
20255 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20256 }
20257
20258 #[cfg(feature = "fixture")]
20259 #[test]
20260 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20261 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20262 assert_eq!(probe.latest_code(), None);
20263
20264 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20265 assert_eq!(
20266 probe.latest_code(),
20267 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20268 );
20269
20270 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20271 assert_eq!(
20272 probe.latest_code(),
20273 Some(NodeFailureCode::SpawnDeadlineExceeded),
20274 );
20275
20276 probe.record(None);
20277 assert_eq!(probe.latest_code(), None);
20278 }
20279
20280 #[test]
20281 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20282 let source = temporary_workspace_root("launch-inventory-source");
20283 let allocation = temporary_workspace_root("launch-inventory-allocation");
20284 std::fs::create_dir_all(&source).unwrap();
20285 std::fs::create_dir_all(&allocation).unwrap();
20286 let workspace_id = WorkspaceId::new("primary").unwrap();
20287 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20288 .unwrap()
20289 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20290 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20291 workspace = workspace
20292 .with_managed_worktree_profile(
20293 ManagedWorktreeProfile::new(
20294 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20295 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20296 &allocation,
20297 "gate4agent",
20298 "HEAD",
20299 ManagedWorktreeRetention::RemoveWhenReleased,
20300 )
20301 .unwrap(),
20302 )
20303 .unwrap();
20304 }
20305 let overflow = workspace.clone().with_managed_worktree_profile(
20306 ManagedWorktreeProfile::new(
20307 WorktreeProfileId::new("overflow").unwrap(),
20308 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20309 &allocation,
20310 "gate4agent",
20311 "HEAD",
20312 ManagedWorktreeRetention::Retain,
20313 )
20314 .unwrap(),
20315 );
20316 assert!(matches!(
20317 overflow,
20318 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20319 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20320 ..
20321 }),
20322 ));
20323
20324 let catalog = active_registry().unwrap();
20325 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20326 let shared = NodeShared::new(
20327 handle,
20328 "fixture-token".to_owned(),
20329 NodeId::new("node-launch-inventory").unwrap(),
20330 vec![workspace],
20331 vec![agent("claude")],
20332 );
20333 let snapshot = shared.snapshot();
20334 let launch = snapshot.launch_inventory.as_ref().unwrap();
20335 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20336 assert!(launch.bundles.as_ref().unwrap().is_empty());
20337 let profiles = snapshot.workspaces[0]
20338 .managed_worktree_profiles
20339 .as_ref()
20340 .unwrap();
20341 assert_eq!(
20342 snapshot.workspaces[0].worktree_service_mode,
20343 Some(WorktreeServiceMode::Managed),
20344 );
20345 assert_eq!(
20346 profiles.profiles.len(),
20347 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20348 );
20349 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20350 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20351 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20352 }
20353 let legacy = snapshot_for_wire(
20354 &shared,
20355 true,
20356 true,
20357 false,
20358 false,
20359 true,
20360 false,
20361 true,
20362 false,
20363 true,
20364 );
20365 assert!(legacy.launch_inventory.is_none());
20366 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20367
20368 drop(shared);
20369 std::fs::remove_dir_all(source).unwrap();
20370 std::fs::remove_dir_all(allocation).unwrap();
20371 }
20372
20373 #[test]
20374 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20375 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20376 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20377 }));
20378 let source = gate4agent_types::ProviderSource {
20379 family: AdapterFamily::PtySemantic,
20380 binding: AdapterBinding::new(
20381 gate4agent_types::AdapterId::new("claude").unwrap(),
20382 "fixture/v1",
20383 gate4agent_types::AdapterVerification::SyntheticFixture,
20384 )
20385 .unwrap(),
20386 };
20387 let provider = ProviderSnapshot {
20388 sequence: 17,
20389 session: Some(ProviderSessionIdentity {
20390 key: ProviderSessionKey::SessionId,
20391 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20392 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20393 }),
20394 model: Some("PRIVATE_MODEL".to_owned()),
20395 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20396 completed_turns: 3,
20397 usage: gate4agent_types::TokenUsage {
20398 input_tokens: 10,
20399 output_tokens: 20,
20400 cache_read_tokens: 30,
20401 cache_write_tokens: 40,
20402 reasoning_tokens: 50,
20403 context_window: Some(200_000),
20404 },
20405 lead_activity: ProviderActivity::WaitingForInput,
20406 activity: ProviderActivity::WaitingForInput,
20407 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20408 active_tools: (0..9)
20409 .map(|index| gate4agent_types::ActiveProviderTool {
20410 id: format!("private-tool-id-{index}"),
20411 name: if index == 0 {
20412 format!(" Read\u{0000}{}", "x".repeat(80))
20413 } else {
20414 format!("Tool-{index}")
20415 },
20416 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20417 })
20418 .collect(),
20419 interactions: vec![gate4agent_types::ProviderInteraction {
20420 id: gate4agent_types::ProviderInteractionId(17),
20421 source: source.clone(),
20422 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20423 interaction_kind: ProviderInteractionKind::Approval,
20424 tool_name: " Approve\u{0007}Tool ".to_owned(),
20425 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20426 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20427 resume_lead_activity: Some(ProviderActivity::Working),
20428 status: ProviderInteractionStatus::Pending,
20429 }],
20430 subagents: vec![gate4agent_types::ProviderSubagent {
20431 source: source.clone(),
20432 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20433 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20434 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20435 }],
20436 sources: vec![gate4agent_types::ProviderSourceCursor {
20437 source,
20438 sequence: 17,
20439 gap_count: 2,
20440 stale: true,
20441 }],
20442 last_event: Some(ProviderEvent::Error {
20443 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20444 }),
20445 gap_count: 2,
20446 stale: true,
20447 };
20448 let address = terminal_address(3);
20449 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20450 assert_eq!(entry.address, address);
20451 assert_eq!(entry.progress.provider_sequence, 17);
20452 assert_eq!(entry.progress.active_tool_count, 9);
20453 assert_eq!(
20454 entry.progress.active_tool_labels.len(),
20455 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20456 );
20457 assert_eq!(entry.progress.subagent_count, 1);
20458 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20459 assert!(entry.progress.truncated);
20460 let encoded = serde_json::to_string(&entry).unwrap();
20461 for forbidden in [
20462 "PRIVATE_PROVIDER_SESSION",
20463 "PRIVATE_PROVIDER_PATH",
20464 "PRIVATE_MODEL",
20465 "PRIVATE_TOOL_INVENTORY",
20466 "PRIVATE_CURRENT_PROMPT",
20467 "PRIVATE_TOOL_INPUT_JSON",
20468 "PRIVATE_PROVIDER_REQUEST",
20469 "PRIVATE_INTERACTION_PROMPT",
20470 "PRIVATE_INTERACTION_AGENT",
20471 "PRIVATE_SUBAGENT_ID",
20472 "PRIVATE_SUBAGENT_TYPE",
20473 "PRIVATE_SUBAGENT_DESCRIPTION",
20474 "PRIVATE_PROVIDER_ERROR",
20475 "input_json",
20476 "current_prompt",
20477 "session_id",
20478 ] {
20479 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20480 }
20481
20482 let snapshot = NodeSnapshot {
20483 node_id: NodeId::new("node-progress-test").unwrap(),
20484 enabled_providers: vec![agent("claude")],
20485 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20486 workspaces: Vec::new(),
20487 session_records: Vec::new(),
20488 managed_worktrees: Vec::new(),
20489 launch_inventory: None,
20490 agent_progress: vec![entry],
20491 };
20492 let mut legacy_reply = ResponseEnvelope {
20493 request_id: 1,
20494 result: Ok(NodeResponse::Snapshot {
20495 event_sequence: 0,
20496 controller: None,
20497 snapshot: snapshot.clone(),
20498 }),
20499 };
20500 project_response_without_agent_progress(&mut legacy_reply);
20501 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20502 panic!("agent progress projection changed response kind");
20503 };
20504 assert!(legacy.agent_progress.is_empty());
20505 assert_eq!(snapshot.agent_progress.len(), 1);
20506 }
20507
20508 fn terminal_address(generation: u64) -> SessionAddress {
20509 SessionAddress {
20510 workspace_id: WorkspaceId::new("primary").unwrap(),
20511 session: SessionKey {
20512 instance_id: AgentInstanceId(41),
20513 generation: SessionGeneration(generation),
20514 },
20515 }
20516 }
20517
20518 fn spawn_spec_fixture() -> SpawnSpec {
20519 SpawnSpec {
20520 target: crate::protocol::SpawnTarget {
20521 node_id: NodeId::new("node-terminal-test").unwrap(),
20522 workspace_id: WorkspaceId::new("primary").unwrap(),
20523 worktree_id: None,
20524 },
20525 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20526 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20527 .unwrap(),
20528 overrides: crate::protocol::SpawnOverrides::default(),
20529 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20530 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20531 required_capabilities: SpawnRequiredCapabilities::default(),
20532 }
20533 }
20534
20535 fn environment_profile_test_shared(
20536 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20537 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20538 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20539 "local-claude-r1",
20540 )
20541 .unwrap();
20542 let spawn_profiles = SpawnProfileRegistry::new([
20543 crate::protocol::SpawnProfileDefaults {
20544 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20545 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20546 provider: agent("claude"),
20547 mode: SessionMode::Pty,
20548 terminal_size: gate4agent_types::TerminalSize {
20549 rows: 24,
20550 columns: 80,
20551 },
20552 prompt: None,
20553 bundle_id: None,
20554 context_id: None,
20555 environment_profile_id: Some(profile_id.clone()),
20556 },
20557 ])
20558 .unwrap();
20559 let catalog = active_registry().unwrap();
20560 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20561 let control = runtime.native_launch_profile_control();
20562 let node_profile = NodeEnvironmentProfile::new(
20563 profile_id.clone(),
20564 profile_revision.clone(),
20565 agent("claude"),
20566 [NativeLaunchProfile::new(
20567 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20568 agent("claude"),
20569 TransportKind::Pty,
20570 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20571 Arc::new(EmptyEnvironmentResolver),
20572 )
20573 .unwrap()],
20574 )
20575 .unwrap();
20576 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20577 for native_profile in native_profiles {
20578 runtime.upsert_native_launch_profile(native_profile).unwrap();
20579 }
20580 let workspace = WorkspaceConfig::new(
20581 WorkspaceId::new("primary").unwrap(),
20582 std::env::current_dir().unwrap(),
20583 )
20584 .unwrap();
20585 let shared = NodeShared::new_with_incarnation(
20586 handle,
20587 "fixture-token".to_owned(),
20588 NodeId::new("node-terminal-test").unwrap(),
20589 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20590 vec![workspace],
20591 vec![agent("claude")],
20592 ProviderRuntimeStatuses::default(),
20593 None,
20594 Vec::new(),
20595 Vec::new(),
20596 spawn_profiles,
20597 Some(control),
20598 None,
20599 None,
20600 None,
20601 Vec::new(),
20602 Vec::new(),
20603 Vec::new(),
20604 Vec::new(),
20605 None,
20606 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20607 );
20608 shared
20609 .environment_profiles
20610 .write()
20611 .unwrap_or_else(|poisoned| poisoned.into_inner())
20612 .insert(binding.id.clone(), binding);
20613 (
20614 shared,
20615 runtime,
20616 ResolvedEnvironmentProfileReceipt {
20617 profile_id,
20618 profile_revision,
20619 network_allowlist: None,
20620 browser_profile_id: None,
20621 },
20622 )
20623 }
20624
20625 fn materialization_test_shared(
20626 deny_secret: bool,
20627 ) -> (
20628 NodeShared,
20629 NativeRuntime,
20630 ResolvedEnvironmentProfileReceipt,
20631 PathBuf,
20632 Arc<AtomicBool>,
20633 ) {
20634 let workspace = WorkspaceConfig::new(
20635 WorkspaceId::new("primary").unwrap(),
20636 std::env::current_dir().unwrap(),
20637 )
20638 .unwrap();
20639 materialization_test_shared_with_workspace(deny_secret, workspace)
20640 }
20641
20642 fn materialization_test_shared_with_workspace(
20643 deny_secret: bool,
20644 workspace: WorkspaceConfig,
20645 ) -> (
20646 NodeShared,
20647 NativeRuntime,
20648 ResolvedEnvironmentProfileReceipt,
20649 PathBuf,
20650 Arc<AtomicBool>,
20651 ) {
20652 let root = temporary_workspace_root(if deny_secret {
20653 "materialization-denied"
20654 } else {
20655 "materialization-ready"
20656 });
20657 std::fs::create_dir_all(&root).unwrap();
20658 let materialization_root = root.join("session-environments");
20659 let deny = Arc::new(AtomicBool::new(deny_secret));
20660 let resolver = Arc::new(FixtureSecretResolver {
20661 deny: Arc::clone(&deny),
20662 });
20663 let materializer = SessionEnvironmentMaterializer::new(
20664 materialization_root,
20665 resolver,
20666 )
20667 .unwrap();
20668 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20669 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20670 "materialized-claude-r1",
20671 )
20672 .unwrap();
20673 let materialization = NodeSessionMaterializationProfile::new(
20674 vec![NodeSessionEnvironmentMutation::SetSecret {
20675 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20676 reference: NodeSecretReference::new("fixture-token").unwrap(),
20677 }],
20678 vec![NodeSessionPathBinding::new(
20679 "GATE4AGENT_PROVIDER_HOME",
20680 NodeSessionPathClass::ProviderHome,
20681 )
20682 .unwrap()],
20683 vec![NodeSessionFile::secret(
20684 NodeSessionPathClass::Config,
20685 "auth/token",
20686 NodeSecretReference::new("fixture-token").unwrap(),
20687 )
20688 .unwrap()],
20689 )
20690 .unwrap();
20691 let catalog = active_registry().unwrap();
20692 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20693 let control = runtime.native_launch_profile_control();
20694 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20695 profile_id.clone(),
20696 profile_revision.clone(),
20697 agent("claude"),
20698 [NativeLaunchProfile::new(
20699 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20700 agent("claude"),
20701 TransportKind::Pty,
20702 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20703 Arc::new(EmptyEnvironmentResolver),
20704 )
20705 .unwrap()],
20706 Some(materialization),
20707 )
20708 .unwrap();
20709 let (binding, native_profiles, materialization) = node_profile.into_parts();
20710 for native_profile in native_profiles {
20711 runtime.upsert_native_launch_profile(native_profile).unwrap();
20712 }
20713 let shared = NodeShared::new_with_incarnation(
20714 handle,
20715 "fixture-token".to_owned(),
20716 NodeId::new("node-materialization-test").unwrap(),
20717 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20718 vec![workspace],
20719 vec![agent("claude")],
20720 ProviderRuntimeStatuses::default(),
20721 None,
20722 Vec::new(),
20723 Vec::new(),
20724 SpawnProfileRegistry::default(),
20725 Some(control),
20726 None,
20727 Some(materializer),
20728 None,
20729 Vec::new(),
20730 Vec::new(),
20731 Vec::new(),
20732 Vec::new(),
20733 None,
20734 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20735 );
20736 shared
20737 .environment_profiles
20738 .write()
20739 .unwrap_or_else(|poisoned| poisoned.into_inner())
20740 .insert(binding.id.clone(), binding);
20741 shared
20742 .environment_materialization_profiles
20743 .write()
20744 .unwrap_or_else(|poisoned| poisoned.into_inner())
20745 .insert(profile_id.clone(), materialization.unwrap());
20746 (
20747 shared,
20748 runtime,
20749 ResolvedEnvironmentProfileReceipt {
20750 profile_id,
20751 profile_revision,
20752 network_allowlist: None,
20753 browser_profile_id: None,
20754 },
20755 root,
20756 deny,
20757 )
20758 }
20759
20760 #[cfg(feature = "fixture")]
20761 #[derive(Clone, Copy)]
20762 enum CodexHomeProfile {
20763 Missing,
20764 WrongClass,
20765 Exact,
20766 }
20767
20768 #[cfg(feature = "fixture")]
20769 fn codex_bundle_test_shared(
20770 home_profile: CodexHomeProfile,
20771 ) -> (
20772 NodeShared,
20773 NativeRuntime,
20774 ResolvedEnvironmentProfileReceipt,
20775 NodeBundle,
20776 PathBuf,
20777 ) {
20778 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20779 const CLAUDE_MANIFEST: &[u8] =
20780 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20781 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20782 const DIGEST: &str =
20783 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20784
20785 let (shared, mut runtime, _claude_receipt, root, _deny) =
20786 materialization_test_shared(false);
20787 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20788 CodexHomeProfile::Missing => "codex-home-missing",
20789 CodexHomeProfile::WrongClass => "codex-home-wrong",
20790 CodexHomeProfile::Exact => "codex-home-exact",
20791 })
20792 .unwrap();
20793 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20794 "codex-home-r1",
20795 )
20796 .unwrap();
20797 let materialization = match home_profile {
20798 CodexHomeProfile::Missing => None,
20799 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20800 Some(NodeSessionMaterializationProfile::new(
20801 Vec::new(),
20802 vec![NodeSessionPathBinding::new(
20803 "CODEX_HOME",
20804 if matches!(home_profile, CodexHomeProfile::Exact) {
20805 NodeSessionPathClass::ProviderHome
20806 } else {
20807 NodeSessionPathClass::Config
20808 },
20809 )
20810 .unwrap()],
20811 Vec::new(),
20812 )
20813 .unwrap())
20814 }
20815 };
20816 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20817 profile_id.clone(),
20818 profile_revision.clone(),
20819 agent("codex"),
20820 [NativeLaunchProfile::new(
20821 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20822 agent("codex"),
20823 TransportKind::Pty,
20824 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20825 Arc::new(EmptyEnvironmentResolver),
20826 )
20827 .unwrap()],
20828 materialization,
20829 )
20830 .unwrap();
20831 let (binding, native_profiles, materialization) = node_profile.into_parts();
20832 for native_profile in native_profiles {
20833 runtime.upsert_native_launch_profile(native_profile).unwrap();
20834 }
20835 shared
20836 .environment_profiles
20837 .write()
20838 .unwrap_or_else(|poisoned| poisoned.into_inner())
20839 .insert(binding.id.clone(), binding);
20840 if let Some(materialization) = materialization {
20841 shared
20842 .environment_materialization_profiles
20843 .write()
20844 .unwrap_or_else(|poisoned| poisoned.into_inner())
20845 .insert(profile_id.clone(), materialization);
20846 }
20847
20848 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20849 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20850 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20851 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20852 std::fs::write(
20853 bundle_source.join(".claude-plugin/plugin.json"),
20854 CLAUDE_MANIFEST,
20855 )
20856 .unwrap();
20857 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20858 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20859 let bundle = NodeBundle::new(
20860 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20861 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20862 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20863 &bundle_source,
20864 )
20865 .unwrap();
20866 *shared
20867 .bundle_catalog
20868 .write()
20869 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20870 BundleCatalog::new([bundle.clone()]).unwrap();
20871 (
20872 shared,
20873 runtime,
20874 ResolvedEnvironmentProfileReceipt {
20875 profile_id,
20876 profile_revision,
20877 network_allowlist: None,
20878 browser_profile_id: None,
20879 },
20880 bundle,
20881 root,
20882 )
20883 }
20884
20885 #[cfg(feature = "fixture")]
20886 #[test]
20887 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20888 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20889 let (shared, runtime, receipt, bundle, root) =
20890 codex_bundle_test_shared(home_profile);
20891 let error = shared
20892 .bundle_layout(
20893 &agent("codex"),
20894 SessionMode::Pty,
20895 Some(&receipt),
20896 &bundle,
20897 )
20898 .unwrap_err();
20899 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20900 assert!(shared
20901 .materializations
20902 .lock()
20903 .unwrap_or_else(|poisoned| poisoned.into_inner())
20904 .is_empty());
20905 let materialization_root = root.join("session-environments");
20906 assert!(!std::fs::read_dir(&materialization_root)
20907 .unwrap()
20908 .filter_map(Result::ok)
20909 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20910 drop(shared);
20911 drop(runtime);
20912 let _ = std::fs::remove_dir_all(root);
20913 }
20914 }
20915
20916 #[cfg(feature = "fixture")]
20917 #[test]
20918 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20919 let (shared, runtime, receipt, bundle, root) =
20920 codex_bundle_test_shared(CodexHomeProfile::Exact);
20921 let address = SessionAddress {
20922 workspace_id: WorkspaceId::new("primary").unwrap(),
20923 session: SessionKey {
20924 instance_id: AgentInstanceId(310),
20925 generation: SessionGeneration(1),
20926 },
20927 };
20928 let (overlay, guard) = shared
20929 .prepare_session_materialization(
20930 &address,
20931 &agent("codex"),
20932 SessionMode::Pty,
20933 Some(&receipt),
20934 Some(&bundle.receipt()),
20935 None,
20936 None,
20937 )
20938 .unwrap()
20939 .unwrap();
20940 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20941 let ownership = shared
20942 .materializations
20943 .lock()
20944 .unwrap_or_else(|poisoned| poisoned.into_inner())
20945 .get(guard.id().unwrap())
20946 .unwrap()
20947 .clone();
20948 let (profile, _, _) = shared
20949 .materialization_profile(
20950 &agent("codex"),
20951 SessionMode::Pty,
20952 Some(&receipt),
20953 Some(&bundle.receipt()),
20954 None,
20955 )
20956 .unwrap()
20957 .unwrap();
20958 let environment = shared
20959 .session_environment_materializer
20960 .as_ref()
20961 .unwrap()
20962 .resolve_environment(&ownership, &profile)
20963 .unwrap();
20964 assert!(environment.iter().any(|mutation| {
20965 mutation.key == OsString::from("CODEX_HOME")
20966 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20967 }));
20968 assert_eq!(
20969 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20970 .unwrap(),
20971 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20972 );
20973 assert!(std::fs::read_dir(ownership.bundle_root())
20974 .unwrap()
20975 .next()
20976 .is_none());
20977 drop(guard);
20978 drop(shared);
20979 drop(runtime);
20980 let _ = std::fs::remove_dir_all(root);
20981 }
20982
20983 #[cfg(feature = "fixture")]
20984 #[test]
20985 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20986 let (shared, runtime, receipt, bundle, root) =
20987 codex_bundle_test_shared(CodexHomeProfile::Exact);
20988 let bundle_receipt = bundle.receipt();
20989 let address = SessionAddress {
20990 workspace_id: WorkspaceId::new("primary").unwrap(),
20991 session: SessionKey {
20992 instance_id: AgentInstanceId(311),
20993 generation: SessionGeneration(1),
20994 },
20995 };
20996 let (_overlay, guard) = shared
20997 .prepare_session_materialization(
20998 &address,
20999 &agent("codex"),
21000 SessionMode::Pty,
21001 Some(&receipt),
21002 Some(&bundle_receipt),
21003 None,
21004 None,
21005 )
21006 .unwrap()
21007 .unwrap();
21008 let materialization_id = guard.id().unwrap().clone();
21009 let skill_path = shared
21010 .materializations
21011 .lock()
21012 .unwrap_or_else(|poisoned| poisoned.into_inner())
21013 .get(&materialization_id)
21014 .unwrap()
21015 .provider_home()
21016 .join("skills/review-code/SKILL.md");
21017 let record_id = shared
21018 .bind_spawn_session_with_materialization(
21019 &address,
21020 agent("codex"),
21021 SessionMode::Pty,
21022 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21023 SpawnRecordPolicy::ProviderIdentityOnly,
21024 Some(receipt.clone()),
21025 Some(bundle_receipt.clone()),
21026 None,
21027 Some(materialization_id.clone()),
21028 )
21029 .unwrap()
21030 .unwrap();
21031 guard.retain();
21032 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21033
21034 let error = match shared.resolve_record_materialization(
21035 &record_id,
21036 &agent("codex"),
21037 SessionMode::Pty,
21038 Some(&receipt),
21039 Some(&bundle_receipt),
21040 None,
21041 ) {
21042 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21043 Err(error) => error,
21044 };
21045 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21046 assert_eq!(
21047 shared
21048 .materializations
21049 .lock()
21050 .unwrap_or_else(|poisoned| poisoned.into_inner())
21051 .get(&materialization_id)
21052 .unwrap()
21053 .state(),
21054 MaterializationState::RecoveryRequired,
21055 );
21056 drop(shared);
21057 drop(runtime);
21058 let _ = std::fs::remove_dir_all(root);
21059 }
21060
21061 fn bind_record_owned_materialization_fixture(
21062 shared: &NodeShared,
21063 receipt: &ResolvedEnvironmentProfileReceipt,
21064 instance_id: u64,
21065 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21066 let address = SessionAddress {
21067 workspace_id: WorkspaceId::new("primary").unwrap(),
21068 session: SessionKey {
21069 instance_id: AgentInstanceId(instance_id),
21070 generation: SessionGeneration(1),
21071 },
21072 };
21073 let (overlay, materialization_guard) = shared
21074 .prepare_session_materialization(
21075 &address,
21076 &agent("claude"),
21077 SessionMode::Pty,
21078 Some(receipt),
21079 None,
21080 None,
21081 None,
21082 )
21083 .unwrap()
21084 .unwrap();
21085 let materialization_id = materialization_guard.id().unwrap().clone();
21086 let materialization_root = shared
21087 .materializations
21088 .lock()
21089 .unwrap_or_else(|poisoned| poisoned.into_inner())
21090 .get(&materialization_id)
21091 .unwrap()
21092 .root()
21093 .to_path_buf();
21094 let selection = shared
21095 .select_environment_profile(
21096 address.session.instance_id,
21097 &agent("claude"),
21098 SessionMode::Pty,
21099 Some(receipt),
21100 )
21101 .unwrap()
21102 .unwrap();
21103 assert!(shared
21104 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21105 .unwrap()
21106 .is_none());
21107 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21108 let record_id = shared
21109 .bind_spawn_session_with_materialization(
21110 &address,
21111 agent("claude"),
21112 SessionMode::Pty,
21113 policy,
21114 SpawnRecordPolicy::ProviderIdentityOnly,
21115 Some(receipt.clone()),
21116 None,
21117 None,
21118 Some(materialization_id.clone()),
21119 )
21120 .unwrap()
21121 .unwrap();
21122 selection.retain();
21123 materialization_guard.retain();
21124 (address, record_id, materialization_id, materialization_root)
21125 }
21126
21127 #[test]
21128 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21129 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21130 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21131 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21132 }));
21133 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21134 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21135 let mut spec = spawn_spec_fixture();
21136 spec.expected_profile_revision =
21137 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21138 assert!(matches!(
21139 &spec.overrides.environment_profile_id,
21140 crate::protocol::SpawnOverride::Inherit,
21141 ));
21142 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21143 .requires_child_environment_profile_capability());
21144 assert!(request_requires_child_environment_profile(
21145 &shared,
21146 &NodeRequest::SpawnSpec { spec: spec.clone() },
21147 ));
21148
21149 let address = terminal_address(1);
21150 shared.bind_session_with_policy(
21151 &address,
21152 ProviderRuntimePolicy::raw_pty(),
21153 Some(environment_profile.clone()),
21154 );
21155 assert!(request_requires_child_environment_profile(
21156 &shared,
21157 &NodeRequest::Input {
21158 session: address.clone(),
21159 text: "x".to_owned(),
21160 },
21161 ));
21162
21163 let mut record = record("claude", "profile-record");
21164 record.active_session = Some(address.clone());
21165 record.environment_profile = Some(environment_profile.clone());
21166 let record_id = record.record_id.clone();
21167 shared.insert_record(record.clone()).unwrap();
21168 assert!(request_requires_child_environment_profile(
21169 &shared,
21170 &NodeRequest::ResumeSessionRecord {
21171 record_id,
21172 terminal_size: gate4agent_types::TerminalSize {
21173 rows: 24,
21174 columns: 80,
21175 },
21176 initial_prompt: None,
21177 },
21178 ));
21179
21180 let snapshot = snapshot_for_wire(
21181 &shared, false, true, true, true, false, false, false, false, true,
21182 );
21183 assert_eq!(snapshot.session_records.len(), 1);
21184 assert!(snapshot.session_records[0].environment_profile.is_none());
21185 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21186 sequence: 1,
21187 event: NodeEvent::SessionRecordUpserted { record },
21188 });
21189 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21190 panic!("record projection changed the event kind");
21191 };
21192 assert!(record.environment_profile.is_none());
21193
21194 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21195 let mut reply = ResponseEnvelope {
21196 request_id: 1,
21197 result: Ok(NodeResponse::SpawnSpecAccepted {
21198 receipt: resolved.receipt_with_environment(
21199 shared.incarnation_id,
21200 address,
21201 Some(environment_profile),
21202 ),
21203 }),
21204 };
21205 project_response_without_child_environment_profile(&mut reply);
21206 assert_eq!(
21207 reply.result.unwrap_err().code,
21208 NodeFailureCode::UnsupportedCapability,
21209 );
21210 }
21211
21212 #[test]
21213 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21214 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21215 let snapshot = shared.snapshot();
21216 let profiles = snapshot
21217 .launch_inventory
21218 .as_ref()
21219 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21220 .unwrap();
21221 assert_eq!(profiles.len(), 1);
21222 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21223 assert!(snapshot.requires_child_environment_profile_capability());
21224
21225 let mut legacy = snapshot.clone();
21226 clear_snapshot_child_environment_profiles(&mut legacy);
21227 assert!(legacy
21228 .launch_inventory
21229 .as_ref()
21230 .unwrap()
21231 .spawn_profiles
21232 .as_ref()
21233 .unwrap()[0]
21234 .environment_profile
21235 .is_none());
21236 assert!(!legacy.requires_child_environment_profile_capability());
21237
21238 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21239 let bindings = shared
21240 .environment_profiles
21241 .read()
21242 .unwrap_or_else(|poisoned| poisoned.into_inner())
21243 .clone();
21244 let mut provider_mismatch = bindings.clone();
21245 provider_mismatch
21246 .values_mut()
21247 .next()
21248 .unwrap()
21249 .provider = agent("codex");
21250 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21251 let mut mode_mismatch = profile;
21252 mode_mismatch.mode = SessionMode::Inline;
21253 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21254
21255 shared
21256 .environment_profiles
21257 .write()
21258 .unwrap_or_else(|poisoned| poisoned.into_inner())
21259 .clear();
21260 assert!(shared
21261 .snapshot()
21262 .launch_inventory
21263 .unwrap()
21264 .spawn_profiles
21265 .unwrap()
21266 .is_empty());
21267 }
21268
21269 #[test]
21270 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21271 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21272 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21273 }));
21274 let shared = terminal_test_shared();
21275 let bundle = ResolvedBundleReceipt {
21276 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21277 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21278 digest: crate::protocol::SpawnBundleDigest::new(format!(
21279 "sha256:{}",
21280 "0".repeat(64),
21281 ))
21282 .unwrap(),
21283 };
21284 let mut bundled = record("claude", "bundle-record");
21285 bundled.bundle = Some(bundle.clone());
21286 let record_id = bundled.record_id.clone();
21287 shared.insert_record(bundled.clone()).unwrap();
21288 assert!(request_requires_session_bundle(
21289 &shared,
21290 &NodeRequest::ForgetSessionRecord {
21291 record_id: record_id.clone(),
21292 },
21293 ));
21294
21295 let snapshot = snapshot_for_wire(
21296 &shared, false, true, true, true, true, false, false, false, true,
21297 );
21298 assert!(snapshot.session_records[0].bundle.is_none());
21299 let launch = snapshot.launch_inventory.as_ref().unwrap();
21300 assert!(launch.spawn_profiles.is_some());
21301 assert!(launch.bundles.is_none());
21302 let event = project_event_without_session_bundle(NodeEventEnvelope {
21303 sequence: 1,
21304 event: NodeEvent::SessionRecordUpserted { record: bundled },
21305 });
21306 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21307 panic!("record projection changed the event kind");
21308 };
21309 assert!(record.bundle.is_none());
21310
21311 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21312 let mut reply = ResponseEnvelope {
21313 request_id: 1,
21314 result: Ok(NodeResponse::SpawnSpecAccepted {
21315 receipt: resolved.receipt_with_materialization(
21316 shared.incarnation_id,
21317 terminal_address(17),
21318 None,
21319 Some(bundle),
21320 None,
21321 ),
21322 }),
21323 };
21324 project_response_without_session_bundle(&mut reply);
21325 assert_eq!(
21326 reply.result.unwrap_err().code,
21327 NodeFailureCode::UnsupportedCapability,
21328 );
21329 }
21330
21331 #[test]
21332 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21333 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21334 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21335 }));
21336 let context_id = SpawnContextId::new("context-review").unwrap();
21337 let context = ResolvedContextPackReceipt {
21338 id: context_id.clone(),
21339 digest: crate::protocol::SpawnContextDigest::new(format!(
21340 "sha256:{}",
21341 "c".repeat(64),
21342 ))
21343 .unwrap(),
21344 lineage: ContextPackLineageReceipt {
21345 source_node_id: NodeId::new("node-source").unwrap(),
21346 source_session: terminal_address(1),
21347 source_provider: agent("kimi"),
21348 },
21349 source_message_count: 2,
21350 retained_message_count: 2,
21351 byte_len: 256,
21352 truncated: false,
21353 };
21354 assert!(context.is_valid());
21355 let mut bound_record = record("codex", "context-record");
21356 bound_record.context_id = Some(context_id);
21357 bound_record.context = Some(context.clone());
21358 let session = gate4agent_types::SessionSnapshot {
21359 instance_id: AgentInstanceId(91),
21360 agent_id: agent("kimi"),
21361 transport: TransportKind::Pty,
21362 generation: SessionGeneration(3),
21363 status: SessionStatus::Running,
21364 pending_operation: None,
21365 pending_input: None,
21366 process_id: Some(9001),
21367 terminal_size: None,
21368 terminal_frame: None,
21369 terminal_stale: None,
21370 session_options: None,
21371 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21372 history: gate4agent_types::HistorySnapshot {
21373 pending: None,
21374 candidates: vec![HistoryCandidateSummary {
21375 id: "candidate-opaque".to_owned(),
21376 session_id_hint: "session-hint".to_owned(),
21377 modified_at_unix_ms: Some(1),
21378 }],
21379 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21380 loaded: Some(HistorySessionRecord {
21381 session_id: "private-vendor-session".to_owned(),
21382 title: Some("private title".to_owned()),
21383 cwd: Some(r"C:\private\history-root".to_owned()),
21384 model: Some("private-model".to_owned()),
21385 message_count: 2,
21386 completed_turn_count: None,
21387 total_tokens: 19,
21388 messages: vec![
21389 gate4agent_types::HistoryMessageRecord {
21390 role: gate4agent_types::HistoryMessageRole::User,
21391 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21392 },
21393 gate4agent_types::HistoryMessageRecord {
21394 role: gate4agent_types::HistoryMessageRole::Assistant,
21395 text: "private answer".to_owned(),
21396 },
21397 ],
21398 }),
21399 last_error: None,
21400 },
21401 resume: gate4agent_types::ResumeSnapshot::default(),
21402 foreground: gate4agent_types::ForegroundSnapshot::default(),
21403 provider: gate4agent_types::ProviderSnapshot::default(),
21404 screen_state: gate4agent_types::PtyScreenState::default(),
21405 };
21406 let original = NodeSnapshot {
21407 node_id: NodeId::new("node-terminal-test").unwrap(),
21408 enabled_providers: vec![agent("kimi"), agent("codex")],
21409 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21410 workspaces: vec![WorkspaceSnapshot {
21411 workspace_id: WorkspaceId::new("primary").unwrap(),
21412 canonical_root: opaque_windows_path(
21413 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21414 ),
21415 sessions: vec![session],
21416 worktree_service_mode: None,
21417 managed_worktree_profiles: None,
21418 }],
21419 session_records: vec![bound_record],
21420 managed_worktrees: Vec::new(),
21421 launch_inventory: None,
21422 agent_progress: Vec::new(),
21423 };
21424
21425 let mut negotiated = original.clone();
21426 project_snapshot_history_for_wire(&mut negotiated, true);
21427 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21428 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21429 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21430 assert!(!serde_json::to_string(&negotiated)
21431 .unwrap()
21432 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21433
21434 let mut legacy = original;
21435 project_snapshot_history_for_wire(&mut legacy, false);
21436 clear_snapshot_context_packs(&mut legacy);
21437 assert_eq!(
21438 legacy.workspaces[0].sessions[0].history,
21439 gate4agent_types::HistorySnapshot::default(),
21440 );
21441 assert!(legacy.session_records[0].context_id.is_none());
21442 assert!(legacy.session_records[0].context.is_none());
21443 let encoded = serde_json::to_string(&legacy).unwrap();
21444 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21445 assert!(!encoded.contains("context-review"));
21446
21447 let mut reply = ResponseEnvelope {
21448 request_id: 7,
21449 result: Ok(NodeResponse::ContextPackExported { context }),
21450 };
21451 project_response_without_context_pack(&mut reply);
21452 assert_eq!(
21453 reply.result.unwrap_err().code,
21454 NodeFailureCode::UnsupportedCapability,
21455 );
21456 }
21457
21458 #[test]
21459 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21460 let shared = terminal_test_shared();
21461 let history = HistorySessionRecord {
21462 session_id: "fixture-history".to_owned(),
21463 title: None,
21464 cwd: None,
21465 model: None,
21466 message_count: 1,
21467 completed_turn_count: None,
21468 total_tokens: 0,
21469 messages: vec![gate4agent_types::HistoryMessageRecord {
21470 role: gate4agent_types::HistoryMessageRole::User,
21471 text: "bounded handoff".to_owned(),
21472 }],
21473 };
21474 let pack = NodeContextPack::export(
21475 ContextPackLineageReceipt {
21476 source_node_id: shared.node_id.clone(),
21477 source_session: terminal_address(1),
21478 source_provider: agent("claude"),
21479 },
21480 &history,
21481 )
21482 .unwrap();
21483 let context = shared
21484 .context_catalog
21485 .write()
21486 .unwrap_or_else(|poisoned| poisoned.into_inner())
21487 .insert(pack)
21488 .unwrap();
21489 let mut retained = record("codex", "context-retained-record");
21490 retained.context_id = Some(context.id.clone());
21491 retained.context = Some(context.clone());
21492 let record_id = retained.record_id.clone();
21493 shared.insert_record(retained).unwrap();
21494 assert_eq!(
21495 shared.forget_context_pack(&context.id).unwrap_err().code,
21496 NodeFailureCode::ContextPackBusy,
21497 );
21498 assert!(shared
21499 .context_catalog
21500 .read()
21501 .unwrap_or_else(|poisoned| poisoned.into_inner())
21502 .get(&context.id)
21503 .is_some());
21504 shared
21505 .session_records
21506 .lock()
21507 .unwrap_or_else(|poisoned| poisoned.into_inner())
21508 .records
21509 .remove(&record_id);
21510
21511 let mut spec = spawn_spec_fixture();
21512 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21513 value: context.id.clone(),
21514 };
21515 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21516 let receipt = resolved.receipt_with_materialization(
21517 shared.incarnation_id,
21518 terminal_address(2),
21519 None,
21520 None,
21521 Some(context.clone()),
21522 );
21523 assert!(receipt.context_binding_is_valid());
21524 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21525
21526 shared.forget_context_pack(&context.id).unwrap();
21527 assert!(shared
21528 .context_catalog
21529 .read()
21530 .unwrap_or_else(|poisoned| poisoned.into_inner())
21531 .get(&context.id)
21532 .is_none());
21533 assert!(shared
21534 .replay_spawn_spec(&spec, Instant::now())
21535 .unwrap()
21536 .is_none());
21537 }
21538
21539 #[test]
21540 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21541 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21542 let address = terminal_address(1);
21543 let selection = shared
21544 .select_environment_profile(
21545 address.session.instance_id,
21546 &agent("claude"),
21547 SessionMode::Pty,
21548 Some(&environment_profile),
21549 )
21550 .unwrap()
21551 .unwrap();
21552 shared.bind_session_with_policy(
21553 &address,
21554 ProviderRuntimePolicy::raw_pty(),
21555 Some(environment_profile.clone()),
21556 );
21557 selection.retain();
21558 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21559 assert!(matches!(
21560 runtime.remove_native_launch_profile(&native_id),
21561 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21562 ));
21563 assert!(shared.remove_binding(&address).is_some());
21564 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21565
21566 let mismatched = ResolvedEnvironmentProfileReceipt {
21567 profile_id: environment_profile.profile_id,
21568 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21569 "local-claude-r2",
21570 )
21571 .unwrap(),
21572 network_allowlist: None,
21573 browser_profile_id: None,
21574 };
21575 let error = match shared.select_environment_profile(
21576 AgentInstanceId(42),
21577 &agent("claude"),
21578 SessionMode::Pty,
21579 Some(&mismatched),
21580 ) {
21581 Ok(_) => panic!("mismatched environment profile revision was selected"),
21582 Err(error) => error,
21583 };
21584 assert_eq!(
21585 error.code,
21586 NodeFailureCode::EnvironmentProfileBindingMismatch,
21587 );
21588 assert!(!shared
21589 .session_bindings
21590 .lock()
21591 .unwrap_or_else(|poisoned| poisoned.into_inner())
21592 .contains_key(&AgentInstanceId(42)));
21593 }
21594
21595 #[test]
21596 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21597 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21598 let address = terminal_address(1);
21599 let selection = shared
21600 .select_environment_profile(
21601 address.session.instance_id,
21602 &agent("claude"),
21603 SessionMode::Pty,
21604 Some(&environment_profile),
21605 )
21606 .unwrap()
21607 .unwrap();
21608 let overlay = NativeInstanceLaunchOverlay::new(
21609 agent("claude"),
21610 TransportKind::Pty,
21611 vec![EnvMutation {
21612 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21613 value: Some(OsString::from("fixture-secret")),
21614 }],
21615 vec![
21616 OsString::from("--plugin-dir"),
21617 std::env::current_dir().unwrap().into_os_string(),
21618 ],
21619 )
21620 .unwrap();
21621 let instance_overlay = shared
21622 .install_prepared_launch_overlay(
21623 address.session.instance_id,
21624 PreparedNativeLaunchOverlay::Instance(overlay),
21625 )
21626 .unwrap()
21627 .unwrap();
21628 let bundle = ResolvedBundleReceipt {
21629 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21630 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21631 digest: crate::protocol::SpawnBundleDigest::new(format!(
21632 "sha256:{}",
21633 "0".repeat(64),
21634 ))
21635 .unwrap(),
21636 };
21637 shared.bind_session_with_materialization(
21638 &address,
21639 ProviderRuntimePolicy::raw_pty(),
21640 Some(environment_profile),
21641 Some(bundle),
21642 None,
21643 None,
21644 );
21645 selection.retain();
21646 instance_overlay.retain();
21647
21648 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21649 assert!(matches!(
21650 runtime.remove_native_launch_profile(&native_id),
21651 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21652 ));
21653 assert!(shared.remove_binding(&address).is_some());
21654 assert!(!shared
21655 .native_launch_profile_control
21656 .as_ref()
21657 .unwrap()
21658 .clear_native_instance_launch_overlay(address.session.instance_id));
21659 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21660 }
21661
21662 #[test]
21663 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21664 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21665 let address = SessionAddress {
21666 workspace_id: WorkspaceId::new("primary").unwrap(),
21667 session: SessionKey {
21668 instance_id: AgentInstanceId(201),
21669 generation: SessionGeneration(1),
21670 },
21671 };
21672 let error = match shared.prepare_session_materialization(
21673 &address,
21674 &agent("claude"),
21675 SessionMode::Pty,
21676 Some(&receipt),
21677 None,
21678 None,
21679 None,
21680 ) {
21681 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21682 Err(error) => error,
21683 };
21684 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21685 assert!(shared.handle.snapshot().sessions.is_empty());
21686 assert!(shared
21687 .session_bindings
21688 .lock()
21689 .unwrap_or_else(|poisoned| poisoned.into_inner())
21690 .is_empty());
21691 assert!(shared
21692 .materializations
21693 .lock()
21694 .unwrap_or_else(|poisoned| poisoned.into_inner())
21695 .is_empty());
21696 drop(shared);
21697 drop(runtime);
21698 std::fs::remove_dir_all(root).unwrap();
21699 }
21700
21701 #[test]
21702 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21703 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21704 let address = SessionAddress {
21705 workspace_id: WorkspaceId::new("primary").unwrap(),
21706 session: SessionKey {
21707 instance_id: AgentInstanceId(202),
21708 generation: SessionGeneration(1),
21709 },
21710 };
21711 let (overlay, materialization_guard) = shared
21712 .prepare_session_materialization(
21713 &address,
21714 &agent("claude"),
21715 SessionMode::Pty,
21716 Some(&receipt),
21717 None,
21718 None,
21719 None,
21720 )
21721 .unwrap()
21722 .unwrap();
21723 let materialization_id = materialization_guard.id().unwrap().clone();
21724 let materialization_root = shared
21725 .materializations
21726 .lock()
21727 .unwrap_or_else(|poisoned| poisoned.into_inner())
21728 .get(&materialization_id)
21729 .unwrap()
21730 .root()
21731 .to_path_buf();
21732 let selection = shared
21733 .select_environment_profile(
21734 address.session.instance_id,
21735 &agent("claude"),
21736 SessionMode::Pty,
21737 Some(&receipt),
21738 )
21739 .unwrap()
21740 .unwrap();
21741 assert!(shared
21742 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21743 .unwrap()
21744 .is_none());
21745 shared.bind_session_with_materialization(
21746 &address,
21747 ProviderRuntimePolicy::raw_pty(),
21748 Some(receipt),
21749 None,
21750 None,
21751 Some(materialization_id),
21752 );
21753 selection.retain();
21754 materialization_guard.retain();
21755
21756 assert!(materialization_root.is_dir());
21757 assert!(shared
21758 .session_bindings
21759 .lock()
21760 .unwrap_or_else(|poisoned| poisoned.into_inner())
21761 .contains_key(&address.session.instance_id));
21762
21763 let removed = shared.remove_binding(&address).unwrap();
21764 shared
21765 .cleanup_session_owned_materialization(&address, &removed)
21766 .unwrap();
21767 assert!(!materialization_root.exists());
21768 assert!(shared
21769 .materializations
21770 .lock()
21771 .unwrap_or_else(|poisoned| poisoned.into_inner())
21772 .is_empty());
21773 drop(shared);
21774 drop(runtime);
21775 std::fs::remove_dir_all(root).unwrap();
21776 }
21777
21778 #[tokio::test]
21779 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21780 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21781 let address = SessionAddress {
21782 workspace_id: WorkspaceId::new("primary").unwrap(),
21783 session: SessionKey {
21784 instance_id: AgentInstanceId(203),
21785 generation: SessionGeneration(1),
21786 },
21787 };
21788 let (overlay, materialization_guard) = shared
21789 .prepare_session_materialization(
21790 &address,
21791 &agent("claude"),
21792 SessionMode::Pty,
21793 Some(&receipt),
21794 None,
21795 None,
21796 None,
21797 )
21798 .unwrap()
21799 .unwrap();
21800 let materialization_id = materialization_guard.id().unwrap().clone();
21801 let materialization_root = shared
21802 .materializations
21803 .lock()
21804 .unwrap_or_else(|poisoned| poisoned.into_inner())
21805 .get(&materialization_id)
21806 .unwrap()
21807 .root()
21808 .to_path_buf();
21809 let selection = shared
21810 .select_environment_profile(
21811 address.session.instance_id,
21812 &agent("claude"),
21813 SessionMode::Pty,
21814 Some(&receipt),
21815 )
21816 .unwrap()
21817 .unwrap();
21818 assert!(shared
21819 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21820 .unwrap()
21821 .is_none());
21822 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21823 let record_id = shared
21824 .bind_spawn_session_with_materialization(
21825 &address,
21826 agent("claude"),
21827 SessionMode::Pty,
21828 policy,
21829 SpawnRecordPolicy::ProviderIdentityOnly,
21830 Some(receipt.clone()),
21831 None,
21832 None,
21833 Some(materialization_id.clone()),
21834 )
21835 .unwrap()
21836 .unwrap();
21837 selection.retain();
21838 materialization_guard.retain();
21839 assert_eq!(
21840 shared
21841 .materializations
21842 .lock()
21843 .unwrap_or_else(|poisoned| poisoned.into_inner())
21844 .get(&materialization_id)
21845 .unwrap()
21846 .owner(),
21847 &MaterializationOwner::Record {
21848 record_id: record_id.clone(),
21849 },
21850 );
21851
21852 let removed = shared.remove_binding(&address).unwrap();
21853 shared
21854 .cleanup_session_owned_materialization(&address, &removed)
21855 .unwrap();
21856 assert!(materialization_root.is_dir());
21857 assert!(shared
21858 .resolve_record_materialization(
21859 &record_id,
21860 &agent("claude"),
21861 SessionMode::Pty,
21862 Some(&receipt),
21863 None,
21864 None,
21865 )
21866 .unwrap()
21867 .is_some());
21868 {
21869 let mut records = shared
21870 .session_records
21871 .lock()
21872 .unwrap_or_else(|poisoned| poisoned.into_inner());
21873 let record = records.records.get_mut(&record_id).unwrap();
21874 record.active_session = None;
21875 record.state = ManagedSessionState::Unavailable;
21876 }
21877 shared.forget_session_record(&record_id).await.unwrap();
21878 assert!(!materialization_root.exists());
21879 assert!(shared
21880 .materializations
21881 .lock()
21882 .unwrap_or_else(|poisoned| poisoned.into_inner())
21883 .is_empty());
21884 drop(shared);
21885 drop(runtime);
21886 std::fs::remove_dir_all(root).unwrap();
21887 }
21888
21889 #[test]
21890 fn record_materialization_revalidation_failure_persists_recovery_required() {
21891 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21892 let state_path = root.join("node-state-v6.json");
21893 shared.state_path = Some(state_path.clone());
21894 let (_address, _record_id, materialization_id, materialization_root) =
21895 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21896 std::fs::write(
21897 materialization_root.join(".gate4agent-materialization-owner"),
21898 b"tampered",
21899 )
21900 .unwrap();
21901
21902 let error = match shared.resolve_record_materialization(
21903 &_record_id,
21904 &agent("claude"),
21905 SessionMode::Pty,
21906 Some(&receipt),
21907 None,
21908 None,
21909 ) {
21910 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21911 Err(error) => error,
21912 };
21913 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21914 assert_eq!(
21915 shared
21916 .materializations
21917 .lock()
21918 .unwrap_or_else(|poisoned| poisoned.into_inner())
21919 .get(&materialization_id)
21920 .unwrap()
21921 .state(),
21922 MaterializationState::RecoveryRequired,
21923 );
21924 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21925 assert_eq!(loaded.materializations.len(), 1);
21926 assert_eq!(
21927 loaded.materializations[0].state(),
21928 MaterializationState::RecoveryRequired,
21929 );
21930 drop(shared);
21931 drop(runtime);
21932 std::fs::remove_dir_all(root).unwrap();
21933 }
21934
21935 #[test]
21936 fn transient_record_materialization_resolution_failure_remains_ready() {
21937 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21938 let (_address, record_id, materialization_id, _materialization_root) =
21939 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21940 deny.store(true, Ordering::Release);
21941
21942 let error = match shared.resolve_record_materialization(
21943 &record_id,
21944 &agent("claude"),
21945 SessionMode::Pty,
21946 Some(&receipt),
21947 None,
21948 None,
21949 ) {
21950 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21951 Err(error) => error,
21952 };
21953 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21954 assert_eq!(
21955 shared
21956 .materializations
21957 .lock()
21958 .unwrap_or_else(|poisoned| poisoned.into_inner())
21959 .get(&materialization_id)
21960 .unwrap()
21961 .state(),
21962 MaterializationState::Ready,
21963 );
21964 drop(shared);
21965 drop(runtime);
21966 std::fs::remove_dir_all(root).unwrap();
21967 }
21968
21969 #[test]
21970 fn startup_missing_record_materialization_is_not_resumable() {
21971 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21972 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21973 shared
21974 .insert_record(ManagedSessionRecord {
21975 record_id: record_id.clone(),
21976 display_name: "missing materialization".to_owned(),
21977 provider: agent("claude"),
21978 mode: SessionMode::Pty,
21979 state: ManagedSessionState::Dormant,
21980 workspace_id: WorkspaceId::new("primary").unwrap(),
21981 canonical_root: opaque_windows_path(
21982 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21983 ),
21984 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21985 key: gate4agent_types::ProviderSessionKey::SessionId,
21986 id: "provider-session-missing-materialization".to_owned(),
21987 transcript_path: None,
21988 }),
21989 active_session: None,
21990 environment_profile: Some(receipt),
21991 bundle: None,
21992 context_id: None,
21993 context: None,
21994 exported_context: None,
21995 task_binding: None,
21996 created_at_unix_ms: 1,
21997 updated_at_unix_ms: 1,
21998 last_error: None,
21999 })
22000 .unwrap();
22001
22002 shared.reconcile_materializations();
22003 let record = shared.record(&record_id).unwrap();
22004 assert_eq!(record.state, ManagedSessionState::Unavailable);
22005 assert!(record.provider_session.is_none());
22006 assert_eq!(
22007 record.last_error.as_deref(),
22008 Some("environment-profile-unavailable"),
22009 );
22010 drop(shared);
22011 drop(runtime);
22012 std::fs::remove_dir_all(root).unwrap();
22013 }
22014
22015 #[tokio::test]
22016 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22017 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22018 let lease = install_managed_lease(&shared);
22019 let address = SessionAddress {
22020 workspace_id: WorkspaceId::new("primary").unwrap(),
22021 session: SessionKey {
22022 instance_id: AgentInstanceId(204),
22023 generation: SessionGeneration(1),
22024 },
22025 };
22026 let (overlay, materialization_guard) = shared
22027 .prepare_session_materialization(
22028 &address,
22029 &agent("claude"),
22030 SessionMode::Pty,
22031 Some(&receipt),
22032 None,
22033 None,
22034 Some(lease.lease_id.clone()),
22035 )
22036 .unwrap()
22037 .unwrap();
22038 let materialization_id = materialization_guard.id().unwrap().clone();
22039 let materialization_root = shared
22040 .materializations
22041 .lock()
22042 .unwrap_or_else(|poisoned| poisoned.into_inner())
22043 .get(&materialization_id)
22044 .unwrap()
22045 .root()
22046 .to_path_buf();
22047 let selection = shared
22048 .select_environment_profile(
22049 address.session.instance_id,
22050 &agent("claude"),
22051 SessionMode::Pty,
22052 Some(&receipt),
22053 )
22054 .unwrap()
22055 .unwrap();
22056 assert!(shared
22057 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22058 .unwrap()
22059 .is_none());
22060 shared.bind_session_with_materialization(
22061 &address,
22062 ProviderRuntimePolicy::raw_pty(),
22063 Some(receipt),
22064 None,
22065 None,
22066 Some(materialization_id),
22067 );
22068 selection.retain();
22069 materialization_guard.retain();
22070
22071 let blocked = shared
22072 .cleanup_managed_worktree(&lease.lease_id, false)
22073 .await
22074 .unwrap_err();
22075 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22076 assert!(materialization_root.is_dir());
22077 let removed = shared.remove_binding(&address).unwrap();
22078 shared
22079 .cleanup_session_owned_materialization(&address, &removed)
22080 .unwrap();
22081 assert!(!materialization_root.exists());
22082 drop(shared);
22083 drop(runtime);
22084 std::fs::remove_dir_all(root).unwrap();
22085 }
22086
22087 #[tokio::test]
22088 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22089 let git_root = temporary_workspace_root("raw-managed-inventory");
22090 let source = git_root.join("source");
22091 let allocation = git_root.join("allocation");
22092 std::fs::create_dir_all(&source).unwrap();
22093 std::fs::create_dir_all(&allocation).unwrap();
22094 run_test_git(&source, &["init"]);
22095 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22096 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22097 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22098 run_test_git(&source, &["add", "seed.txt"]);
22099 run_test_git(&source, &["commit", "-m", "seed"]);
22100 let profile = ManagedWorktreeProfile::new(
22101 WorktreeProfileId::new("default").unwrap(),
22102 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22103 &allocation,
22104 "gate4agent",
22105 "HEAD",
22106 ManagedWorktreeRetention::RemoveWhenReleased,
22107 )
22108 .unwrap();
22109 let workspace = WorkspaceConfig::new(
22110 WorkspaceId::new("primary").unwrap(),
22111 &source,
22112 )
22113 .unwrap()
22114 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22115 .with_managed_worktree_profile(profile.clone())
22116 .unwrap();
22117 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22118 materialization_test_shared_with_workspace(false, workspace);
22119 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22120 let source_text = source.to_string_lossy().into_owned();
22121 let target_text = target.to_string_lossy().into_owned();
22122 let created = create_git_worktree(
22123 &source_text,
22124 &target_text,
22125 "gate4agent/mw-raw-inventory",
22126 Some("HEAD"),
22127 )
22128 .await
22129 .unwrap();
22130 let lease = ManagedWorktreeLeaseRecord {
22131 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22132 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22133 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22134 profile_id: profile.profile_id().clone(),
22135 profile_revision: profile.revision().clone(),
22136 target_root: created.path.clone(),
22137 branch: "gate4agent/mw-raw-inventory".to_owned(),
22138 base_commit: created.head.clone(),
22139 expected_head: Some(created.head.clone()),
22140 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22141 state: ManagedWorktreeLeaseState::Ready,
22142 session_holders: Vec::new(),
22143 record_holders: Vec::new(),
22144 cleanup_failure: None,
22145 created_at_unix_ms: 1,
22146 updated_at_unix_ms: 1,
22147 };
22148 shared
22149 .workspaces
22150 .write()
22151 .unwrap_or_else(|poisoned| poisoned.into_inner())
22152 .insert(lease.workspace_id.clone(), created.path.clone());
22153 *shared
22154 .managed_worktrees
22155 .lock()
22156 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22157 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22158 let address = SessionAddress {
22159 workspace_id: lease.workspace_id.clone(),
22160 session: SessionKey {
22161 instance_id: AgentInstanceId(205),
22162 generation: SessionGeneration(1),
22163 },
22164 };
22165 let (overlay, materialization_guard) = shared
22166 .prepare_session_materialization(
22167 &address,
22168 &agent("claude"),
22169 SessionMode::Pty,
22170 Some(&receipt),
22171 None,
22172 None,
22173 Some(lease.lease_id.clone()),
22174 )
22175 .unwrap()
22176 .unwrap();
22177 let materialization_id = materialization_guard.id().unwrap().clone();
22178 let materialization_root = shared
22179 .materializations
22180 .lock()
22181 .unwrap_or_else(|poisoned| poisoned.into_inner())
22182 .get(&materialization_id)
22183 .unwrap()
22184 .root()
22185 .to_path_buf();
22186 let selection = shared
22187 .select_environment_profile(
22188 address.session.instance_id,
22189 &agent("claude"),
22190 SessionMode::Pty,
22191 Some(&receipt),
22192 )
22193 .unwrap()
22194 .unwrap();
22195 assert!(shared
22196 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22197 .unwrap()
22198 .is_none());
22199 let record_id = shared
22200 .bind_spawn_session_with_materialization(
22201 &address,
22202 agent("claude"),
22203 SessionMode::Pty,
22204 ProviderRuntimePolicy::raw_pty(),
22205 SpawnRecordPolicy::Always,
22206 Some(receipt.clone()),
22207 None,
22208 None,
22209 Some(materialization_id.clone()),
22210 )
22211 .unwrap()
22212 .unwrap();
22213 selection.retain();
22214 materialization_guard.retain();
22215 let record = shared.record(&record_id).unwrap();
22216 assert_eq!(record.state, ManagedSessionState::Live);
22217 assert!(record.provider_session.is_none());
22218 assert!(record.environment_profile.is_none());
22219 assert!(record.bundle.is_none());
22220 assert!(record.context_id.is_none());
22221 assert!(record.context.is_none());
22222 assert_eq!(record.active_session.as_ref(), Some(&address));
22223 assert_eq!(
22224 shared
22225 .materializations
22226 .lock()
22227 .unwrap_or_else(|poisoned| poisoned.into_inner())
22228 .get(&materialization_id)
22229 .unwrap()
22230 .owner(),
22231 &MaterializationOwner::Session {
22232 incarnation_id: shared.incarnation_id,
22233 instance_id: address.session.instance_id,
22234 generation: address.session.generation,
22235 },
22236 );
22237 {
22238 let mut bindings = shared
22239 .session_bindings
22240 .lock()
22241 .unwrap_or_else(|poisoned| poisoned.into_inner());
22242 bindings
22243 .get_mut(&address.session.instance_id)
22244 .unwrap()
22245 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22246 }
22247 let lease_snapshot = shared
22248 .managed_worktrees
22249 .lock()
22250 .unwrap_or_else(|poisoned| poisoned.into_inner())
22251 .bind_session(
22252 &lease.lease_id,
22253 ManagedWorktreeSessionHolder {
22254 incarnation_id: shared.incarnation_id,
22255 instance_id: address.session.instance_id,
22256 generation: address.session.generation,
22257 },
22258 None,
22259 unix_time_ms(),
22260 )
22261 .unwrap();
22262 assert_eq!(lease_snapshot.active_session_count, 1);
22263 assert_eq!(lease_snapshot.managed_record_count, 0);
22264 shared.persist_state().unwrap();
22265 assert!(shared
22266 .managed_worktrees
22267 .lock()
22268 .unwrap_or_else(|poisoned| poisoned.into_inner())
22269 .get(&lease.lease_id)
22270 .unwrap()
22271 .record_holders
22272 .is_empty());
22273 shared
22274 .handle
22275 .dispatch(shared.prepare_command(ControlCommand::Register {
22276 instance_id: address.session.instance_id,
22277 agent_id: agent("claude"),
22278 transport: TransportKind::Pty,
22279 }))
22280 .unwrap();
22281 runtime.tick().await;
22282 shared.reconcile_managed_record(
22283 &record_id,
22284 &address,
22285 &ControlEvent {
22286 sequence: 1,
22287 command_id: None,
22288 instance_id: address.session.instance_id,
22289 generation: address.session.generation,
22290 event: ControlEventKind::Removed,
22291 },
22292 false,
22293 false,
22294 );
22295 let remove = shared.remove_session(&address);
22296 let drive_runtime = async {
22297 runtime.tick().await;
22298 };
22299 let (removed, ()) = tokio::join!(remove, drive_runtime);
22300 removed.unwrap();
22301
22302 assert!(!materialization_root.exists());
22303 assert!(!Path::new(&created.path).exists());
22304 assert_eq!(
22305 shared
22306 .managed_worktrees
22307 .lock()
22308 .unwrap_or_else(|poisoned| poisoned.into_inner())
22309 .get(&lease.lease_id)
22310 .unwrap()
22311 .state,
22312 ManagedWorktreeLeaseState::Removed,
22313 );
22314 let renamed = shared
22315 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22316 .unwrap();
22317 assert_eq!(renamed.record_id, record_id);
22318 assert_eq!(renamed.display_name, "raw managed inventory");
22319 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22320 assert!(renamed.active_session.is_none());
22321
22322 drop(shared);
22323 drop(runtime);
22324 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22325 std::fs::remove_dir_all(git_root).unwrap();
22326 }
22327
22328 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22329 let root = std::env::current_dir().unwrap()
22330 .join("managed-test-target")
22331 .to_string_lossy()
22332 .into_owned();
22333 let lease = ManagedWorktreeLeaseRecord {
22334 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22335 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22336 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22337 profile_id: WorktreeProfileId::new("default").unwrap(),
22338 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22339 target_root: root.clone(),
22340 branch: "gate4agent/mw-test".to_owned(),
22341 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22342 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22343 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22344 state: ManagedWorktreeLeaseState::Ready,
22345 session_holders: Vec::new(),
22346 record_holders: Vec::new(),
22347 cleanup_failure: None,
22348 created_at_unix_ms: 1,
22349 updated_at_unix_ms: 1,
22350 };
22351 shared.workspaces.write()
22352 .unwrap_or_else(|poisoned| poisoned.into_inner())
22353 .insert(lease.workspace_id.clone(), root);
22354 *shared.managed_worktrees.lock()
22355 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22356 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22357 lease
22358 }
22359
22360 #[cfg(feature = "fixture")]
22361 #[test]
22362 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22363 let mut shared = terminal_test_shared();
22364 shared.fixture_semantic_hook_policy = true;
22365 let lease = install_managed_lease(&shared);
22366 let address = SessionAddress {
22367 workspace_id: lease.workspace_id.clone(),
22368 session: SessionKey {
22369 instance_id: AgentInstanceId(206),
22370 generation: SessionGeneration(1),
22371 },
22372 };
22373 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22374 let effective_runtime_policy =
22375 shared.effective_spawn_runtime_policy(pre_admission_policy);
22376 assert_ne!(effective_runtime_policy, pre_admission_policy);
22377 assert!(effective_runtime_policy.provider_session_identity);
22378
22379 let record_id = shared
22380 .bind_spawn_session(
22381 &address,
22382 agent("claude"),
22383 SessionMode::Pty,
22384 effective_runtime_policy,
22385 SpawnRecordPolicy::Always,
22386 None,
22387 )
22388 .unwrap()
22389 .unwrap();
22390 assert_eq!(
22391 shared.record(&record_id).unwrap().state,
22392 ManagedSessionState::IdentityPending,
22393 );
22394 let (record_holder, raw_inventory_record_id) = {
22395 let mut bindings = shared
22396 .session_bindings
22397 .lock()
22398 .unwrap_or_else(|poisoned| poisoned.into_inner());
22399 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22400 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22401 managed_spawn_record_ownership(binding, effective_runtime_policy)
22402 };
22403 assert_eq!(record_holder.as_ref(), Some(&record_id));
22404 assert!(raw_inventory_record_id.is_none());
22405 let snapshot = shared
22406 .managed_worktrees
22407 .lock()
22408 .unwrap_or_else(|poisoned| poisoned.into_inner())
22409 .bind_session(
22410 &lease.lease_id,
22411 ManagedWorktreeSessionHolder {
22412 incarnation_id: shared.incarnation_id,
22413 instance_id: address.session.instance_id,
22414 generation: address.session.generation,
22415 },
22416 record_holder,
22417 unix_time_ms(),
22418 )
22419 .unwrap();
22420 assert_eq!(snapshot.managed_record_count, 1);
22421 assert_eq!(
22422 shared
22423 .managed_worktrees
22424 .lock()
22425 .unwrap_or_else(|poisoned| poisoned.into_inner())
22426 .get(&lease.lease_id)
22427 .unwrap()
22428 .record_holders,
22429 vec![record_id.clone()],
22430 );
22431
22432 let identity = ProviderSessionIdentity {
22433 key: ProviderSessionKey::SessionId,
22434 id: "monitoring-fixture-managed-session".to_owned(),
22435 transcript_path: None,
22436 };
22437 shared.publish_control(ControlEvent {
22438 sequence: 1,
22439 command_id: None,
22440 instance_id: address.session.instance_id,
22441 generation: address.session.generation,
22442 event: ControlEventKind::ProviderEvent {
22443 sequence: 1,
22444 source: gate4agent_types::ProviderSource {
22445 family: AdapterFamily::Hook,
22446 binding: AdapterBinding::new(
22447 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22448 "fixture/v1",
22449 gate4agent_types::AdapterVerification::SyntheticFixture,
22450 )
22451 .unwrap(),
22452 },
22453 source_sequence: 1,
22454 event: ProviderEvent::SessionIdentityObserved {
22455 identity: identity.clone(),
22456 },
22457 },
22458 });
22459
22460 let live = shared.record(&record_id).unwrap();
22461 assert_eq!(live.state, ManagedSessionState::Live);
22462 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22463 assert_eq!(live.active_session.as_ref(), Some(&address));
22464 assert_eq!(
22465 shared
22466 .managed_worktrees
22467 .lock()
22468 .unwrap_or_else(|poisoned| poisoned.into_inner())
22469 .get(&lease.lease_id)
22470 .unwrap()
22471 .record_holders,
22472 vec![record_id],
22473 );
22474 }
22475
22476 fn managed_v2_test_shared(
22477 allocation_root: &Path,
22478 state_path: Option<PathBuf>,
22479 profile_revision: &str,
22480 ) -> NodeShared {
22481 let profile = ManagedWorktreeProfile::new(
22482 WorktreeProfileId::new("default").unwrap(),
22483 WorktreeProfileRevision::new(profile_revision).unwrap(),
22484 allocation_root,
22485 "gate4agent",
22486 "HEAD",
22487 ManagedWorktreeRetention::RemoveWhenReleased,
22488 )
22489 .unwrap();
22490 let workspace = WorkspaceConfig::new(
22491 WorkspaceId::new("primary").unwrap(),
22492 std::env::current_dir().unwrap(),
22493 )
22494 .unwrap()
22495 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22496 .with_managed_worktree_profile(profile)
22497 .unwrap();
22498 let catalog = active_registry().unwrap();
22499 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22500 let mut shared = NodeShared::new(
22501 handle,
22502 "fixture-token".to_owned(),
22503 NodeId::new("node-terminal-test").unwrap(),
22504 vec![workspace],
22505 vec![agent("claude")],
22506 );
22507 shared.state_path = state_path;
22508 shared
22509 }
22510
22511 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22512 let mut spawn_spec = spawn_spec_fixture();
22513 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22514 ManagedWorktreeSpawnRequestV2 {
22515 spawn_spec,
22516 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22517 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22518 }
22519 }
22520
22521 #[test]
22522 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22523 let shared = terminal_test_shared();
22524 let mut lease = install_managed_lease(&shared);
22525 let session = SessionAddress {
22526 workspace_id: lease.workspace_id.clone(),
22527 session: SessionKey {
22528 instance_id: AgentInstanceId(73),
22529 generation: SessionGeneration(4),
22530 },
22531 };
22532 lease.state = ManagedWorktreeLeaseState::InUse;
22533 lease.session_holders.push(ManagedWorktreeSessionHolder {
22534 incarnation_id: shared.incarnation_id,
22535 instance_id: session.session.instance_id,
22536 generation: session.session.generation,
22537 });
22538 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22539 assert!(resolved.target.worktree_id.is_none());
22540
22541 let receipt = managed_spawn_receipt(
22542 &resolved,
22543 shared.incarnation_id,
22544 session.clone(),
22545 lease.snapshot(),
22546 None,
22547 None,
22548 None,
22549 );
22550 assert!(resolved.target.worktree_id.is_none());
22551 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22552 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22553 assert_eq!(receipt.spawn.session, session);
22554 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22555 let encoded = serde_json::to_vec(&receipt).unwrap();
22556 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22557 assert_eq!(decoded, receipt);
22558 }
22559
22560 #[tokio::test]
22561 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22562 let root = temporary_workspace_root("managed-v2-revision");
22563 let allocation = root.join("allocation");
22564 std::fs::create_dir_all(&allocation).unwrap();
22565 let shared = managed_v2_test_shared(
22566 &allocation,
22567 Some(root.join("node-state.json")),
22568 "v1",
22569 );
22570 let mut request = managed_v2_request("managed-v2-revision");
22571 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22572
22573 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22574
22575 assert_eq!(
22576 failure.code,
22577 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22578 );
22579 assert!(shared
22580 .managed_worktrees
22581 .lock()
22582 .unwrap_or_else(|poisoned| poisoned.into_inner())
22583 .records()
22584 .is_empty());
22585 assert!(shared
22586 .managed_spawn_replays
22587 .lock()
22588 .unwrap_or_else(|poisoned| poisoned.into_inner())
22589 .is_empty());
22590 std::fs::remove_dir_all(root).unwrap();
22591 }
22592
22593 #[test]
22594 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22595 let root = temporary_workspace_root("managed-v2-concurrent");
22596 let allocation = root.join("allocation");
22597 std::fs::create_dir_all(&allocation).unwrap();
22598 let shared = Arc::new(managed_v2_test_shared(
22599 &allocation,
22600 Some(root.join("node-state.json")),
22601 "v1",
22602 ));
22603 let request = managed_v2_request("managed-v2-concurrent");
22604 let barrier = Arc::new(std::sync::Barrier::new(3));
22605 let mut joins = Vec::new();
22606 for _ in 0..2 {
22607 let shared = Arc::clone(&shared);
22608 let request = request.clone();
22609 let barrier = Arc::clone(&barrier);
22610 joins.push(std::thread::spawn(move || {
22611 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22612 barrier.wait();
22613 shared.acquire_durable_managed_spawn_v2(&request, digest)
22614 }));
22615 }
22616 barrier.wait();
22617 let results = joins
22618 .into_iter()
22619 .map(|join| join.join().unwrap())
22620 .collect::<Vec<_>>();
22621 assert_eq!(
22622 results
22623 .iter()
22624 .filter(|result| {
22625 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22626 })
22627 .count(),
22628 1,
22629 );
22630 assert_eq!(
22631 results
22632 .iter()
22633 .filter(|result| {
22634 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22635 })
22636 .count(),
22637 1,
22638 );
22639 assert_eq!(
22640 shared
22641 .managed_spawn_replays
22642 .lock()
22643 .unwrap_or_else(|poisoned| poisoned.into_inner())
22644 .len(),
22645 1,
22646 );
22647 assert!(shared
22648 .managed_worktrees
22649 .lock()
22650 .unwrap_or_else(|poisoned| poisoned.into_inner())
22651 .records()
22652 .is_empty());
22653 drop(shared);
22654 std::fs::remove_dir_all(root).unwrap();
22655 }
22656
22657 #[tokio::test]
22658 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22659 let root = temporary_workspace_root("managed-v2-linked-pending");
22660 let allocation = root.join("allocation");
22661 std::fs::create_dir_all(&allocation).unwrap();
22662 let state_path = root.join("node-state.json");
22663 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22664 let lease = install_managed_lease(&shared);
22665 let request = managed_v2_request("managed-v2-linked-pending");
22666 assert!(matches!(
22667 shared
22668 .acquire_durable_managed_spawn_v2(
22669 &request,
22670 managed_spawn_request_digest_v2(&request).unwrap(),
22671 )
22672 .unwrap(),
22673 DurableManagedSpawnReplayDecision::Reserved,
22674 ));
22675 {
22676 let _transaction = shared
22677 .state_transaction
22678 .lock()
22679 .unwrap_or_else(|poisoned| poisoned.into_inner());
22680 shared
22681 .link_durable_managed_spawn_v2_lease_locked(
22682 &request.spawn_spec.idempotency_key,
22683 &lease.lease_id,
22684 )
22685 .unwrap();
22686 shared.persist_state_locked().unwrap();
22687 }
22688 drop(shared);
22689
22690 let mut loaded = session_registry::load(
22691 Some(&state_path),
22692 &NodeId::new("node-terminal-test").unwrap(),
22693 )
22694 .unwrap();
22695 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22696 *reopened
22697 .managed_worktrees
22698 .lock()
22699 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22700 ManagedWorktreeRegistry::from_records(
22701 std::mem::take(&mut loaded.managed_worktrees),
22702 std::mem::take(&mut loaded.managed_worktree_tombstones),
22703 )
22704 .unwrap();
22705 *reopened
22706 .managed_spawn_replays
22707 .lock()
22708 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22709 .managed_spawn_replays
22710 .into_iter()
22711 .map(|record| (record.idempotency_key.clone(), record))
22712 .collect();
22713 let lease_count = reopened
22714 .managed_worktrees
22715 .lock()
22716 .unwrap_or_else(|poisoned| poisoned.into_inner())
22717 .records()
22718 .len();
22719
22720 let failure = reopened
22721 .spawn_managed_worktree_v2(request)
22722 .await
22723 .unwrap_err();
22724
22725 assert_eq!(
22726 failure.code,
22727 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22728 );
22729 assert_eq!(
22730 reopened
22731 .managed_worktrees
22732 .lock()
22733 .unwrap_or_else(|poisoned| poisoned.into_inner())
22734 .records()
22735 .len(),
22736 lease_count,
22737 );
22738 std::fs::remove_dir_all(root).unwrap();
22739 }
22740
22741 #[tokio::test]
22742 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22743 let root = temporary_workspace_root("managed-v2-reopen");
22744 let allocation = root.join("allocation");
22745 std::fs::create_dir_all(&allocation).unwrap();
22746 let state_path = root.join("node-state.json");
22747 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22748 let lease = install_managed_lease(&shared);
22749 let request = managed_v2_request("managed-v2-reopen");
22750 let session = SessionAddress {
22751 workspace_id: lease.workspace_id.clone(),
22752 session: SessionKey {
22753 instance_id: AgentInstanceId(91),
22754 generation: SessionGeneration(1),
22755 },
22756 };
22757 let snapshot = {
22758 let mut registry = shared
22759 .managed_worktrees
22760 .lock()
22761 .unwrap_or_else(|poisoned| poisoned.into_inner());
22762 let current = registry.get_mut(&lease.lease_id).unwrap();
22763 current.state = ManagedWorktreeLeaseState::InUse;
22764 current.session_holders.push(ManagedWorktreeSessionHolder {
22765 incarnation_id: shared.incarnation_id,
22766 instance_id: session.session.instance_id,
22767 generation: session.session.generation,
22768 });
22769 current.snapshot()
22770 };
22771 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22772 let receipt = managed_spawn_receipt(
22773 &resolved,
22774 shared.incarnation_id,
22775 session,
22776 snapshot,
22777 None,
22778 None,
22779 None,
22780 );
22781 assert!(matches!(
22782 shared
22783 .acquire_durable_managed_spawn_v2(
22784 &request,
22785 managed_spawn_request_digest_v2(&request).unwrap(),
22786 )
22787 .unwrap(),
22788 DurableManagedSpawnReplayDecision::Reserved,
22789 ));
22790 {
22791 let _transaction = shared
22792 .state_transaction
22793 .lock()
22794 .unwrap_or_else(|poisoned| poisoned.into_inner());
22795 shared
22796 .link_durable_managed_spawn_v2_lease_locked(
22797 &request.spawn_spec.idempotency_key,
22798 &lease.lease_id,
22799 )
22800 .unwrap();
22801 shared.persist_state_locked().unwrap();
22802 }
22803 shared
22804 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22805 .unwrap();
22806 let legacy_same_key = ManagedWorktreeSpawnRequest {
22807 spawn_spec: request.spawn_spec.clone(),
22808 worktree_profile_id: request.worktree_profile_id.clone(),
22809 };
22810 shared.remember_managed_spawn_attempt(
22811 Some(&request.spawn_spec.idempotency_key),
22812 legacy_same_key.clone(),
22813 Ok(receipt.clone()),
22814 Instant::now(),
22815 );
22816 assert!(shared
22817 .replay_managed_spawn(&legacy_same_key, Instant::now())
22818 .unwrap()
22819 .is_none());
22820 drop(shared);
22821
22822 let mut loaded = session_registry::load(
22823 Some(&state_path),
22824 &NodeId::new("node-terminal-test").unwrap(),
22825 )
22826 .unwrap();
22827 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22828 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22829 *reopened
22830 .managed_worktrees
22831 .lock()
22832 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22833 ManagedWorktreeRegistry::from_records(
22834 std::mem::take(&mut loaded.managed_worktrees),
22835 std::mem::take(&mut loaded.managed_worktree_tombstones),
22836 )
22837 .unwrap();
22838 *reopened
22839 .managed_spawn_replays
22840 .lock()
22841 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22842 .managed_spawn_replays
22843 .into_iter()
22844 .map(|record| (record.idempotency_key.clone(), record))
22845 .collect();
22846 let lease_count_before = reopened
22847 .managed_worktrees
22848 .lock()
22849 .unwrap_or_else(|poisoned| poisoned.into_inner())
22850 .records()
22851 .len();
22852
22853 assert_eq!(
22854 reopened
22855 .spawn_managed_worktree_v2(request.clone())
22856 .await
22857 .unwrap(),
22858 receipt,
22859 );
22860 assert_eq!(
22861 reopened
22862 .managed_worktrees
22863 .lock()
22864 .unwrap_or_else(|poisoned| poisoned.into_inner())
22865 .records()
22866 .len(),
22867 lease_count_before,
22868 );
22869 let mut changed = request;
22870 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22871 let failure = reopened
22872 .spawn_managed_worktree_v2(changed)
22873 .await
22874 .unwrap_err();
22875 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22876 assert_eq!(
22877 reopened
22878 .managed_worktrees
22879 .lock()
22880 .unwrap_or_else(|poisoned| poisoned.into_inner())
22881 .records()
22882 .len(),
22883 lease_count_before,
22884 );
22885 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22886 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22887 let failure = reopened
22888 .spawn_managed_worktree_v2(new_key)
22889 .await
22890 .unwrap_err();
22891 assert_eq!(
22892 failure.code,
22893 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22894 );
22895 assert_eq!(
22896 reopened
22897 .managed_worktrees
22898 .lock()
22899 .unwrap_or_else(|poisoned| poisoned.into_inner())
22900 .records()
22901 .len(),
22902 lease_count_before,
22903 );
22904 assert_eq!(
22905 reopened
22906 .managed_spawn_replays
22907 .lock()
22908 .unwrap_or_else(|poisoned| poisoned.into_inner())
22909 .len(),
22910 1,
22911 );
22912 std::fs::remove_dir_all(root).unwrap();
22913 }
22914
22915 #[test]
22916 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22917 let shared = terminal_test_shared();
22918 let request = ManagedWorktreeSpawnRequest {
22919 spawn_spec: spawn_spec_fixture(),
22920 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22921 };
22922 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22923 shared.remember_managed_spawn(
22924 request.clone(),
22925 Err(expected.clone()),
22926 Instant::now(),
22927 );
22928
22929 assert_eq!(
22930 shared
22931 .replay_managed_spawn(&request, Instant::now())
22932 .unwrap()
22933 .unwrap(),
22934 Err(expected),
22935 );
22936 assert!(shared
22937 .managed_spawn_replays
22938 .lock()
22939 .unwrap_or_else(|poisoned| poisoned.into_inner())
22940 .is_empty());
22941 }
22942
22943 #[tokio::test]
22944 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22945 let shared = terminal_test_shared();
22946 let lease = install_managed_lease(&shared);
22947 let raw = shared.spawn_session(
22948 lease.workspace_id.clone(),
22949 agent("claude"),
22950 SessionMode::Pty,
22951 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22952 None,
22953 ).await.unwrap_err();
22954 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22955 let mut spec = spawn_spec_fixture();
22956 spec.target.workspace_id = lease.workspace_id.clone();
22957 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22958 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22959 let mut selected = spawn_spec_fixture();
22960 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22961 selected.target.worktree_id = Some(lease.workspace_id);
22962 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22963 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22964 assert!(shared.handle.snapshot().sessions.is_empty());
22965 }
22966
22967 #[test]
22968 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22969 let shared = terminal_test_shared();
22970 let lease = install_managed_lease(&shared);
22971 let snapshot = snapshot_for_wire(
22972 &shared, true, true, true, false, true, true, false, false, true,
22973 );
22974 assert!(snapshot.managed_worktrees.is_empty());
22975 assert!(snapshot.workspaces.iter().all(|workspace| {
22976 workspace.managed_worktree_profiles.is_none()
22977 }));
22978 let mut reply = ResponseEnvelope {
22979 request_id: 1,
22980 result: Ok(NodeResponse::Resync {
22981 event_sequence: 1,
22982 oldest_available_sequence: 1,
22983 snapshot: shared.snapshot(),
22984 events: vec![NodeEventEnvelope {
22985 sequence: 1,
22986 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22987 }],
22988 }),
22989 };
22990 project_response_without_managed_worktrees(&mut reply);
22991 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22992 panic!("projection changed the response kind");
22993 };
22994 assert!(snapshot.managed_worktrees.is_empty());
22995 assert!(events.is_empty());
22996 let mut git = git_snapshot_for_parser();
22997 git.branch = Some(lease.branch.clone());
22998 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22999 lease_id: lease.lease_id.clone(),
23000 source_workspace_id: lease.source_workspace_id.clone(),
23001 branch: lease.branch.clone(),
23002 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23003 active_session_count: 1,
23004 managed_record_count: 0,
23005 });
23006 let mut inspection_reply = ResponseEnvelope {
23007 request_id: 2,
23008 result: Ok(NodeResponse::WorkspaceInspected {
23009 inspection: WorkspaceInspection {
23010 workspace_id: lease.workspace_id.clone(),
23011 entries: Vec::new(),
23012 tree_truncated: false,
23013 git,
23014 truncation: None,
23015 },
23016 }),
23017 };
23018 project_response_without_managed_worktrees(&mut inspection_reply);
23019 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23020 panic!("projection changed the inspection response kind");
23021 };
23022 assert!(inspection.git.managed_worktree.is_none());
23023 let failure = managed_git_worktree_failure(GitWorktreeError {
23024 kind: GitWorktreeErrorKind::Failed,
23025 message: r"git failed at C:\private\managed-target".to_owned(),
23026 });
23027 assert!(!failure.message.contains("private"));
23028 assert!(!failure.message.contains("managed-target"));
23029 }
23030
23031 #[tokio::test]
23032 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23033 let root = temporary_workspace_root("managed-restart");
23034 let source = root.join("source");
23035 let allocation = root.join("allocation");
23036 std::fs::create_dir_all(&source).unwrap();
23037 std::fs::create_dir_all(&allocation).unwrap();
23038 run_test_git(&source, &["init"]);
23039 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23040 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23041 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23042 run_test_git(&source, &["add", "seed.txt"]);
23043 run_test_git(&source, &["commit", "-m", "seed"]);
23044 let profile = ManagedWorktreeProfile::new(
23045 WorktreeProfileId::new("default").unwrap(),
23046 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23047 &allocation,
23048 "gate4agent",
23049 "HEAD",
23050 ManagedWorktreeRetention::Retain,
23051 ).unwrap();
23052 let workspace = WorkspaceConfig::new(
23053 WorkspaceId::new("primary").unwrap(),
23054 &source,
23055 ).unwrap()
23056 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23057 .with_managed_worktree_profile(profile.clone()).unwrap();
23058 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23059 let source_text = workspace.canonical_root().to_owned();
23060 let target_text = target.to_string_lossy().into_owned();
23061 let created = create_git_worktree(
23062 &source_text,
23063 &target_text,
23064 "gate4agent/mw-restart",
23065 Some("HEAD"),
23066 ).await.unwrap();
23067 let lease = ManagedWorktreeLeaseRecord {
23068 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23069 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23070 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23071 profile_id: profile.profile_id().clone(),
23072 profile_revision: profile.revision().clone(),
23073 target_root: created.path.clone(),
23074 branch: "gate4agent/mw-restart".to_owned(),
23075 base_commit: created.head.clone(),
23076 expected_head: None,
23077 retention: ManagedWorktreeRetention::Retain,
23078 state: ManagedWorktreeLeaseState::Allocating,
23079 session_holders: Vec::new(),
23080 record_holders: Vec::new(),
23081 cleanup_failure: None,
23082 created_at_unix_ms: 1,
23083 updated_at_unix_ms: 1,
23084 };
23085 let catalog = active_registry().unwrap();
23086 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23087 let shared = NodeShared::new_with_incarnation(
23088 handle,
23089 "fixture-token".to_owned(),
23090 NodeId::new("node-managed-restart").unwrap(),
23091 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23092 vec![workspace],
23093 vec![agent("claude")],
23094 ProviderRuntimeStatuses::default(),
23095 None,
23096 Vec::new(),
23097 Vec::new(),
23098 SpawnProfileRegistry::default(),
23099 None,
23100 None,
23101 None,
23102 None,
23103 Vec::new(),
23104 vec![lease.clone()],
23105 Vec::new(),
23106 Vec::new(),
23107 None,
23108 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23109 );
23110 shared.reconcile_managed_worktrees().await;
23111 let retained = shared.managed_worktrees.lock()
23112 .unwrap_or_else(|poisoned| poisoned.into_inner())
23113 .get(&lease.lease_id).unwrap().clone();
23114 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23115 assert!(shared.workspaces.read()
23116 .unwrap_or_else(|poisoned| poisoned.into_inner())
23117 .contains_key(&lease.workspace_id));
23118 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23119 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23120 assert!(!Path::new(&created.path).exists());
23121 std::fs::remove_dir_all(root).unwrap();
23122 }
23123
23124 fn run_test_git(root: &Path, arguments: &[&str]) {
23125 let output = std::process::Command::new("git")
23126 .arg("-C").arg(root).args(arguments).output().unwrap();
23127 assert!(
23128 output.status.success(),
23129 "git {:?} failed: {}",
23130 arguments,
23131 String::from_utf8_lossy(&output.stderr),
23132 );
23133 }
23134
23135 #[tokio::test]
23136 async fn spawn_spec_validation_fails_before_session_mutation() {
23137 let shared = terminal_test_shared();
23138 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23139 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23140 }));
23141 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23142 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23143 }));
23144 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23145 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23146 }));
23147
23148 let mut target_mismatch = spawn_spec_fixture();
23149 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23150 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23151 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23152
23153 let mut unknown_profile = spawn_spec_fixture();
23154 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23155 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23156 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23157 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23158
23159 let mut revision_mismatch = spawn_spec_fixture();
23160 revision_mismatch.idempotency_key =
23161 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23162 revision_mismatch.expected_profile_revision =
23163 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23164 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23165 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23166
23167 let mut unsupported_materializer = spawn_spec_fixture();
23168 unsupported_materializer.idempotency_key =
23169 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23170 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23171 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23172 };
23173 let failure = shared
23174 .spawn_from_spec(unsupported_materializer)
23175 .await
23176 .unwrap_err();
23177 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23178
23179 let failure = shared
23180 .spawn_session_with_deadline(
23181 WorkspaceId::new("primary").unwrap(),
23182 agent("claude"),
23183 SessionMode::Pty,
23184 gate4agent_types::TerminalSize {
23185 rows: 24,
23186 columns: 80,
23187 },
23188 None,
23189 None,
23190 None,
23191 None,
23192 None,
23193 None,
23194 SpawnRecordPolicy::ProviderIdentityOnly,
23195 Some(Instant::now()),
23196 &[],
23197 None,
23198 ApprovalLevel::default(),
23199 None,
23200 None,
23201 )
23202 .await
23203 .unwrap_err();
23204 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23205 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23206 assert!(shared.handle.snapshot().sessions.is_empty());
23207 }
23208
23209 #[test]
23210 fn worktree_selection_requires_its_separate_negotiated_capability() {
23211 let mut spec = spawn_spec_fixture();
23212 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23213 let request = NodeRequest::SpawnSpec { spec };
23214 let spawn_spec_capability =
23215 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23216 assert!(request_uses_unnegotiated_capability(
23217 &request,
23218 &[spawn_spec_capability.clone()],
23219 ));
23220 let worktree_capability =
23221 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23222 assert!(request_uses_unnegotiated_capability(
23223 &request,
23224 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23225 ));
23226 let profile_revision_capability =
23227 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23228 assert!(request_uses_unnegotiated_capability(
23229 &request,
23230 &[
23231 spawn_spec_capability.clone(),
23232 worktree_capability.clone(),
23233 profile_revision_capability.clone(),
23234 ],
23235 ));
23236 let bundle_materialization_capability =
23243 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23244 assert!(request_uses_unnegotiated_capability(
23245 &request,
23246 &[
23247 spawn_spec_capability.clone(),
23248 worktree_capability.clone(),
23249 profile_revision_capability.clone(),
23250 bundle_materialization_capability.clone(),
23251 ],
23252 ));
23253 let history_context_pack_capability =
23254 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23255 assert!(!request_uses_unnegotiated_capability(
23256 &request,
23257 &[
23258 spawn_spec_capability,
23259 worktree_capability,
23260 profile_revision_capability,
23261 bundle_materialization_capability,
23262 history_context_pack_capability,
23263 ],
23264 ));
23265 }
23266
23267 #[tokio::test]
23268 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23269 let mut shared = terminal_test_shared();
23270 let spec = spawn_spec_fixture();
23271 let receipt = shared
23272 .resolve_spawn_spec(&spec)
23273 .unwrap()
23274 .receipt(shared.incarnation_id, terminal_address(1));
23275 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23276
23277 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23278 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23279 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23280
23281 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23282
23283 let mut revision_conflicting = spec.clone();
23284 revision_conflicting.expected_profile_revision =
23285 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23286 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23287 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23288
23289 let mut stale_cache_miss = spec.clone();
23290 stale_cache_miss.idempotency_key =
23291 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23292 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23293 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23294
23295 let mut conflicting = spec;
23296 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23297 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23298 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23299 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23300 assert!(shared.handle.snapshot().sessions.is_empty());
23301 }
23302
23303 #[tokio::test]
23304 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23305 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23306 let mut spec = spawn_spec_fixture();
23307 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23308 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23309 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23310
23311 let failure = shared
23312 .create_worktree(
23313 WorkspaceId::new("primary").unwrap(),
23314 WorkspaceId::new("new-worktree").unwrap(),
23315 "not-an-absolute-path".to_owned(),
23316 "invalid branch".to_owned(),
23317 None,
23318 )
23319 .await
23320 .unwrap_err();
23321 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23322
23323 let failure = shared
23324 .remove_worktree(
23325 WorkspaceId::new("primary").unwrap(),
23326 "not-an-absolute-path".to_owned(),
23327 )
23328 .await
23329 .unwrap_err();
23330 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23331 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23332 assert!(shared.handle.snapshot().sessions.is_empty());
23333 }
23334
23335 #[tokio::test]
23336 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23337 let root = temporary_workspace_root("manual-worktree-compensation");
23338 let source = root.join("source");
23339 let target = root.join("target");
23340 let invalid_state_parent = root.join("state-parent-is-a-file");
23341 let invalid_state_path = invalid_state_parent.join("state.json");
23342 std::fs::create_dir_all(&source).unwrap();
23343 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23344 run_test_git(&source, &["init"]);
23345 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23346 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23347 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23348 run_test_git(&source, &["add", "seed.txt"]);
23349 run_test_git(&source, &["commit", "-m", "seed"]);
23350
23351 let catalog = active_registry().unwrap();
23352 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23353 let workspace = WorkspaceConfig::new(
23354 WorkspaceId::new("primary").unwrap(),
23355 &source,
23356 ).unwrap();
23357 let mut shared = NodeShared::new(
23358 handle,
23359 "fixture-token".to_owned(),
23360 NodeId::new("node-worktree-compensation").unwrap(),
23361 vec![workspace],
23362 vec![agent("claude")],
23363 );
23364 shared.state_path = Some(invalid_state_path);
23365
23366 let error = shared.create_worktree(
23367 WorkspaceId::new("primary").unwrap(),
23368 WorkspaceId::new("compensated").unwrap(),
23369 target.to_string_lossy().into_owned(),
23370 "gate4agent/compensated".to_owned(),
23371 Some("HEAD".to_owned()),
23372 ).await.unwrap_err();
23373
23374 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23375 assert!(!target.exists());
23376 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23377 .iter()
23378 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23379 std::fs::remove_dir_all(root).unwrap();
23380 }
23381
23382 #[test]
23383 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23384 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23385 NativeGitWorktreeSnapshot {
23386 path: path.to_owned(),
23387 head: "0123456789abcdef".to_owned(),
23388 branch: Some("refs/heads/test".to_owned()),
23389 is_bare,
23390 is_main,
23391 locked: false,
23392 lock_reason: None,
23393 prunable: false,
23394 prunable_reason: None,
23395 workspace_id: None,
23396 }
23397 }
23398
23399 let source = r"C:\repo";
23400 let selected = r"C:\repo-worktree";
23401 let listed = vec![
23402 worktree(source, true, false),
23403 worktree(selected, false, false),
23404 ];
23405 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23406
23407 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23408 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23409
23410 let bare = vec![
23411 worktree(source, true, false),
23412 worktree(selected, false, true),
23413 ];
23414 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23415 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23416
23417 let foreign_failure =
23418 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23419 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23420 }
23421
23422 fn terminal_frame(sequence: u64) -> TerminalFrame {
23423 TerminalFrame {
23424 sequence,
23425 size: gate4agent_types::TerminalSize {
23426 rows: 24,
23427 columns: 80,
23428 },
23429 cursor_row: 2,
23430 cursor_column: 3,
23431 contents: format!("frame-{sequence}"),
23432 formatted: vec![1, 2, 3],
23433 scrollback_formatted: Vec::new(),
23434 alternate_screen: false,
23435 mouse_protocol_enabled: false,
23436 mouse_protocol_encoding: Default::default(),
23437 produced_at_unix_ms: 0,
23438 screen_state: gate4agent_types::PtyScreenState::default(),
23439 bracketed_paste: None,
23440 }
23441 }
23442
23443 #[test]
23444 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23445 let shared = terminal_test_shared();
23446 let mut receiver = shared.terminal_event_tx.subscribe();
23447 let original = terminal_address(1);
23448
23449 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23450 let first = receiver.try_recv().unwrap();
23451 assert_eq!(first.len(), 1);
23452 assert!(matches!(
23453 &first[0].event,
23454 NodeEvent::TerminalFrame { address, frame }
23455 if address == &original && frame.sequence == 7
23456 ));
23457
23458 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23459 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23460 assert!(matches!(
23461 receiver.try_recv(),
23462 Err(broadcast::error::TryRecvError::Empty)
23463 ));
23464
23465 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23466 let advanced = receiver.try_recv().unwrap();
23467 assert_eq!(advanced[0].sequence, 2);
23468
23469 let resumed = terminal_address(2);
23470 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23471 let reset = receiver.try_recv().unwrap();
23472 assert_eq!(reset[0].sequence, 3);
23473 assert!(matches!(
23474 &reset[0].event,
23475 NodeEvent::TerminalFrame { address, frame }
23476 if address == &resumed && frame.sequence == 1
23477 ));
23478 shared.clear_terminal_frame_watermark(&resumed);
23479 assert!(!shared
23480 .terminal_frame_watermarks
23481 .lock()
23482 .unwrap_or_else(|poisoned| poisoned.into_inner())
23483 .contains_key(&resumed.session.instance_id));
23484 }
23485
23486 #[test]
23487 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23488 let shared = terminal_test_shared();
23489 let mut receiver = shared.terminal_event_tx.subscribe();
23490 let address = terminal_address(1);
23491 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23492
23493 for sequence in 1..=count {
23494 shared.publish_terminal_frame_candidates(vec![(
23495 address.clone(),
23496 terminal_frame(sequence),
23497 )]);
23498 }
23499
23500 let history = shared
23501 .history
23502 .lock()
23503 .unwrap_or_else(|poisoned| poisoned.into_inner());
23504 assert!(history.events.is_empty());
23505 assert_eq!(history.last_sequence, count);
23506 assert_eq!(history.replay_floor_sequence, 1);
23507 drop(history);
23508 let NodeResponse::Resync {
23509 event_sequence,
23510 oldest_available_sequence,
23511 events,
23512 ..
23513 } = shared.resync(0) else {
23514 unreachable!("resync helper returned another response")
23515 };
23516 assert_eq!(event_sequence, count);
23517 assert_eq!(oldest_available_sequence, 1);
23518 assert!(events.is_empty());
23519 assert!(matches!(
23520 resync_required_event(&shared).event,
23521 NodeEvent::ResyncRequired {
23522 oldest_available_sequence: 1,
23523 }
23524 ));
23525 assert!(matches!(
23526 receiver.try_recv(),
23527 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23528 ));
23529 let latest = receiver.try_recv().unwrap();
23530 assert_eq!(latest.len(), 1);
23531 assert_eq!(latest[0].sequence, count);
23532 }
23533
23534 #[test]
23535 fn real_durable_eviction_advances_authoritative_replay_floor() {
23536 let shared = terminal_test_shared();
23537 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23538 shared.publish(NodeEvent::ControllerChanged { controller: None });
23539 }
23540
23541 let NodeResponse::Resync {
23542 event_sequence,
23543 oldest_available_sequence,
23544 events,
23545 ..
23546 } = shared.resync(0) else {
23547 unreachable!("resync helper returned another response")
23548 };
23549 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23550 assert_eq!(oldest_available_sequence, 2);
23551 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23552 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23553 assert!(matches!(
23554 resync_required_event(&shared).event,
23555 NodeEvent::ResyncRequired {
23556 oldest_available_sequence: 2,
23557 }
23558 ));
23559 }
23560
23561 #[test]
23562 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23563 let shared = terminal_test_shared();
23564 let without_capability = terminal_event_subscription(&shared, false);
23565 let with_capability = terminal_event_subscription(&shared, true);
23566 assert!(without_capability.is_none());
23567 assert!(with_capability.is_some());
23568 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23569 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23570 }));
23571 }
23572
23573 #[test]
23574 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23575 let mut pending = Vec::new();
23576 let mut event_sequence = 0_u64;
23577 for frame_sequence in 1..=4 {
23578 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23579 event_sequence += 1;
23580 let mut address = terminal_address(1);
23581 address.session.instance_id = AgentInstanceId(instance);
23582 queue_connection_event(
23583 &mut pending,
23584 NodeEventEnvelope {
23585 sequence: event_sequence,
23586 event: NodeEvent::TerminalFrame {
23587 address,
23588 frame: terminal_frame(frame_sequence),
23589 },
23590 },
23591 0,
23592 );
23593 }
23594 }
23595 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23596 pending.sort_unstable_by_key(|event| event.sequence);
23597
23598 let mut uninterrupted = pending.clone();
23599 let first_burst = connection_event_burst_len(uninterrupted.len());
23600 uninterrupted.drain(..first_burst);
23601 let second_burst = connection_event_burst_len(uninterrupted.len());
23602 uninterrupted.drain(..second_burst);
23603 assert!(uninterrupted.is_empty());
23604
23605 let (frame_tx, mut frame_rx) =
23606 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23607 frame_tx
23608 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23609 request_id: 77,
23610 request: NodeRequest::Snapshot,
23611 })))
23612 .unwrap();
23613 let burst_len = connection_event_burst_len(pending.len());
23614 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23615 pending.drain(..burst_len);
23616 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23617 assert!(matches!(
23618 frame_rx.try_recv(),
23619 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23620 request_id: 77,
23621 request: NodeRequest::Snapshot,
23622 })))
23623 ));
23624 }
23625
23626 #[test]
23627 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23628 let mut pending = Vec::new();
23629 for sequence in [7_u64, 8] {
23630 let mut address = terminal_address(1);
23631 address.session.instance_id = AgentInstanceId(sequence);
23632 queue_connection_event(
23633 &mut pending,
23634 NodeEventEnvelope {
23635 sequence,
23636 event: NodeEvent::TerminalFrame {
23637 address,
23638 frame: terminal_frame(sequence),
23639 },
23640 },
23641 0,
23642 );
23643 }
23644 let mut discard_through = 0;
23645 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23646 assert!(pending.is_empty());
23647
23648 let replayed = NodeEventEnvelope {
23649 sequence: 8,
23650 event: NodeEvent::TerminalFrame {
23651 address: terminal_address(1),
23652 frame: terminal_frame(8),
23653 },
23654 };
23655 let advanced = NodeEventEnvelope {
23656 sequence: 9,
23657 event: NodeEvent::TerminalFrame {
23658 address: terminal_address(2),
23659 frame: terminal_frame(1),
23660 },
23661 };
23662 queue_connection_event_batch(
23663 &mut pending,
23664 &[replayed, advanced.clone()],
23665 discard_through,
23666 );
23667 assert_eq!(pending, vec![advanced]);
23668 }
23669
23670 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23671 ManagedSessionRecord {
23672 record_id: SessionRecordId::new(record_id).unwrap(),
23673 display_name: format!("{provider} record"),
23674 provider: agent(provider),
23675 mode: SessionMode::Pty,
23676 state: ManagedSessionState::Unavailable,
23677 workspace_id: WorkspaceId::new("primary").unwrap(),
23678 canonical_root: opaque_windows_path(
23679 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23680 ),
23681 provider_session: None,
23682 active_session: None,
23683 environment_profile: None,
23684 bundle: None,
23685 context_id: None,
23686 context: None,
23687 exported_context: None,
23688 task_binding: None,
23689 created_at_unix_ms: 1,
23690 updated_at_unix_ms: 1,
23691 last_error: None,
23692 }
23693 }
23694
23695 fn retention_test_record(
23696 workspace_id: &str,
23697 record_id: &str,
23698 state: ManagedSessionState,
23699 updated_at_unix_ms: u64,
23700 ) -> ManagedSessionRecord {
23701 ManagedSessionRecord {
23702 record_id: SessionRecordId::new(record_id).unwrap(),
23703 display_name: format!("{record_id} display"),
23704 provider: agent("claude"),
23705 mode: SessionMode::Pty,
23706 state,
23707 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23708 canonical_root: opaque_windows_path(
23709 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23710 ),
23711 provider_session: None,
23712 active_session: None,
23713 environment_profile: None,
23714 bundle: None,
23715 context_id: None,
23716 context: None,
23717 exported_context: None,
23718 task_binding: None,
23719 created_at_unix_ms: updated_at_unix_ms,
23720 updated_at_unix_ms,
23721 last_error: None,
23722 }
23723 }
23724
23725 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23733 vec![
23734 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23735 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23736 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23737 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23738 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23739 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23740 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23741 retention_test_record(
23742 "workspace-a",
23743 "decoy-pending",
23744 ManagedSessionState::IdentityPending,
23745 now - 500_000,
23746 ),
23747 retention_test_record(
23748 "workspace-a",
23749 "decoy-dormant",
23750 ManagedSessionState::Dormant,
23751 now - 500_000,
23752 ),
23753 ]
23754 }
23755
23756 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23757 let root = temporary_workspace_root("session-record-retention");
23758 std::fs::create_dir_all(&root).unwrap();
23759 let catalog = active_registry().unwrap();
23760 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23761 let workspace_a =
23762 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23763 let workspace_b =
23764 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23765 let mut shared = NodeShared::new(
23766 handle,
23767 "fixture-token".to_owned(),
23768 NodeId::new("node-retention-test").unwrap(),
23769 vec![workspace_a, workspace_b],
23770 vec![agent("claude")],
23771 );
23772 shared.state_path = Some(root.join("node-state-v10.json"));
23773 let now = unix_time_ms();
23774 for record in retention_fixture_records(now) {
23775 shared.insert_record(record).unwrap();
23776 }
23777 shared
23778 .persist_state_locked()
23779 .expect("fixture-only, empty-history persist against a fresh temp state path");
23780 (shared, runtime, root, now)
23781 }
23782
23783 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23784 shared
23785 .history
23786 .lock()
23787 .unwrap_or_else(|poisoned| poisoned.into_inner())
23788 .events
23789 .iter()
23790 .filter_map(|envelope| match &envelope.event {
23791 NodeEvent::SessionRecordRemoved { record_id } => {
23792 Some(record_id.as_str().to_owned())
23793 }
23794 _ => None,
23795 })
23796 .collect()
23797 }
23798
23799 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23800 shared
23801 .session_records
23802 .lock()
23803 .unwrap_or_else(|poisoned| poisoned.into_inner())
23804 .records
23805 .keys()
23806 .map(|record_id| record_id.as_str().to_owned())
23807 .collect()
23808 }
23809
23810 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23811 values.iter().map(|value| (*value).to_owned()).collect()
23812 }
23813
23814 #[tokio::test]
23815 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23816 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23817 shared.session_record_retention = SessionRecordRetentionConfig {
23818 age_ms: 0,
23819 keep_per_workspace: 1,
23820 };
23821 shared.retire_unavailable_session_records().await;
23822
23823 assert_eq!(
23824 retention_remaining_record_ids(&shared),
23825 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23826 );
23827 assert_eq!(
23828 retention_removed_record_ids(&shared),
23829 owned(&["a1", "a2", "b1", "b2"]),
23830 );
23831 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23832
23833 drop(shared);
23834 drop(runtime);
23835 std::fs::remove_dir_all(root).unwrap();
23836 }
23837
23838 #[tokio::test]
23844 async fn session_record_retention_both_set_retires_only_the_intersection() {
23845 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23846 shared.session_record_retention = SessionRecordRetentionConfig {
23847 age_ms: 250_000,
23848 keep_per_workspace: 1,
23849 };
23850 shared.retire_unavailable_session_records().await;
23851
23852 assert_eq!(
23853 retention_remaining_record_ids(&shared),
23854 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23855 );
23856 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23857 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23858
23859 drop(shared);
23860 drop(runtime);
23861 std::fs::remove_dir_all(root).unwrap();
23862 }
23863
23864 #[tokio::test]
23865 async fn session_record_retention_both_disabled_is_a_no_op() {
23866 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23867 shared.session_record_retention = SessionRecordRetentionConfig::default();
23868 shared.retire_unavailable_session_records().await;
23869
23870 assert_eq!(
23871 retention_remaining_record_ids(&shared),
23872 owned(&[
23873 "a1", "a2", "a3", "b1", "b2", "b3",
23874 "decoy-live", "decoy-pending", "decoy-dormant",
23875 ]),
23876 );
23877 assert!(retention_removed_record_ids(&shared).is_empty());
23878 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23879
23880 drop(shared);
23881 drop(runtime);
23882 std::fs::remove_dir_all(root).unwrap();
23883 }
23884
23885 #[test]
23886 fn session_record_context_export_selects_one_exact_history_identity() {
23887 let candidates = vec![
23888 HistoryCandidateSummary {
23889 id: "candidate-other".to_owned(),
23890 session_id_hint: "provider-other".to_owned(),
23891 modified_at_unix_ms: Some(1),
23892 },
23893 HistoryCandidateSummary {
23894 id: "candidate-exact".to_owned(),
23895 session_id_hint: "provider-exact".to_owned(),
23896 modified_at_unix_ms: Some(2),
23897 },
23898 ];
23899 assert_eq!(
23900 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23901 "candidate-exact",
23902 );
23903 }
23904
23905 #[test]
23906 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23907 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23908 id: id.to_owned(),
23909 session_id_hint: hint.to_owned(),
23910 modified_at_unix_ms: None,
23911 };
23912 let missing = select_history_candidate(
23913 &[candidate("candidate-other", "provider-other")],
23914 Some("provider-exact"),
23915 )
23916 .unwrap_err();
23917 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23918 let ambiguous = select_history_candidate(
23919 &[
23920 candidate("candidate-a", "provider-exact"),
23921 candidate("candidate-b", "provider-exact"),
23922 ],
23923 Some("provider-exact"),
23924 )
23925 .unwrap_err();
23926 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23927 }
23928
23929 #[test]
23937 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23938 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23939 id: id.to_owned(),
23940 session_id_hint: hint.to_owned(),
23941 modified_at_unix_ms: None,
23942 };
23943 assert_eq!(
23944 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23945 .unwrap(),
23946 "candidate-sole",
23947 );
23948 let missing = select_history_candidate(&[], None).unwrap_err();
23949 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23950 let ambiguous = select_history_candidate(
23951 &[
23952 candidate("candidate-a", "sidecar-session-a"),
23953 candidate("candidate-b", "sidecar-session-b"),
23954 ],
23955 None,
23956 )
23957 .unwrap_err();
23958 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23959 }
23960
23961 #[test]
23962 fn session_record_context_export_revalidation_rejects_record_change() {
23963 let session = terminal_address(1);
23964 let identity = ProviderSessionIdentity {
23965 key: ProviderSessionKey::SessionId,
23966 id: "provider-exact".to_owned(),
23967 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23968 };
23969 let mut expected = record("claude", "record-context-export");
23970 expected.state = ManagedSessionState::Live;
23971 expected.provider_session = Some(identity.clone());
23972 expected.active_session = Some(session.clone());
23973 let binding = SessionBinding {
23974 workspace_id: session.workspace_id.clone(),
23975 generation: session.session.generation,
23976 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23977 pending_resume: None,
23978 record_id: Some(expected.record_id.clone()),
23979 managed_worktree_lease_id: None,
23980 environment_profile: None,
23981 bundle: None,
23982 context: None,
23983 materialization_id: None,
23984 };
23985 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23986 assert!(session_record_context_export_binding_is_exact(
23987 &expected,
23988 &expected,
23989 Some(&identity),
23990 &session,
23991 &binding,
23992 &expected.provider,
23993 ¤t_root,
23994 false,
23995 ));
23996 let mut changed = expected.clone();
23997 changed.updated_at_unix_ms += 1;
23998 assert!(!session_record_context_export_binding_is_exact(
23999 &expected,
24000 &changed,
24001 Some(&identity),
24002 &session,
24003 &binding,
24004 &expected.provider,
24005 ¤t_root,
24006 false,
24007 ));
24008 assert!(!session_record_context_export_binding_is_exact(
24013 &expected,
24014 &changed,
24015 Some(&identity),
24016 &session,
24017 &binding,
24018 &expected.provider,
24019 ¤t_root,
24020 true,
24021 ));
24022 let mut detached = expected.clone();
24023 detached.state = ManagedSessionState::Dormant;
24024 detached.active_session = None;
24025 detached.updated_at_unix_ms += 1;
24026 assert!(!session_record_context_export_binding_is_exact(
24027 &expected,
24028 &detached,
24029 Some(&identity),
24030 &session,
24031 &binding,
24032 &expected.provider,
24033 ¤t_root,
24034 false,
24035 ));
24036 assert!(session_record_context_export_binding_is_exact(
24037 &expected,
24038 &detached,
24039 Some(&identity),
24040 &session,
24041 &binding,
24042 &expected.provider,
24043 ¤t_root,
24044 true,
24045 ));
24046 }
24047
24048 #[test]
24055 fn session_record_context_export_binding_is_exact_without_identity() {
24056 let session = terminal_address(1);
24057 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24058 expected.state = ManagedSessionState::Live;
24059 expected.active_session = Some(session.clone());
24060 let binding = SessionBinding {
24061 workspace_id: session.workspace_id.clone(),
24062 generation: session.session.generation,
24063 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24064 pending_resume: None,
24065 record_id: Some(expected.record_id.clone()),
24066 managed_worktree_lease_id: None,
24067 environment_profile: None,
24068 bundle: None,
24069 context: None,
24070 materialization_id: None,
24071 };
24072 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24073 assert!(session_record_context_export_binding_is_exact(
24074 &expected,
24075 &expected,
24076 None,
24077 &session,
24078 &binding,
24079 &expected.provider,
24080 ¤t_root,
24081 false,
24082 ));
24083 let mut drifted = expected.clone();
24087 drifted.provider_session = Some(ProviderSessionIdentity {
24088 key: ProviderSessionKey::SessionId,
24089 id: "unexpected-identity".to_owned(),
24090 transcript_path: None,
24091 });
24092 assert!(!session_record_context_export_binding_is_exact(
24093 &expected,
24094 &drifted,
24095 None,
24096 &session,
24097 &binding,
24098 &expected.provider,
24099 ¤t_root,
24100 false,
24101 ));
24102 }
24103
24104 #[test]
24105 fn session_record_context_export_preflight_requires_live_usable_source() {
24106 let mut source = record("claude", "record-context-export-preflight");
24107 source.state = ManagedSessionState::Live;
24108 assert!(session_record_context_export_source_is_usable(
24109 &source,
24110 &SessionStatus::Running,
24111 false,
24112 ));
24113 assert!(session_record_context_export_source_is_usable(
24114 &source,
24115 &SessionStatus::Exited { exit_code: Some(0) },
24116 false,
24117 ));
24118
24119 source.state = ManagedSessionState::Dormant;
24120 assert!(!session_record_context_export_source_is_usable(
24121 &source,
24122 &SessionStatus::Running,
24123 false,
24124 ));
24125 assert!(session_record_context_export_source_is_usable(
24129 &source,
24130 &SessionStatus::Exited { exit_code: Some(0) },
24131 true,
24132 ));
24133 assert!(!session_record_context_export_source_is_usable(
24134 &source,
24135 &SessionStatus::Exited { exit_code: Some(0) },
24136 false,
24137 ));
24138 source.state = ManagedSessionState::Live;
24139 assert!(!session_record_context_export_source_is_usable(
24140 &source,
24141 &SessionStatus::Starting,
24142 false,
24143 ));
24144 }
24145
24146 #[test]
24147 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24148 let catalog = active_registry().unwrap();
24149 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24150 let workspace = WorkspaceConfig::new(
24151 WorkspaceId::new("primary").unwrap(),
24152 std::env::current_dir().unwrap(),
24153 )
24154 .unwrap();
24155 let shared = NodeShared::new(
24156 handle,
24157 "fixture-token".to_owned(),
24158 NodeId::new("node-context-export-conflict").unwrap(),
24159 vec![workspace],
24160 vec![agent("claude")],
24161 );
24162 let history = HistorySessionRecord {
24163 session_id: "provider-exact".to_owned(),
24164 title: None,
24165 cwd: None,
24166 model: None,
24167 message_count: 1,
24168 completed_turn_count: None,
24169 total_tokens: 0,
24170 messages: vec![gate4agent_types::HistoryMessageRecord {
24171 role: gate4agent_types::HistoryMessageRole::User,
24172 text: "bounded context".to_owned(),
24173 }],
24174 };
24175 let pack = NodeContextPack::export(
24176 ContextPackLineageReceipt {
24177 source_node_id: shared.node_id.clone(),
24178 source_session: terminal_address(1),
24179 source_provider: agent("claude"),
24180 },
24181 &history,
24182 )
24183 .unwrap();
24184 let context_id = pack.receipt().id.clone();
24185 let session = terminal_address(1);
24186 let identity = ProviderSessionIdentity {
24187 key: ProviderSessionKey::SessionId,
24188 id: history.session_id.clone(),
24189 transcript_path: None,
24190 };
24191 let mut expected = record("claude", "record-context-export-final-conflict");
24192 expected.state = ManagedSessionState::Live;
24193 expected.provider_session = Some(identity.clone());
24194 expected.active_session = Some(session.clone());
24195 let mut changed = expected.clone();
24196 changed.updated_at_unix_ms += 1;
24197 shared.insert_record(changed).unwrap();
24198
24199 let error = shared
24200 .commit_context_pack_for_session_record(
24201 &expected,
24202 Some(&identity),
24203 &session,
24204 "candidate-exact",
24205 &history,
24206 pack,
24207 false,
24208 )
24209 .unwrap_err();
24210
24211 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24212 assert!(shared
24213 .context_catalog
24214 .read()
24215 .unwrap_or_else(|poisoned| poisoned.into_inner())
24216 .get(&context_id)
24217 .is_none());
24218 }
24219
24220 #[tokio::test]
24221 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24222 let root = temporary_workspace_root("session-task-binding");
24223 std::fs::create_dir_all(&root).unwrap();
24224 let state_path = root.join("node-state.json");
24225 let catalog = active_registry().unwrap();
24226 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24227 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24228 let node_id = NodeId::new("node-1").unwrap();
24229 let mut shared = NodeShared::new(
24230 handle,
24231 "fixture-token".to_owned(),
24232 node_id.clone(),
24233 vec![workspace],
24234 vec![agent("claude")],
24235 );
24236 shared.state_path = Some(state_path.clone());
24237 let record = record("claude", "sr-task");
24238 let record_id = record.record_id.clone();
24239 shared.insert_record(record).unwrap();
24240 shared.persist_state().unwrap();
24241 shared
24242 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24243 .unwrap();
24244
24245 let minted = process_request_inner(
24246 &shared,
24247 77,
24248 ClientRole::Operator,
24249 NodeRequest::SetSessionTask {
24250 record_id: record_id.clone(),
24251 expected_revision: 0,
24252 target: SessionTaskTargetV1::New,
24253 },
24254 )
24255 .await
24256 .unwrap();
24257 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24258 panic!("session task mutation returned another response");
24259 };
24260 let minted_binding = minted.task_binding.clone().unwrap();
24261 let minted_task_id = minted_binding.task_id.clone().unwrap();
24262 assert_eq!(minted_binding.revision, 1);
24263 assert!(minted_task_id.as_str().starts_with("task-"));
24264 assert_eq!(minted_task_id.as_str().len(), 29);
24265
24266 let event_count = shared.current_sequence();
24267 let repeated = process_request_inner(
24268 &shared,
24269 77,
24270 ClientRole::Operator,
24271 NodeRequest::SetSessionTask {
24272 record_id: record_id.clone(),
24273 expected_revision: 1,
24274 target: SessionTaskTargetV1::Existing {
24275 task_id: minted_task_id.clone(),
24276 },
24277 },
24278 )
24279 .await
24280 .unwrap();
24281 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24282 assert_eq!(shared.current_sequence(), event_count);
24283
24284 let stale = process_request_inner(
24285 &shared,
24286 77,
24287 ClientRole::Operator,
24288 NodeRequest::SetSessionTask {
24289 record_id: record_id.clone(),
24290 expected_revision: 0,
24291 target: SessionTaskTargetV1::Existing {
24292 task_id: minted_task_id,
24293 },
24294 },
24295 )
24296 .await
24297 .unwrap_err();
24298 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24299
24300 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24301 let assigned = process_request_inner(
24302 &shared,
24303 77,
24304 ClientRole::Operator,
24305 NodeRequest::SetSessionTask {
24306 record_id: record_id.clone(),
24307 expected_revision: 1,
24308 target: SessionTaskTargetV1::Existing {
24309 task_id: assigned_id.clone(),
24310 },
24311 },
24312 )
24313 .await
24314 .unwrap();
24315 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24316 panic!("session task assignment returned another response");
24317 };
24318 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24319 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24320
24321 let cleared = process_request_inner(
24322 &shared,
24323 77,
24324 ClientRole::Operator,
24325 NodeRequest::SetSessionTask {
24326 record_id: record_id.clone(),
24327 expected_revision: 2,
24328 target: SessionTaskTargetV1::Clear,
24329 },
24330 )
24331 .await
24332 .unwrap();
24333 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24334 panic!("session task clear returned another response");
24335 };
24336 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24337 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24338 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24339 assert_eq!(
24340 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24341 cleared.updated_at_unix_ms,
24342 );
24343
24344 let persisted = std::fs::read(&state_path).unwrap();
24345 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24346 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24347 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24348 std::fs::remove_dir_all(root).unwrap();
24349 }
24350
24351 #[test]
24352 fn session_task_capability_strips_snapshots_events_and_replies() {
24353 let mut bound = record("claude", "sr-task-projection");
24354 bound.updated_at_unix_ms = 2;
24355 bound.task_binding = Some(SessionTaskBindingV1 {
24356 revision: 1,
24357 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24358 changed_at_unix_ms: 2,
24359 });
24360 let request = NodeRequest::SetSessionTask {
24361 record_id: bound.record_id.clone(),
24362 expected_revision: 1,
24363 target: SessionTaskTargetV1::Clear,
24364 };
24365 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24366 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24367 }));
24368 assert!(!request_uses_unnegotiated_capability(
24369 &request,
24370 &baseline_capabilities().unwrap(),
24371 ));
24372 assert!(request_uses_unnegotiated_capability(&request, &[]));
24373
24374 let mut snapshot = terminal_test_shared().snapshot();
24375 snapshot.session_records.push(bound.clone());
24376 let mut reply = ResponseEnvelope {
24377 request_id: 1,
24378 result: Ok(NodeResponse::Resync {
24379 event_sequence: 1,
24380 oldest_available_sequence: 1,
24381 snapshot,
24382 events: vec![NodeEventEnvelope {
24383 sequence: 1,
24384 event: NodeEvent::SessionRecordUpserted {
24385 record: bound.clone(),
24386 },
24387 }],
24388 }),
24389 };
24390 project_response_without_session_task_binding(&mut reply);
24391 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24392 panic!("task projection changed the response kind");
24393 };
24394 assert!(snapshot.session_records[0].task_binding.is_none());
24395 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24396 panic!("task projection changed the event kind");
24397 };
24398 assert!(record.task_binding.is_none());
24399
24400 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24401 sequence: 2,
24402 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24403 });
24404 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24405 panic!("task event projection changed the event kind");
24406 };
24407 assert!(record.task_binding.is_none());
24408
24409 let mut direct = ResponseEnvelope {
24410 request_id: 2,
24411 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24412 };
24413 project_response_without_session_task_binding(&mut direct);
24414 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24415 panic!("task reply projection changed the response kind");
24416 };
24417 assert!(record.task_binding.is_none());
24418 }
24419
24420 #[test]
24421 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24422 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24423 let workspace = WorkspaceConfig::new(
24424 WorkspaceId::new("primary").unwrap(),
24425 std::env::current_dir().unwrap(),
24426 )
24427 .unwrap();
24428 let config = NodeServerConfig::new(
24429 DEFAULT_NODE_ENDPOINT,
24430 "fixture-token",
24431 NodeId::new("node-1").unwrap(),
24432 [workspace],
24433 )
24434 .unwrap();
24435 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24436 }
24437
24438 #[cfg(all(feature = "fixture", windows))]
24439 #[test]
24440 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24441 let root = temporary_workspace_root("provider-bundle-argv-hold");
24442 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24443 std::fs::create_dir_all(&root).unwrap();
24444 std::fs::create_dir_all(&outside).unwrap();
24445 let proof_path = root.join("provider.proof");
24446 let release_signal = root.join("release.signal");
24447 let config = NodeServerConfig::new(
24448 format!(
24449 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24450 std::process::id(),
24451 ),
24452 "fixture-token",
24453 NodeId::new("provider-bundle-hold-node").unwrap(),
24454 [WorkspaceConfig::new(
24455 WorkspaceId::new("primary").unwrap(),
24456 std::env::current_dir().unwrap(),
24457 )
24458 .unwrap()],
24459 )
24460 .unwrap()
24461 .with_spawn_profiles(SpawnProfileRegistry::new([
24462 crate::protocol::SpawnProfileDefaults {
24463 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24464 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24465 .unwrap(),
24466 provider: agent("kimi"),
24467 mode: SessionMode::Pty,
24468 terminal_size: gate4agent_types::TerminalSize {
24469 rows: 24,
24470 columns: 80,
24471 },
24472 prompt: None,
24473 bundle_id: None,
24474 context_id: None,
24475 environment_profile_id: None,
24476 },
24477 ])
24478 .unwrap());
24479 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24480 config,
24481 agent("kimi"),
24482 proof_path.clone(),
24483 release_signal.clone(),
24484 )
24485 .unwrap();
24486 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24487
24488 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24489 agent("kimi"),
24490 proof_path.clone(),
24491 Some(release_signal.clone()),
24492 )
24493 .unwrap();
24494 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24495 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24496 let release_wait = wrapper
24497 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24498 .unwrap();
24499 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24500 assert!(proof_write < release_wait);
24501 assert!(release_wait < fixture_ready);
24502 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24503 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24504 assert_eq!(
24505 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24506 &[
24507 proof_path.to_string_lossy().into_owned(),
24508 release_signal.to_string_lossy().into_owned(),
24509 ],
24510 );
24511
24512 let error = NodeServer::provider_bundle_argv_fixture_spec(
24513 agent("kimi"),
24514 proof_path,
24515 Some(outside.join("release.signal")),
24516 )
24517 .unwrap_err();
24518 assert!(matches!(
24519 error,
24520 NodeServerError::Registry(message)
24521 if message == "provider bundle argv release path must share the proof parent"
24522 ));
24523 drop(server);
24524 std::fs::remove_dir_all(root).unwrap();
24525 std::fs::remove_dir_all(outside).unwrap();
24526 }
24527
24528 #[cfg(feature = "fixture")]
24529 #[test]
24530 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24531 let root = temporary_workspace_root("context-fixture-registry");
24532 std::fs::create_dir_all(&root).unwrap();
24533 let proof_path = root.join("context.proof");
24534 let config = NodeServerConfig::new(
24535 format!(
24536 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24537 std::process::id(),
24538 ),
24539 "fixture-token",
24540 NodeId::new("context-fixture-node").unwrap(),
24541 [WorkspaceConfig::new(
24542 WorkspaceId::new("primary").unwrap(),
24543 std::env::current_dir().unwrap(),
24544 )
24545 .unwrap()],
24546 )
24547 .unwrap();
24548 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24549 let mut enabled = server
24550 .shared
24551 .enabled_providers
24552 .iter()
24553 .map(AgentId::as_str)
24554 .collect::<Vec<_>>();
24555 enabled.sort_unstable();
24556 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24557 for provider in &server.shared.enabled_providers {
24558 assert!(server
24559 .shared
24560 .provider_adapter_contracts
24561 .iter()
24562 .any(|contract| {
24563 &contract.provider == provider
24564 && contract.family == AdapterFamily::History
24565 }));
24566 }
24567 drop(server);
24568 std::fs::remove_dir_all(root).unwrap();
24569 }
24570
24571 #[cfg(feature = "fixture")]
24572 #[test]
24573 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24574 let root = temporary_workspace_root("monitoring-context-fixture");
24575 std::fs::create_dir_all(&root).unwrap();
24576 let proof_path = root.join("context.proof");
24577 let standard = NodeServer::context_pack_fixture_catalog(
24578 proof_path.clone(),
24579 None,
24580 false,
24581 ).unwrap();
24582 let monitoring = NodeServer::context_pack_fixture_catalog(
24583 proof_path.clone(),
24584 None,
24585 true,
24586 ).unwrap();
24587 let claude = AgentId::new("claude").unwrap();
24588 let standard_claude = standard.get(&claude).unwrap();
24589 let monitoring_claude = monitoring.get(&claude).unwrap();
24590 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24594 assert_eq!(
24595 monitoring_claude.capabilities.adapters.history,
24596 standard_claude.capabilities.adapters.history,
24597 );
24598 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24599 let codex = AgentId::new("codex").unwrap();
24600 let standard_codex = standard.get(&codex).unwrap();
24601 let monitoring_codex = monitoring.get(&codex).unwrap();
24602 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24603 assert_eq!(
24604 monitoring_codex.capabilities.adapters.history,
24605 standard_codex.capabilities.adapters.history,
24606 );
24607 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24608 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24609 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24610 .unwrap();
24611 for required in [
24612 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24613 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24614 "@('role', 'text')",
24615 "g4a-private-provider-session-canary",
24616 "private-provider-login-identity",
24617 "$rawProof",
24618 "$env:GATE4AGENT_HOOK_URL",
24619 "g4a-private-provider-session-canary",
24620 ] {
24621 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24622 }
24623 for provider in ["grok", "kimi"] {
24624 let provider = AgentId::new(provider).unwrap();
24625 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24626 }
24627
24628 let config = NodeServerConfig::new(
24629 format!(
24630 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24631 std::process::id(),
24632 ),
24633 "fixture-token",
24634 NodeId::new("monitoring-context-fixture-node").unwrap(),
24635 [WorkspaceConfig::new(
24636 WorkspaceId::new("primary").unwrap(),
24637 std::env::current_dir().unwrap(),
24638 )
24639 .unwrap()],
24640 )
24641 .unwrap();
24642 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24643 assert!(server.shared.fixture_semantic_hook_policy);
24644 drop(server);
24645 std::fs::remove_dir_all(root).unwrap();
24646 }
24647
24648 #[cfg(feature = "fixture")]
24649 #[test]
24650 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24651 let root = temporary_workspace_root("kimi-context-only-fixture");
24652 std::fs::create_dir_all(&root).unwrap();
24653 let proof_path = root.join("context.proof");
24654 let standard = NodeServer::context_pack_fixture_catalog(
24655 proof_path.clone(),
24656 None,
24657 false,
24658 ).unwrap();
24659 let kimi = AgentId::new("kimi").unwrap();
24660 let context_only = NodeServer::context_pack_fixture_catalog(
24661 proof_path,
24662 Some(&kimi),
24663 false,
24664 )
24665 .unwrap();
24666
24667 let codex = AgentId::new("codex").unwrap();
24668 assert_eq!(
24669 standard.get(&codex).unwrap().launch.fixed_args,
24670 context_only.get(&codex).unwrap().launch.fixed_args,
24671 );
24672 let script = context_only
24673 .get(&kimi)
24674 .unwrap()
24675 .launch
24676 .fixed_args
24677 .iter()
24678 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24679 .expect("Kimi context-only validation script is missing");
24680 for required in [
24681 "$bundleArgs.Count -ne 0",
24682 "GATE4AGENT_CONTEXT_ROOT",
24683 "context-pack.json",
24684 "$entries.Count -ne 1",
24685 "g4a-context-pack-v1",
24686 "$document.PSObject.Properties['cwd']",
24687 "'claude', 'codex', 'grok', 'kimi'",
24688 "$hasUser",
24689 "$hasAssistant",
24690 "SHA256",
24691 "context-only",
24692 ] {
24693 assert!(script.contains(required), "missing validation: {required}");
24694 }
24695 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24696 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24697 }
24698 assert!(!standard
24699 .get(&kimi)
24700 .unwrap()
24701 .launch
24702 .fixed_args
24703 .iter()
24704 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24705
24706 std::fs::remove_dir_all(root).unwrap();
24707 }
24708
24709 #[cfg(all(feature = "fixture", windows))]
24710 #[tokio::test]
24711 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24712 let root = temporary_workspace_root("kimi-context-only-spawn");
24713 let workspace_root = root.join("workspace");
24714 std::fs::create_dir_all(&workspace_root).unwrap();
24715 let materialization_root = root.join("private-materializations");
24716 let proof_path = root.join("context.proof");
24717 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24718 let workspace_id = WorkspaceId::new("primary").unwrap();
24719 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24720 let endpoint = format!(
24721 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24722 std::process::id(),
24723 unix_time_ms(),
24724 );
24725 let spawn_profiles = SpawnProfileRegistry::new([
24726 crate::protocol::SpawnProfileDefaults {
24727 profile_id: profile_id.clone(),
24728 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24729 provider: agent("kimi"),
24730 mode: SessionMode::Pty,
24731 terminal_size: gate4agent_types::TerminalSize {
24732 rows: 30,
24733 columns: 120,
24734 },
24735 prompt: None,
24736 bundle_id: None,
24737 context_id: None,
24738 environment_profile_id: None,
24739 },
24740 ])
24741 .unwrap();
24742 let mut config = NodeServerConfig::new(
24743 &endpoint,
24744 "fixture-token",
24745 node_id.clone(),
24746 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24747 )
24748 .unwrap()
24749 .with_spawn_profiles(spawn_profiles)
24750 .with_session_environment_materialization(
24751 materialization_root,
24752 Arc::new(FixtureSecretResolver {
24753 deny: Arc::new(AtomicBool::new(false)),
24754 }),
24755 )
24756 .unwrap();
24757 config.fixture_raw_pty_runtime = true;
24758 let server = NodeServer::new_context_only_proof_fixture(
24759 config,
24760 agent("kimi"),
24761 proof_path.clone(),
24762 )
24763 .unwrap();
24764 let shared = Arc::clone(&server.shared);
24765 let shutdown = server.shutdown_handle();
24766
24767 let history = HistorySessionRecord {
24768 session_id: "context-only-source-history".to_owned(),
24769 title: None,
24770 cwd: None,
24771 model: None,
24772 message_count: 2,
24773 completed_turn_count: None,
24774 total_tokens: 0,
24775 messages: vec![
24776 gate4agent_types::HistoryMessageRecord {
24777 role: gate4agent_types::HistoryMessageRole::User,
24778 text: "continue the bounded parent run through native C2".to_owned(),
24779 },
24780 gate4agent_types::HistoryMessageRecord {
24781 role: gate4agent_types::HistoryMessageRole::Assistant,
24782 text: "the exact parent context is ready for the child run".to_owned(),
24783 },
24784 ],
24785 };
24786 let pack = NodeContextPack::export(
24787 ContextPackLineageReceipt {
24788 source_node_id: node_id.clone(),
24789 source_session: SessionAddress {
24790 workspace_id: workspace_id.clone(),
24791 session: SessionKey {
24792 instance_id: AgentInstanceId(71),
24793 generation: SessionGeneration(1),
24794 },
24795 },
24796 source_provider: agent("codex"),
24797 },
24798 &history,
24799 )
24800 .unwrap();
24801 let expected_context_hash = digest(&SHA256, pack.bytes())
24802 .as_ref()
24803 .iter()
24804 .map(|byte| format!("{byte:02x}"))
24805 .collect::<String>();
24806 let context = shared
24807 .context_catalog
24808 .write()
24809 .unwrap_or_else(|poisoned| poisoned.into_inner())
24810 .insert(pack)
24811 .unwrap();
24812 let spec = SpawnSpec {
24813 target: crate::protocol::SpawnTarget {
24814 node_id,
24815 workspace_id: workspace_id.clone(),
24816 worktree_id: None,
24817 },
24818 profile_id,
24819 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24820 "target-kimi-r1",
24821 ).unwrap(),
24822 overrides: crate::protocol::SpawnOverrides {
24823 context_id: crate::protocol::SpawnOverride::Set {
24824 value: context.id.clone(),
24825 },
24826 ..crate::protocol::SpawnOverrides::default()
24827 },
24828 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24829 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24830 required_capabilities: SpawnRequiredCapabilities::default(),
24831 };
24832 let resolved = match shared.resolve_spawn_spec(&spec) {
24833 Ok(resolved) => resolved,
24834 Err(error) => panic!(
24835 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24836 error.code,
24837 error.message,
24838 ),
24839 };
24840 let resolved_context = match shared.resolve_context(&resolved) {
24841 Ok(Some(resolved_context)) => resolved_context,
24842 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24843 Err(error) => panic!(
24844 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24845 error.code,
24846 error.message,
24847 ),
24848 };
24849 assert_eq!(resolved_context, context);
24850 let runtime_policy = match shared
24851 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24852 .await
24853 {
24854 Ok(runtime_policy) => runtime_policy,
24855 Err(error) => panic!(
24856 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24857 error.code,
24858 error.message,
24859 ),
24860 };
24861 assert!(runtime_policy.raw_pty_lifecycle);
24862
24863 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24864 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24865 agent("kimi"),
24866 TransportKind::Pty,
24867 vec![EnvMutation {
24868 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24869 value: Some(OsString::from("must-remain-rejected")),
24870 }],
24871 )
24872 .unwrap();
24873 assert_eq!(
24874 launch_control
24875 .install_native_launch_environment_overlay(
24876 AgentInstanceId(72),
24877 generic_overlay,
24878 )
24879 .unwrap_err(),
24880 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24881 );
24882
24883 let diagnostic_address = SessionAddress {
24884 workspace_id: workspace_id.clone(),
24885 session: SessionKey {
24886 instance_id: AgentInstanceId(73),
24887 generation: SessionGeneration(1),
24888 },
24889 };
24890 let (diagnostic_overlay, diagnostic_materialization) = match shared
24891 .prepare_session_materialization(
24892 &diagnostic_address,
24893 &agent("kimi"),
24894 SessionMode::Pty,
24895 None,
24896 None,
24897 Some(&resolved_context),
24898 None,
24899 )
24900 {
24901 Ok(Some(prepared)) => prepared,
24902 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24903 Err(error) => panic!(
24904 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24905 error.code,
24906 error.message,
24907 ),
24908 };
24909 let diagnostic_overlay = diagnostic_overlay
24910 .expect("Kimi context-only materialization omitted its environment overlay");
24911 assert!(matches!(
24912 &diagnostic_overlay,
24913 PreparedNativeLaunchOverlay::Environment(_),
24914 ));
24915 match shared.install_prepared_launch_overlay(
24916 diagnostic_address.session.instance_id,
24917 diagnostic_overlay,
24918 ) {
24919 Ok(None) => {}
24920 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24921 Err(error) => panic!(
24922 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24923 error.code,
24924 error.message,
24925 ),
24926 }
24927 assert!(launch_control.clear_native_instance_launch_overlay(
24928 diagnostic_address.session.instance_id,
24929 ));
24930 drop(diagnostic_materialization);
24931 assert!(shared
24932 .materializations
24933 .lock()
24934 .unwrap_or_else(|poisoned| poisoned.into_inner())
24935 .is_empty());
24936
24937 let node_task = tokio::spawn(server.run());
24938 let ready_client = timeout(Duration::from_secs(5), async {
24939 loop {
24940 match gate4agent_node_wire::LocalNodeClient::connect(
24941 &endpoint,
24942 &shared.node_id,
24943 ClientRole::Observer,
24944 "fixture-token",
24945 )
24946 .await
24947 {
24948 Ok(client) => break client,
24949 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24950 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24951 }
24952 }
24953 })
24954 .await
24955 .expect("Kimi context-only Node did not become ready");
24956 assert_eq!(
24957 ready_client
24958 .hello()
24959 .snapshot
24960 .provider_runtime_statuses
24961 .iter()
24962 .find(|status| status.provider().as_str() == "kimi")
24963 .expect("Kimi runtime status is missing")
24964 .mode(),
24965 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24966 );
24967 drop(ready_client);
24968
24969 let receipt = match shared.spawn_from_spec(spec).await {
24970 Ok(receipt) => receipt,
24971 Err(error) => panic!(
24972 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24973 error.code,
24974 error.message,
24975 ),
24976 };
24977 assert_eq!(receipt.provider, agent("kimi"));
24978 assert_eq!(receipt.context.as_ref(), Some(&context));
24979 assert!(receipt.context_binding_is_valid());
24980 timeout(Duration::from_secs(5), async {
24981 while !proof_path.is_file() {
24982 sleep(Duration::from_millis(10)).await;
24983 }
24984 })
24985 .await
24986 .expect("Kimi context-only fixture did not write its proof");
24987
24988 let proof = std::fs::read_to_string(&proof_path).unwrap();
24989 let lines = proof.lines().collect::<Vec<_>>();
24990 assert_eq!(lines.len(), 7);
24991 assert_eq!(lines[0], "context-only");
24992 assert!(Path::new(lines[1]).is_absolute());
24993 assert_ne!(
24994 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24995 std::fs::canonicalize(&workspace_root).unwrap(),
24996 );
24997 assert_eq!(
24998 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24999 std::fs::canonicalize(&workspace_root).unwrap(),
25000 );
25001 assert_eq!(lines[3], expected_context_hash);
25002 assert_eq!(lines[4], "g4a-context-pack-v1");
25003 assert_eq!(lines[5], "codex");
25004 assert_eq!(lines[6], "2");
25005 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25006 session.instance_id == receipt.session.session.instance_id
25007 && session.agent_id.as_str() == "kimi"
25008 }));
25009
25010 shutdown.request_shutdown().await.unwrap();
25011 timeout(Duration::from_secs(5), node_task)
25012 .await
25013 .unwrap()
25014 .unwrap()
25015 .unwrap();
25016 drop(shutdown);
25017 drop(shared);
25018 std::fs::remove_dir_all(root).unwrap();
25019 }
25020
25021 #[cfg(feature = "fixture")]
25022 #[test]
25023 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25024 let root = temporary_workspace_root("clean-exit-constructor");
25025 std::fs::create_dir_all(&root).unwrap();
25026 let config = construction_test_config("clean-exit-constructor")
25027 .with_state_path(root.join("node-state.json"))
25028 .unwrap();
25029 let server = NodeServer::new_clean_exit_fixture(
25030 config,
25031 root.clone(),
25032 root.join("started.marker"),
25033 root.join("release.signal"),
25034 )
25035 .unwrap();
25036 assert_eq!(
25037 server
25038 .shared
25039 .enabled_providers
25040 .iter()
25041 .map(AgentId::as_str)
25042 .collect::<Vec<_>>(),
25043 ["claude"],
25044 );
25045 drop(server);
25046 std::fs::remove_dir_all(root).unwrap();
25047 }
25048
25049 #[test]
25050 fn production_registry_provider_contract_manifest_is_exact() {
25051 let registry = active_registry().unwrap();
25052 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25053 assert_eq!(
25054 providers
25055 .iter()
25056 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25057 .collect::<Vec<_>>(),
25058 vec![
25059 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25060 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25061 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25062 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25063 ]
25064 );
25065 assert_eq!(
25066 adapters
25067 .iter()
25068 .map(|contract| (
25069 contract.provider.as_str(),
25070 contract.family,
25071 contract.adapter_id.as_str(),
25072 contract.revision.as_str(),
25073 ))
25074 .collect::<Vec<_>>(),
25075 vec![
25076 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25077 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25078 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25079 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25080 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25081 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25082 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25083 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25084 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25085 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25086 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25087 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25088 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25089 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25090 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25091 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25092 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25093 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25094 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25095 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25096 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25097 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25098 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25099 ]
25100 );
25101 }
25102
25103 fn construction_test_config(name: &str) -> NodeServerConfig {
25104 let workspace = WorkspaceConfig::new(
25105 WorkspaceId::new("primary").unwrap(),
25106 std::env::current_dir().unwrap(),
25107 )
25108 .unwrap();
25109 NodeServerConfig::new(
25110 format!(r"\\.\pipe\gate4agent-{name}"),
25111 "fixture-token",
25112 NodeId::new(name).unwrap(),
25113 [workspace],
25114 )
25115 .unwrap()
25116 }
25117
25118 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25119 let adapter = AdapterBinding::new(
25120 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25121 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25122 gate4agent_types::AdapterVerification::SyntheticFixture,
25123 )
25124 .unwrap();
25125 let agent_ids = ["claude", "codex", "kimi"]
25126 .into_iter()
25127 .map(|agent_id| AgentId::new(agent_id).unwrap())
25128 .collect::<Vec<_>>();
25129 let adapters = gate4agent_catalog::AdapterRegistry::new(
25130 [
25131 AdapterFamily::PtySemantic,
25132 AdapterFamily::Pipe,
25133 AdapterFamily::OneShot,
25134 AdapterFamily::Acp,
25135 AdapterFamily::Hook,
25136 AdapterFamily::ManagedHook,
25137 AdapterFamily::History,
25138 AdapterFamily::Resume,
25139 AdapterFamily::SessionOptions,
25140 AdapterFamily::CapabilityProbe,
25141 ]
25142 .into_iter()
25143 .map(|family| gate4agent_catalog::AdapterDescriptor {
25144 family,
25145 binding: adapter.clone(),
25146 agents: agent_ids.clone(),
25147 }),
25148 )
25149 .unwrap();
25150 let specs = agent_ids
25151 .iter()
25152 .map(|agent_id| {
25153 let agent_id = agent_id.as_str();
25154 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25155 spec.revision =
25156 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25157 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25158 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25159 adapter: adapter.clone(),
25160 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25161 launch_override: None,
25162 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25163 });
25164 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25165 adapter: adapter.clone(),
25166 launch_override: None,
25167 });
25168 spec.capabilities.adapters.hook = Some(adapter.clone());
25169 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25170 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25171 spec.capabilities.adapters.history = Some(adapter.clone());
25172 spec.capabilities.adapters.resume = Some(adapter.clone());
25173 spec.capabilities.adapters.session_options = Some(adapter.clone());
25174 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25175 spec
25176 })
25177 .collect::<Vec<_>>();
25178 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25179 }
25180
25181 #[test]
25189 fn node_server_construction_accepts_the_production_manifest() {
25190 let registry = active_registry().unwrap();
25191 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25192 assert_eq!(providers.len(), registry.iter().count());
25193 assert!(!providers.is_empty());
25194 let server = NodeServer::new_with_registry(
25195 construction_test_config("manifest-production-bound"),
25196 registry,
25197 )
25198 .unwrap();
25199 drop(server);
25200 }
25201
25202 #[test]
25203 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25204 let registry = maximum_size_provider_manifest_registry();
25205 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25206 assert_eq!(providers.len(), 3);
25207 assert_eq!(adapters.len(), 30);
25208 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25209 let error = match NodeServer::new_with_registry(
25210 construction_test_config("manifest-handshake-overflow"),
25211 registry,
25212 ) {
25213 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25214 Err(error) => error,
25215 };
25216 assert!(matches!(
25217 error,
25218 NodeServerError::ProviderContractManifest(message)
25219 if message.contains("authentication binding length")
25220 && message.contains("8192-byte limit")
25221 ));
25222 }
25223
25224 #[test]
25225 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25226 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25227 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25228 let registry = AgentRegistry::new([spec]).unwrap();
25229 assert!(matches!(
25230 provider_contract_manifest(®istry),
25231 Err(NodeServerError::ProviderContractManifest(message))
25232 if message.contains("exceeds the 128-byte limit")
25233 ));
25234 }
25235
25236 #[test]
25237 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25238 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25239 spec.revision = "invalid revision".to_owned();
25240 let registry = AgentRegistry::new([spec]).unwrap();
25241 assert!(matches!(
25242 provider_contract_manifest(®istry),
25243 Err(NodeServerError::ProviderContractManifest(message))
25244 if message.contains("bounded lowercase ASCII")
25245 ));
25246 }
25247
25248 #[test]
25249 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25250 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25251 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25252 let conflicting_binding = builtin_registry()
25253 .get_by_id("codex")
25254 .unwrap()
25255 .capabilities
25256 .adapters
25257 .one_shot
25258 .clone()
25259 .unwrap();
25260 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25261 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25262 pipe.adapter = transport_binding;
25263 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25264 let registry = AgentRegistry::new([spec]).unwrap();
25265 assert!(matches!(
25266 provider_contract_manifest(®istry),
25267 Err(NodeServerError::ProviderContractManifest(message))
25268 if message.contains("conflicting OneShot adapter bindings")
25269 ));
25270 }
25271
25272 #[test]
25273 fn public_node_failures_expose_only_fixed_categories() {
25274 let provider = failure(
25275 NodeFailureCode::BackendOperationFailed,
25276 r"provider bearer-secret failed at C:\private\session.jsonl",
25277 );
25278 assert_eq!(provider.message, "backend-operation-failed");
25279 assert!(!provider.message.contains("bearer-secret"));
25280 assert!(!provider.message.contains(r"C:\private"));
25281 let persistence = persistence_failure(io::Error::new(
25282 io::ErrorKind::Other,
25283 r"state write failed at C:\private\state-v1.json",
25284 ));
25285 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25286 assert!(!persistence.message.contains(r"C:\private"));
25287
25288 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25289 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25290 assert_eq!(
25291 generic.to_string(),
25292 "node durable state failed: durable-state-load-failed",
25293 );
25294 }
25295 }
25296
25297 #[test]
25298 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25299 let sequence = b"\x1b[1;5D".to_vec();
25300 assert_eq!(
25301 terminal_bytes_action(sequence.clone()).unwrap(),
25302 InputAction::TerminalBytes(sequence),
25303 );
25304 assert_eq!(
25305 terminal_bytes_action(Vec::new()).unwrap_err().code,
25306 NodeFailureCode::InvalidRequest,
25307 );
25308 assert_eq!(
25309 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25310 .unwrap_err()
25311 .code,
25312 NodeFailureCode::InvalidRequest,
25313 );
25314 }
25315
25316 #[test]
25317 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25318 let root = temporary_workspace_root("exclusive-state-owner");
25319 std::fs::create_dir_all(&root).unwrap();
25320 let workspace = WorkspaceConfig::new(
25321 WorkspaceId::new("primary").unwrap(),
25322 &root,
25323 )
25324 .unwrap();
25325 let state_path = root.join("state-v1.json");
25326 let endpoint = format!(
25327 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25328 std::process::id(),
25329 std::time::SystemTime::now()
25330 .duration_since(std::time::UNIX_EPOCH)
25331 .unwrap()
25332 .as_nanos(),
25333 );
25334 let config = NodeServerConfig::new(
25335 endpoint,
25336 "fixture-token",
25337 NodeId::new("node-1").unwrap(),
25338 [workspace],
25339 )
25340 .unwrap()
25341 .with_state_path(&state_path)
25342 .unwrap();
25343
25344 let first = NodeServer::new(config.clone()).unwrap();
25345 let error = match NodeServer::new(config.clone()) {
25346 Ok(_) => panic!("a second node server acquired the same durable state path"),
25347 Err(error) => error,
25348 };
25349 assert!(matches!(
25350 &error,
25351 NodeServerError::DurableState(error)
25352 if error.kind() == io::ErrorKind::WouldBlock
25353 ));
25354 assert_eq!(
25355 error.to_string(),
25356 "node durable state failed: durable-state-lock-failed",
25357 );
25358 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25359 drop(first);
25360 let second = NodeServer::new(config).unwrap();
25361 drop(second);
25362 std::fs::remove_dir_all(root).unwrap();
25363 }
25364
25365 #[test]
25366 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25367 let catalog = active_registry().unwrap();
25368 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25369 let workspace_id = WorkspaceId::new("primary").unwrap();
25370 let workspace = WorkspaceConfig::new(
25371 workspace_id.clone(),
25372 std::env::current_dir().unwrap(),
25373 )
25374 .unwrap();
25375 let shared = NodeShared::new(
25376 handle,
25377 "fixture-token".to_owned(),
25378 NodeId::new("node-1").unwrap(),
25379 vec![workspace.clone()],
25380 vec![agent("claude"), agent("codex"), agent("kimi")],
25381 );
25382 let instance_id = AgentInstanceId(17);
25383 let generation = SessionGeneration(3);
25384 assert!(shared.address_for(instance_id, generation).is_none());
25385 shared.bind_session(instance_id, workspace_id.clone(), generation);
25386 let address = SessionAddress {
25387 workspace_id,
25388 session: SessionKey { instance_id, generation },
25389 };
25390 assert_eq!(
25391 shared.workspace_root(&address.workspace_id).unwrap(),
25392 workspace.canonical_root,
25393 );
25394 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25395 shared.remove_binding(&address);
25396 assert!(shared.address_for(instance_id, generation).is_none());
25397 }
25398
25399 #[test]
25400 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25401 let address = SessionAddress {
25402 workspace_id: WorkspaceId::new("primary").unwrap(),
25403 session: SessionKey {
25404 instance_id: AgentInstanceId(7),
25405 generation: SessionGeneration(2),
25406 },
25407 };
25408 let record_id = SessionRecordId::new("record-a").unwrap();
25409 let size = gate4agent_types::TerminalSize {
25410 rows: 24,
25411 columns: 80,
25412 };
25413 let address_requests = [
25414 NodeRequest::Resume {
25415 session: address.clone(),
25416 terminal_size: size,
25417 initial_prompt: None,
25418 },
25419 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25420 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25421 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25422 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25423 NodeRequest::TerminalControl {
25424 session: address.clone(),
25425 control: TerminalControl::Enter,
25426 },
25427 NodeRequest::Resize { session: address.clone(), size },
25428 NodeRequest::Interrupt { session: address.clone() },
25429 NodeRequest::Stop { session: address.clone(), force: false },
25430 NodeRequest::Remove { session: address },
25431 ];
25432 for request in &address_requests {
25433 assert!(request_requires_open_provider_ids_with(
25434 request,
25435 |_| Some(agent("third-party-agent")),
25436 |_| Some(agent("claude")),
25437 ));
25438 assert!(!request_requires_open_provider_ids_with(
25439 request,
25440 |_| Some(agent("claude")),
25441 |_| Some(agent("claude")),
25442 ));
25443 assert!(request_requires_open_provider_ids_with(
25444 request,
25445 |_| None,
25446 |_| Some(agent("claude")),
25447 ));
25448 }
25449
25450 let record_requests = [
25451 NodeRequest::RenameSessionRecord {
25452 record_id: record_id.clone(),
25453 display_name: "renamed".to_owned(),
25454 },
25455 NodeRequest::ResumeSessionRecord {
25456 record_id: record_id.clone(),
25457 terminal_size: size,
25458 initial_prompt: None,
25459 },
25460 NodeRequest::ForgetSessionRecord { record_id },
25461 ];
25462 for request in &record_requests {
25463 assert!(request_requires_open_provider_ids_with(
25464 request,
25465 |_| Some(agent("claude")),
25466 |_| Some(agent("grok")),
25467 ));
25468 assert!(!request_requires_open_provider_ids_with(
25469 request,
25470 |_| Some(agent("claude")),
25471 |_| Some(agent("kimi")),
25472 ));
25473 assert!(request_requires_open_provider_ids_with(
25474 request,
25475 |_| Some(agent("claude")),
25476 |_| None,
25477 ));
25478 }
25479
25480 assert!(request_requires_open_provider_ids_with(
25481 &NodeRequest::Spawn {
25482 workspace_id: WorkspaceId::new("primary").unwrap(),
25483 provider: agent("third-party-agent"),
25484 mode: SessionMode::Pty,
25485 terminal_size: size,
25486 initial_prompt: None,
25487 },
25488 |_| None,
25489 |_| None,
25490 ));
25491 assert!(!request_requires_open_provider_ids_with(
25492 &NodeRequest::Snapshot,
25493 |_| None,
25494 |_| None,
25495 ));
25496 assert!(request_requires_open_provider_ids_with(
25497 &NodeRequest::ForgetContextPack {
25498 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25499 },
25500 |_| None,
25501 |_| None,
25502 ));
25503 }
25504
25505 #[test]
25506 fn legacy_projection_filters_open_record_replies_and_event_history() {
25507 let catalog = active_registry().unwrap();
25508 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25509 let workspace = WorkspaceConfig::new(
25510 WorkspaceId::new("primary").unwrap(),
25511 std::env::current_dir().unwrap(),
25512 )
25513 .unwrap();
25514 let shared = NodeShared::new(
25515 handle,
25516 "fixture-token".to_owned(),
25517 NodeId::new("node-1").unwrap(),
25518 vec![workspace],
25519 vec![agent("claude"), agent("grok")],
25520 );
25521 let legacy = record("claude", "legacy-record");
25522 let open = record("grok", "open-record");
25523 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25524 record: legacy.clone(),
25525 });
25526 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25527 record: open.clone(),
25528 });
25529 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25530 record_id: legacy.record_id.clone(),
25531 });
25532 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25533 record_id: open.record_id.clone(),
25534 });
25535 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25536 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25537 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25538 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25539
25540 let mut record_reply = ResponseEnvelope {
25541 request_id: 1,
25542 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25543 };
25544 project_response_legacy_provider_ids(&shared, &mut record_reply);
25545 assert!(matches!(
25546 record_reply.result,
25547 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25548 ));
25549
25550 let mut resync = ResponseEnvelope {
25551 request_id: 2,
25552 result: Ok(NodeResponse::Resync {
25553 event_sequence: open_removed.sequence,
25554 oldest_available_sequence: 1,
25555 snapshot: shared.snapshot(),
25556 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25557 }),
25558 };
25559 project_response_legacy_provider_ids(&shared, &mut resync);
25560 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25561 panic!("legacy projection changed the resync response kind");
25562 };
25563 assert!(snapshot.session_records.iter().all(|item| {
25564 provider_id_is_legacy(&item.provider)
25565 }));
25566 assert_eq!(events.len(), 2);
25567 assert!(events.iter().all(|event| matches!(
25568 &event.event,
25569 NodeEvent::SessionRecordUpserted { record }
25570 if provider_id_is_legacy(&record.provider)
25571 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25572 }
25573
25574 #[test]
25575 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25576 let catalog = active_registry().unwrap();
25577 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25578 let workspace_id = WorkspaceId::new("primary").unwrap();
25579 let workspace = WorkspaceConfig::new(
25580 workspace_id.clone(),
25581 std::env::current_dir().unwrap(),
25582 )
25583 .unwrap();
25584 let shared = NodeShared::new(
25585 handle,
25586 "fixture-token".to_owned(),
25587 NodeId::new("node-1").unwrap(),
25588 vec![workspace],
25589 vec![agent("claude")],
25590 );
25591 for index in 0..CONTROL_SESSIONS_MAX {
25592 shared.bind_session(
25593 AgentInstanceId((index + 1) as u64),
25594 workspace_id.clone(),
25595 SessionGeneration(1),
25596 );
25597 }
25598 let error = shared.ensure_binding_capacity().unwrap_err();
25599 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25600 }
25601
25602 #[tokio::test]
25603 async fn provider_spawn_admission_rejects_before_session_mutation() {
25604 let catalog = active_registry().unwrap();
25605 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25606 let workspace = WorkspaceConfig::new(
25607 WorkspaceId::new("primary").unwrap(),
25608 std::env::current_dir().unwrap(),
25609 )
25610 .unwrap();
25611 let mut shared = NodeShared::new(
25612 handle,
25613 "fixture-token".to_owned(),
25614 NodeId::new("node-provider-admission").unwrap(),
25615 vec![workspace],
25616 vec![agent("claude")],
25617 );
25618 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25619 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25620 ])
25621 .unwrap();
25622 let terminal_size = gate4agent_types::TerminalSize {
25623 rows: 24,
25624 columns: 80,
25625 };
25626
25627 let unavailable = shared
25628 .spawn_session(
25629 WorkspaceId::new("primary").unwrap(),
25630 agent("claude"),
25631 SessionMode::Pty,
25632 terminal_size,
25633 None,
25634 )
25635 .await
25636 .unwrap_err();
25637 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25638 assert_eq!(unavailable.message, "backend-operation-failed");
25639
25640 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25641 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25642 agent("claude"),
25643 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25644 ),
25645 ])
25646 .unwrap();
25647 let semantic = shared
25648 .spawn_session(
25649 WorkspaceId::new("primary").unwrap(),
25650 agent("claude"),
25651 SessionMode::Inline,
25652 terminal_size,
25653 None,
25654 )
25655 .await
25656 .unwrap_err();
25657 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25658 assert_eq!(semantic.message, "unsupported-capability");
25659
25660 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25661 assert!(shared
25662 .session_bindings
25663 .lock()
25664 .unwrap_or_else(|poisoned| poisoned.into_inner())
25665 .is_empty());
25666 assert!(shared
25667 .session_records
25668 .lock()
25669 .unwrap_or_else(|poisoned| poisoned.into_inner())
25670 .records
25671 .is_empty());
25672 assert!(shared.handle.snapshot().sessions.is_empty());
25673 }
25674
25675 #[tokio::test]
25676 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25677 let catalog = active_registry().unwrap();
25678 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25679 let workspace_id = WorkspaceId::new("primary").unwrap();
25680 let workspace = WorkspaceConfig::new(
25681 workspace_id.clone(),
25682 std::env::current_dir().unwrap(),
25683 )
25684 .unwrap();
25685 let shared = NodeShared::new(
25686 handle,
25687 "fixture-token".to_owned(),
25688 NodeId::new("node-raw-spawn-record").unwrap(),
25689 vec![workspace],
25690 vec![agent("claude")],
25691 );
25692 let address = SessionAddress {
25693 workspace_id,
25694 session: SessionKey {
25695 instance_id: AgentInstanceId(71),
25696 generation: SessionGeneration::default(),
25697 },
25698 };
25699
25700 let record_id = shared
25701 .bind_spawn_session(
25702 &address,
25703 agent("claude"),
25704 SessionMode::Pty,
25705 ProviderRuntimePolicy::raw_pty(),
25706 SpawnRecordPolicy::Always,
25707 None,
25708 )
25709 .unwrap()
25710 .unwrap();
25711 shared
25712 .handle
25713 .dispatch(shared.prepare_command(ControlCommand::Register {
25714 instance_id: address.session.instance_id,
25715 agent_id: agent("claude"),
25716 transport: TransportKind::Pty,
25717 }))
25718 .unwrap();
25719 runtime.tick().await;
25720
25721 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25722 session.instance_id == address.session.instance_id
25723 && session.generation == address.session.generation
25724 }));
25725 let record = shared.record(&record_id).unwrap();
25726 assert_eq!(record.display_name, "claude #1");
25727 assert_eq!(record.state, ManagedSessionState::Live);
25728 assert_eq!(record.active_session.as_ref(), Some(&address));
25729 assert!(record.provider_session.is_none());
25730 let binding_record_id = shared
25731 .session_bindings
25732 .lock()
25733 .unwrap_or_else(|poisoned| poisoned.into_inner())
25734 .get(&address.session.instance_id)
25735 .and_then(|binding| binding.record_id.clone());
25736 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25737
25738 let renamed = shared
25739 .rename_session_record(&record_id, "raw review".to_owned())
25740 .unwrap();
25741 assert_eq!(renamed.record_id, record_id);
25742 assert_eq!(renamed.display_name, "raw review");
25743 shared.reconcile_managed_record(
25744 &record_id,
25745 &address,
25746 &ControlEvent {
25747 sequence: 1,
25748 command_id: None,
25749 instance_id: address.session.instance_id,
25750 generation: address.session.generation,
25751 event: ControlEventKind::Running { process_id: None },
25752 },
25753 false,
25754 false,
25755 );
25756 let running = shared.record(&record_id).unwrap();
25757 assert_eq!(running.state, ManagedSessionState::Live);
25758 assert_eq!(running.active_session.as_ref(), Some(&address));
25759 assert_eq!(running.display_name, "raw review");
25760
25761 shared.reconcile_managed_record(
25762 &record_id,
25763 &address,
25764 &ControlEvent {
25765 sequence: 2,
25766 command_id: None,
25767 instance_id: address.session.instance_id,
25768 generation: address.session.generation,
25769 event: ControlEventKind::Exited {
25770 exit_code: Some(0),
25771 forced: false,
25772 },
25773 },
25774 false,
25775 false,
25776 );
25777 let detached = shared.record(&record_id).unwrap();
25778 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25779 assert!(detached.active_session.is_none());
25780 assert_eq!(detached.display_name, "raw review");
25781 let renamed_detached = shared
25782 .rename_session_record(&record_id, "raw review detached".to_owned())
25783 .unwrap();
25784 assert_eq!(renamed_detached.record_id, record_id);
25785 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25786 assert!(renamed_detached.active_session.is_none());
25787 assert_eq!(renamed_detached.display_name, "raw review detached");
25788 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25789 }
25790
25791 #[test]
25792 fn legacy_raw_spawn_binding_remains_untracked() {
25793 let catalog = active_registry().unwrap();
25794 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25795 let workspace_id = WorkspaceId::new("primary").unwrap();
25796 let workspace = WorkspaceConfig::new(
25797 workspace_id.clone(),
25798 std::env::current_dir().unwrap(),
25799 )
25800 .unwrap();
25801 let shared = NodeShared::new(
25802 handle,
25803 "fixture-token".to_owned(),
25804 NodeId::new("node-legacy-raw-spawn").unwrap(),
25805 vec![workspace],
25806 vec![agent("claude")],
25807 );
25808 let address = SessionAddress {
25809 workspace_id,
25810 session: SessionKey {
25811 instance_id: AgentInstanceId(72),
25812 generation: SessionGeneration::default(),
25813 },
25814 };
25815
25816 let record_id = shared
25817 .bind_spawn_session(
25818 &address,
25819 agent("claude"),
25820 SessionMode::Pty,
25821 ProviderRuntimePolicy::raw_pty(),
25822 SpawnRecordPolicy::ProviderIdentityOnly,
25823 None,
25824 )
25825 .unwrap();
25826
25827 assert!(record_id.is_none());
25828 let binding_record_id = shared
25829 .session_bindings
25830 .lock()
25831 .unwrap_or_else(|poisoned| poisoned.into_inner())
25832 .get(&address.session.instance_id)
25833 .and_then(|binding| binding.record_id.clone());
25834 assert!(binding_record_id.is_none());
25835 assert!(shared.snapshot().session_records.is_empty());
25836 }
25837
25838 #[tokio::test]
25839 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25840 let catalog = active_registry().unwrap();
25841 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25842 let workspace_id = WorkspaceId::new("primary").unwrap();
25843 let workspace = WorkspaceConfig::new(
25844 workspace_id.clone(),
25845 std::env::current_dir().unwrap(),
25846 )
25847 .unwrap();
25848 let mut shared = NodeShared::new(
25849 handle,
25850 "fixture-token".to_owned(),
25851 NodeId::new("node-raw-semantic-gate").unwrap(),
25852 vec![workspace],
25853 vec![agent("claude")],
25854 );
25855 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25856 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25857 ])
25858 .unwrap();
25859 shared
25860 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25861 .unwrap();
25862 let address = SessionAddress {
25863 workspace_id,
25864 session: SessionKey {
25865 instance_id: AgentInstanceId(72),
25866 generation: SessionGeneration::default(),
25867 },
25868 };
25869 shared.bind_session_with_policy(
25870 &address,
25871 ProviderRuntimePolicy::raw_pty(),
25872 None,
25873 );
25874 shared
25875 .handle
25876 .dispatch(shared.prepare_command(ControlCommand::Register {
25877 instance_id: address.session.instance_id,
25878 agent_id: agent("claude"),
25879 transport: TransportKind::Pty,
25880 }))
25881 .unwrap();
25882 runtime.tick().await;
25883 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25884 let revision_before = shared.handle.snapshot().revision;
25885 let history_sequence_before = shared.current_sequence();
25886 let terminal_size = gate4agent_types::TerminalSize {
25887 rows: 24,
25888 columns: 80,
25889 };
25890
25891 for request in [
25892 NodeRequest::Prompt {
25893 session: address.clone(),
25894 text: "prompt".to_owned(),
25895 },
25896 NodeRequest::Paste {
25897 session: address.clone(),
25898 text: "paste".to_owned(),
25899 },
25900 NodeRequest::Resume {
25906 session: address.clone(),
25907 terminal_size,
25908 initial_prompt: Some("resume".to_owned()),
25909 },
25910 ] {
25911 let error = process_request_inner(
25912 &shared,
25913 77,
25914 ClientRole::Operator,
25915 request,
25916 )
25917 .await
25918 .unwrap_err();
25919 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25920 }
25921
25922 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25923 assert_eq!(shared.handle.snapshot().revision, revision_before);
25924 assert_eq!(shared.current_sequence(), history_sequence_before);
25925 let bindings = shared
25926 .session_bindings
25927 .lock()
25928 .unwrap_or_else(|poisoned| poisoned.into_inner());
25929 let binding = bindings.get(&address.session.instance_id).unwrap();
25930 assert_eq!(binding.generation, address.session.generation);
25931 assert!(binding.pending_resume.is_none());
25932 assert!(binding.record_id.is_none());
25933 }
25934
25935 #[tokio::test]
25936 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25937 let catalog = active_registry().unwrap();
25938 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25939 let primary = WorkspaceConfig::new(
25940 WorkspaceId::new("primary").unwrap(),
25941 std::env::current_dir().unwrap(),
25942 )
25943 .unwrap();
25944 let shared = NodeShared::new(
25945 handle,
25946 "fixture-token".to_owned(),
25947 NodeId::new("node-1").unwrap(),
25948 vec![primary],
25949 vec![agent("claude")],
25950 );
25951 shared
25952 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25953 .unwrap();
25954 let before = shared.snapshot();
25955 let error = process_request_inner(
25956 &shared,
25957 77,
25958 ClientRole::Operator,
25959 NodeRequest::RegisterWorkspace {
25960 workspace_id: WorkspaceId::new("foreign").unwrap(),
25961 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25962 },
25963 )
25964 .await
25965 .unwrap_err();
25966 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25967 assert_eq!(error.message, "invalid-request");
25968 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25969 }
25970
25971 #[tokio::test]
25972 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25973 let catalog = active_registry().unwrap();
25974 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25975 let primary_id = WorkspaceId::new("primary").unwrap();
25976 let primary = WorkspaceConfig::new(
25977 primary_id.clone(),
25978 std::env::current_dir().unwrap(),
25979 )
25980 .unwrap();
25981 let shared = NodeShared::new(
25982 handle,
25983 "fixture-token".to_owned(),
25984 NodeId::new("node-1").unwrap(),
25985 vec![primary.clone()],
25986 vec![agent("claude")],
25987 );
25988 let secondary_id = WorkspaceId::new("secondary").unwrap();
25989 let secondary = shared
25990 .register_workspace(
25991 secondary_id.clone(),
25992 std::env::temp_dir().to_string_lossy().into_owned(),
25993 )
25994 .await
25995 .unwrap();
25996 assert_eq!(secondary.workspace_id, secondary_id);
25997 assert!(secondary.sessions.is_empty());
25998 assert_eq!(shared.snapshot().workspaces.len(), 2);
25999
26000 let duplicate_id = shared
26001 .register_workspace(
26002 secondary_id.clone(),
26003 primary.canonical_root().to_owned(),
26004 )
26005 .await
26006 .unwrap_err();
26007 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26008 let duplicate_root = shared
26009 .register_workspace(
26010 WorkspaceId::new("duplicate-root").unwrap(),
26011 windows_path_text(&secondary.canonical_root).to_owned(),
26012 )
26013 .await
26014 .unwrap_err();
26015 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26016
26017 let address = SessionAddress {
26018 workspace_id: secondary_id.clone(),
26019 session: SessionKey {
26020 instance_id: AgentInstanceId(41),
26021 generation: SessionGeneration(1),
26022 },
26023 };
26024 shared.bind_session(
26025 address.session.instance_id,
26026 secondary_id.clone(),
26027 address.session.generation,
26028 );
26029 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26030 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26031 shared.remove_binding(&address);
26032 shared.unregister_workspace(&secondary_id).unwrap();
26033 assert_eq!(shared.snapshot().workspaces.len(), 1);
26034
26035 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26036 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26037 let history = shared
26038 .history
26039 .lock()
26040 .unwrap_or_else(|poisoned| poisoned.into_inner());
26041 assert!(matches!(
26042 &history.events[0],
26043 NodeEventEnvelope {
26044 sequence: 1,
26045 event: NodeEvent::WorkspaceAdded { workspace },
26046 } if workspace.workspace_id == secondary_id
26047 ));
26048 assert!(matches!(
26049 &history.events[1],
26050 NodeEventEnvelope {
26051 sequence: 2,
26052 event: NodeEvent::WorkspaceRemoved { workspace_id },
26053 } if workspace_id == &secondary_id
26054 ));
26055 }
26056
26057 #[tokio::test]
26058 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26059 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26060 std::fs::create_dir_all(&secondary_root).unwrap();
26061 let catalog = active_registry().unwrap();
26062 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26063 let primary_id = WorkspaceId::new("primary").unwrap();
26064 let secondary_id = WorkspaceId::new("secondary").unwrap();
26065 let shared = NodeShared::new(
26066 handle,
26067 "fixture-token".to_owned(),
26068 NodeId::new("node-session-reference-index").unwrap(),
26069 vec![
26070 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26071 .unwrap(),
26072 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26073 ],
26074 vec![agent("claude"), agent("codex")],
26075 );
26076 let identity = ProviderSessionIdentity {
26077 key: gate4agent_types::ProviderSessionKey::SessionId,
26078 id: "provider-session-42".to_owned(),
26079 transcript_path: None,
26080 };
26081 let index_request = |workspace_id: WorkspaceId,
26082 provider: AgentId,
26083 identity: ProviderSessionIdentity,
26084 display_name: &str| {
26085 NodeRequest::IndexProviderSession {
26086 workspace_id,
26087 provider,
26088 identity,
26089 display_name: display_name.to_owned(),
26090 }
26091 };
26092 let request = index_request(
26093 primary_id.clone(),
26094 agent("claude"),
26095 identity.clone(),
26096 "release shepherd",
26097 );
26098 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26099 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26100 }));
26101 let observer = process_request_inner(
26102 &shared,
26103 77,
26104 ClientRole::Observer,
26105 request.clone(),
26106 )
26107 .await
26108 .unwrap_err();
26109 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26110 let uncontrolled = process_request_inner(
26111 &shared,
26112 77,
26113 ClientRole::Operator,
26114 request.clone(),
26115 )
26116 .await
26117 .unwrap_err();
26118 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26119 shared
26120 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26121 .unwrap();
26122
26123 let indexed = process_request_inner(
26124 &shared,
26125 77,
26126 ClientRole::Operator,
26127 request.clone(),
26128 )
26129 .await
26130 .unwrap();
26131 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26132 panic!("provider session reference index returned another response");
26133 };
26134 assert_eq!(record.state, ManagedSessionState::Dormant);
26135 assert_eq!(record.mode, SessionMode::Pty);
26136 assert_eq!(record.workspace_id, primary_id);
26137 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26138 assert!(record.active_session.is_none());
26139 assert!(record.environment_profile.is_none());
26140 assert!(record.bundle.is_none());
26141 assert!(record.context.is_none());
26142
26143 let repeated = process_request_inner(
26144 &shared,
26145 77,
26146 ClientRole::Operator,
26147 index_request(
26148 primary_id.clone(),
26149 agent("claude"),
26150 identity.clone(),
26151 "ignored replacement",
26152 ),
26153 )
26154 .await
26155 .unwrap();
26156 assert!(matches!(
26157 repeated,
26158 NodeResponse::ProviderSessionIndexed { record: ref existing }
26159 if existing.record_id == record.record_id
26160 && existing.display_name == "release shepherd"
26161 ));
26162 let conflicting_workspace = process_request_inner(
26163 &shared,
26164 77,
26165 ClientRole::Operator,
26166 index_request(
26167 secondary_id,
26168 agent("claude"),
26169 identity.clone(),
26170 "release shepherd",
26171 ),
26172 )
26173 .await
26174 .unwrap_err();
26175 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26176 let conflicting_provider = process_request_inner(
26177 &shared,
26178 77,
26179 ClientRole::Operator,
26180 index_request(
26181 primary_id.clone(),
26182 agent("codex"),
26183 identity.clone(),
26184 "release shepherd",
26185 ),
26186 )
26187 .await
26188 .unwrap_err();
26189 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26190 let transcript_rejected = process_request_inner(
26191 &shared,
26192 77,
26193 ClientRole::Operator,
26194 index_request(
26195 primary_id,
26196 agent("claude"),
26197 ProviderSessionIdentity {
26198 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26199 ..identity
26200 },
26201 "release shepherd",
26202 ),
26203 )
26204 .await
26205 .unwrap_err();
26206 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26207 let upserts = shared
26208 .history
26209 .lock()
26210 .unwrap_or_else(|poisoned| poisoned.into_inner())
26211 .events
26212 .iter()
26213 .filter(|event| matches!(
26214 &event.event,
26215 NodeEvent::SessionRecordUpserted { record: published }
26216 if published.record_id == record.record_id
26217 ))
26218 .count();
26219 assert_eq!(upserts, 1);
26220 std::fs::remove_dir_all(secondary_root).unwrap();
26221 }
26222
26223 #[tokio::test]
26224 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26225 let fixture_root = temporary_workspace_root("standalone-independent");
26226 let target = fixture_root.join("independent");
26227 std::fs::create_dir_all(&fixture_root).unwrap();
26228 let catalog = active_registry().unwrap();
26229 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26230 let selected = WorkspaceConfig::new(
26231 WorkspaceId::new("primary").unwrap(),
26232 std::env::current_dir().unwrap(),
26233 )
26234 .unwrap();
26235 let selected_root = selected.canonical_root().to_owned();
26236 let shared = NodeShared::new(
26237 handle,
26238 "fixture-token".to_owned(),
26239 NodeId::new("node-standalone").unwrap(),
26240 vec![selected],
26241 vec![agent("claude")],
26242 );
26243 let workspace_id = WorkspaceId::new("independent").unwrap();
26244 let request = NodeRequest::CreateStandaloneWorkspace {
26245 workspace_id: workspace_id.clone(),
26246 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26247 initial_branch: Some("main".to_owned()),
26248 };
26249 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26250 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26251 }));
26252 assert!(!request_uses_unnegotiated_capability(
26253 &request,
26254 &baseline_capabilities().unwrap(),
26255 ));
26256 assert!(request_uses_unnegotiated_capability(&request, &[]));
26257
26258 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26259 .await
26260 .unwrap_err();
26261 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26262 assert!(!target.exists());
26263 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26264 .await
26265 .unwrap_err();
26266 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26267 assert!(!target.exists());
26268 shared
26269 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26270 .unwrap();
26271
26272 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26273 .await
26274 .unwrap();
26275 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26276 panic!("standalone workspace creation returned another response");
26277 };
26278 assert_eq!(workspace.workspace_id, workspace_id);
26279 assert!(target.join(".git").is_dir());
26280 let branch = run_git_bounded(
26281 target.to_str().unwrap(),
26282 &["branch", "--show-current"],
26283 GIT_OUTPUT_MAX_BYTES,
26284 )
26285 .await
26286 .unwrap();
26287 assert!(branch.success);
26288 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26289 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26290 assert_eq!(worktrees.len(), 1);
26291 let listed_root = WorkspaceConfig::new(
26292 WorkspaceId::new("listed-independent").unwrap(),
26293 &worktrees[0].path,
26294 )
26295 .unwrap();
26296 assert!(platform::roots_equal(
26297 listed_root.canonical_root(),
26298 windows_path_text(&workspace.canonical_root),
26299 ));
26300 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26301 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26302 snapshot.workspace_id == workspace_id
26303 && snapshot.canonical_root == workspace.canonical_root
26304 }));
26305 assert!(shared
26306 .history
26307 .lock()
26308 .unwrap_or_else(|poisoned| poisoned.into_inner())
26309 .events
26310 .iter()
26311 .any(|event| matches!(
26312 &event.event,
26313 NodeEvent::WorkspaceAdded { workspace }
26314 if workspace.workspace_id == workspace_id
26315 )));
26316 std::fs::remove_dir_all(fixture_root).unwrap();
26317 }
26318
26319 #[tokio::test]
26320 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26321 let fixture_root = temporary_workspace_root("standalone-compensation");
26322 let created_target = fixture_root.join("created-target");
26323 let existing_target = fixture_root.join("existing-target");
26324 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26325 let invalid_state_path = invalid_state_parent.join("state.json");
26326 std::fs::create_dir_all(&existing_target).unwrap();
26327 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26328 let catalog = active_registry().unwrap();
26329 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26330 let primary = WorkspaceConfig::new(
26331 WorkspaceId::new("primary").unwrap(),
26332 std::env::current_dir().unwrap(),
26333 )
26334 .unwrap();
26335 let mut shared = NodeShared::new(
26336 handle,
26337 "fixture-token".to_owned(),
26338 NodeId::new("node-standalone-compensation").unwrap(),
26339 vec![primary],
26340 vec![agent("claude")],
26341 );
26342 shared.state_path = Some(invalid_state_path);
26343
26344 let created_recovery = shared
26345 .create_standalone_workspace(
26346 WorkspaceId::new("compensated").unwrap(),
26347 created_target.to_string_lossy().into_owned(),
26348 Some("main".to_owned()),
26349 )
26350 .await
26351 .unwrap_err();
26352 assert_eq!(
26353 created_recovery.code,
26354 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26355 );
26356 assert!(created_target.join(".git").is_dir());
26357 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26358 workspace.workspace_id.as_str() != "compensated"
26359 }));
26360
26361 let existing_recovery = shared
26362 .create_standalone_workspace(
26363 WorkspaceId::new("restored").unwrap(),
26364 existing_target.to_string_lossy().into_owned(),
26365 None,
26366 )
26367 .await
26368 .unwrap_err();
26369 assert_eq!(
26370 existing_recovery.code,
26371 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26372 );
26373 assert!(existing_target.is_dir());
26374 assert!(existing_target.join(".git").is_dir());
26375 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26376 workspace.workspace_id.as_str() != "restored"
26377 }));
26378
26379 let registered_empty = fixture_root.join("registered-empty");
26380 std::fs::create_dir(®istered_empty).unwrap();
26381 let catalog = active_registry().unwrap();
26382 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26383 let registered = WorkspaceConfig::new(
26384 WorkspaceId::new("registered-empty").unwrap(),
26385 ®istered_empty,
26386 )
26387 .unwrap();
26388 let duplicate_shared = NodeShared::new(
26389 handle,
26390 "fixture-token".to_owned(),
26391 NodeId::new("node-standalone-duplicate-root").unwrap(),
26392 vec![registered],
26393 vec![agent("claude")],
26394 );
26395 let duplicate = duplicate_shared
26396 .create_standalone_workspace(
26397 WorkspaceId::new("duplicate-root").unwrap(),
26398 registered_empty.to_string_lossy().into_owned(),
26399 None,
26400 )
26401 .await
26402 .unwrap_err();
26403 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26404 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26405 std::fs::remove_dir_all(fixture_root).unwrap();
26406 }
26407
26408 #[test]
26409 fn managed_record_id_collision_preserves_the_existing_record() {
26410 let catalog = active_registry().unwrap();
26411 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26412 let workspace = WorkspaceConfig::new(
26413 WorkspaceId::new("primary").unwrap(),
26414 std::env::current_dir().unwrap(),
26415 )
26416 .unwrap();
26417 let shared = NodeShared::new(
26418 handle,
26419 "fixture-token".to_owned(),
26420 NodeId::new("node-1").unwrap(),
26421 vec![workspace.clone()],
26422 vec![agent("claude")],
26423 );
26424 let record_id = SessionRecordId::new("sr-collision").unwrap();
26425 let original = ManagedSessionRecord {
26426 record_id: record_id.clone(),
26427 display_name: "original".to_owned(),
26428 provider: agent("claude"),
26429 mode: SessionMode::Pty,
26430 state: ManagedSessionState::Dormant,
26431 workspace_id: workspace.workspace_id,
26432 canonical_root: opaque_windows_path(workspace.canonical_root),
26433 provider_session: None,
26434 active_session: None,
26435 environment_profile: None,
26436 bundle: None,
26437 context_id: None,
26438 context: None,
26439 exported_context: None,
26440 task_binding: None,
26441 created_at_unix_ms: 1,
26442 updated_at_unix_ms: 1,
26443 last_error: None,
26444 };
26445 shared.insert_record(original.clone()).unwrap();
26446 let mut collision = original.clone();
26447 collision.display_name = "replacement".to_owned();
26448 let error = shared.insert_record(collision).unwrap_err();
26449 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26450 assert_eq!(shared.record(&record_id).unwrap(), original);
26451 }
26452
26453 #[test]
26454 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26455 let catalog = active_registry().unwrap();
26456 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26457 let workspace = WorkspaceConfig::new(
26458 WorkspaceId::new("primary").unwrap(),
26459 std::env::current_dir().unwrap(),
26460 )
26461 .unwrap();
26462 let shared = NodeShared::new(
26463 handle,
26464 "fixture-token".to_owned(),
26465 NodeId::new("node-1").unwrap(),
26466 vec![workspace.clone()],
26467 vec![agent("claude")],
26468 );
26469 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26470 shared
26471 .insert_record(ManagedSessionRecord {
26472 record_id: record_id.clone(),
26473 display_name: "runtime error".to_owned(),
26474 provider: agent("claude"),
26475 mode: SessionMode::Pty,
26476 state: ManagedSessionState::IdentityPending,
26477 workspace_id: workspace.workspace_id,
26478 canonical_root: opaque_windows_path(workspace.canonical_root),
26479 provider_session: None,
26480 active_session: None,
26481 environment_profile: None,
26482 bundle: None,
26483 context_id: None,
26484 context: None,
26485 exported_context: None,
26486 task_binding: None,
26487 created_at_unix_ms: 1,
26488 updated_at_unix_ms: 1,
26489 last_error: None,
26490 })
26491 .unwrap();
26492
26493 shared
26494 .mark_record_error(
26495 &record_id,
26496 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26497 )
26498 .unwrap();
26499 let record = shared.record(&record_id).unwrap();
26500 assert_eq!(record.state, ManagedSessionState::Unavailable);
26501 assert_eq!(
26502 record.last_error.as_deref(),
26503 Some("provider-runtime-failed"),
26504 );
26505 let snapshot = shared.snapshot();
26506 assert_eq!(snapshot.session_records, vec![record]);
26507 }
26508
26509 #[test]
26518 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26519 let catalog = active_registry().unwrap();
26520 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26521 let workspace = WorkspaceConfig::new(
26522 WorkspaceId::new("primary").unwrap(),
26523 std::env::current_dir().unwrap(),
26524 )
26525 .unwrap();
26526 let shared = NodeShared::new(
26527 handle,
26528 "fixture-token".to_owned(),
26529 NodeId::new("node-1").unwrap(),
26530 vec![workspace],
26531 vec![agent("claude")],
26532 );
26533
26534 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26535
26536 shared.record_rejected_command(
26537 CommandId(1),
26538 "agent 'claude' does not support transport Acp".to_owned(),
26539 true,
26540 );
26541 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26543
26544 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26545 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26546 assert!(reason.unsupported_transport);
26547
26548 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26550
26551 shared.record_rejected_command(
26556 CommandId(3),
26557 "backend busy".to_owned(),
26558 false,
26559 );
26560 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26561 assert!(!unclassified.unsupported_transport);
26562 }
26563
26564 #[test]
26565 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26566 let catalog = active_registry().unwrap();
26567 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26568 let workspace = WorkspaceConfig::new(
26569 WorkspaceId::new("primary").unwrap(),
26570 std::env::current_dir().unwrap(),
26571 )
26572 .unwrap();
26573 let shared = NodeShared::new(
26574 handle,
26575 "fixture-token".to_owned(),
26576 NodeId::new("node-identity-policy").unwrap(),
26577 vec![workspace.clone()],
26578 vec![agent("claude")],
26579 );
26580 let address = SessionAddress {
26581 workspace_id: workspace.workspace_id.clone(),
26582 session: SessionKey {
26583 instance_id: AgentInstanceId(90),
26584 generation: SessionGeneration(1),
26585 },
26586 };
26587 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26588 shared
26589 .insert_record(ManagedSessionRecord {
26590 record_id: record_id.clone(),
26591 display_name: "policy denied".to_owned(),
26592 provider: agent("claude"),
26593 mode: SessionMode::Pty,
26594 state: ManagedSessionState::IdentityPending,
26595 workspace_id: workspace.workspace_id,
26596 canonical_root: opaque_windows_path(workspace.canonical_root),
26597 provider_session: None,
26598 active_session: Some(address.clone()),
26599 environment_profile: None,
26600 bundle: None,
26601 context_id: None,
26602 context: None,
26603 exported_context: None,
26604 task_binding: None,
26605 created_at_unix_ms: 1,
26606 updated_at_unix_ms: 1,
26607 last_error: None,
26608 })
26609 .unwrap();
26610 let event = ControlEvent {
26611 sequence: 1,
26612 command_id: None,
26613 instance_id: address.session.instance_id,
26614 generation: address.session.generation,
26615 event: ControlEventKind::ProviderEvent {
26616 sequence: 1,
26617 source: gate4agent_types::ProviderSource {
26618 family: AdapterFamily::PtySemantic,
26619 binding: AdapterBinding::new(
26620 gate4agent_types::AdapterId::new("claude").unwrap(),
26621 "fixture/v1",
26622 gate4agent_types::AdapterVerification::SyntheticFixture,
26623 )
26624 .unwrap(),
26625 },
26626 source_sequence: 1,
26627 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26628 identity: gate4agent_types::ProviderSessionIdentity {
26629 key: gate4agent_types::ProviderSessionKey::SessionId,
26630 id: "provider-session".to_owned(),
26631 transcript_path: None,
26632 },
26633 },
26634 },
26635 };
26636
26637 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26638
26639 let record = shared.record(&record_id).unwrap();
26640 assert!(record.provider_session.is_none());
26641 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26642 assert_eq!(record.active_session.as_ref(), Some(&address));
26643 }
26644
26645 #[test]
26646 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26647 let secondary_root = temporary_workspace_root("identity-scope");
26648 std::fs::create_dir_all(&secondary_root).unwrap();
26649 let catalog = active_registry().unwrap();
26650 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26651 let primary = WorkspaceConfig::new(
26652 WorkspaceId::new("primary").unwrap(),
26653 std::env::current_dir().unwrap(),
26654 )
26655 .unwrap();
26656 let secondary = WorkspaceConfig::new(
26657 WorkspaceId::new("secondary").unwrap(),
26658 &secondary_root,
26659 )
26660 .unwrap();
26661 let shared = NodeShared::new(
26662 handle,
26663 "fixture-token".to_owned(),
26664 NodeId::new("node-1").unwrap(),
26665 vec![primary.clone(), secondary.clone()],
26666 vec![agent("claude")],
26667 );
26668 let identity = gate4agent_types::ProviderSessionIdentity {
26669 key: gate4agent_types::ProviderSessionKey::SessionId,
26670 id: "provider-session".to_owned(),
26671 transcript_path: None,
26672 };
26673 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26674 shared
26675 .insert_record(ManagedSessionRecord {
26676 record_id: dormant_id.clone(),
26677 display_name: "dormant".to_owned(),
26678 provider: agent("claude"),
26679 mode: SessionMode::Pty,
26680 state: ManagedSessionState::Dormant,
26681 workspace_id: primary.workspace_id,
26682 canonical_root: opaque_windows_path(primary.canonical_root),
26683 provider_session: Some(identity.clone()),
26684 active_session: None,
26685 environment_profile: None,
26686 bundle: None,
26687 context_id: None,
26688 context: None,
26689 exported_context: None,
26690 task_binding: None,
26691 created_at_unix_ms: 1,
26692 updated_at_unix_ms: 1,
26693 last_error: None,
26694 })
26695 .unwrap();
26696 let address = SessionAddress {
26697 workspace_id: secondary.workspace_id.clone(),
26698 session: SessionKey {
26699 instance_id: AgentInstanceId(91),
26700 generation: SessionGeneration(1),
26701 },
26702 };
26703 let current_id = SessionRecordId::new("sr-current").unwrap();
26704 shared
26705 .insert_record(ManagedSessionRecord {
26706 record_id: current_id.clone(),
26707 display_name: "current".to_owned(),
26708 provider: agent("claude"),
26709 mode: SessionMode::Pty,
26710 state: ManagedSessionState::IdentityPending,
26711 workspace_id: secondary.workspace_id,
26712 canonical_root: opaque_windows_path(secondary.canonical_root),
26713 provider_session: None,
26714 active_session: Some(address.clone()),
26715 environment_profile: None,
26716 bundle: None,
26717 context_id: None,
26718 context: None,
26719 exported_context: None,
26720 task_binding: None,
26721 created_at_unix_ms: 2,
26722 updated_at_unix_ms: 2,
26723 last_error: None,
26724 })
26725 .unwrap();
26726 shared.bind_managed_session(
26727 &address,
26728 current_id.clone(),
26729 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26730 None,
26731 );
26732 let event = ControlEvent {
26733 sequence: 1,
26734 command_id: None,
26735 instance_id: address.session.instance_id,
26736 generation: address.session.generation,
26737 event: ControlEventKind::ProviderEvent {
26738 sequence: 1,
26739 source: gate4agent_types::ProviderSource {
26740 family: gate4agent_types::AdapterFamily::PtySemantic,
26741 binding: gate4agent_types::AdapterBinding::new(
26742 gate4agent_types::AdapterId::new("claude").unwrap(),
26743 "fixture/v1",
26744 gate4agent_types::AdapterVerification::SyntheticFixture,
26745 )
26746 .unwrap(),
26747 },
26748 source_sequence: 1,
26749 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26750 identity,
26751 },
26752 },
26753 };
26754 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26755
26756 let binding = shared
26757 .session_bindings
26758 .lock()
26759 .unwrap_or_else(|poisoned| poisoned.into_inner())
26760 .get(&address.session.instance_id)
26761 .cloned()
26762 .unwrap();
26763 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26764 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26765 let current = shared.record(¤t_id).unwrap();
26766 assert_eq!(current.state, ManagedSessionState::Unavailable);
26767 assert_eq!(current.active_session.as_ref(), Some(&address));
26768 assert_eq!(
26769 current.last_error.as_deref(),
26770 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26771 );
26772 std::fs::remove_dir_all(secondary_root).unwrap();
26773 }
26774
26775 #[tokio::test]
26776 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26777 let root = temporary_workspace_root("semantic-provider-session");
26778 std::fs::create_dir_all(&root).unwrap();
26779 let state_path = root.join("state-v1.json");
26780 let catalog = active_registry().unwrap();
26781 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26782 let workspace = WorkspaceConfig::new(
26783 WorkspaceId::new("primary").unwrap(),
26784 &root,
26785 )
26786 .unwrap();
26787 let node_id = NodeId::new("node-semantic-identity").unwrap();
26788 let shared = NodeShared::new_with_incarnation(
26789 handle,
26790 "fixture-token".to_owned(),
26791 node_id.clone(),
26792 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26793 vec![workspace.clone()],
26794 vec![agent("claude")],
26795 ProviderRuntimeStatuses::default(),
26796 None,
26797 Vec::new(),
26798 Vec::new(),
26799 SpawnProfileRegistry::default(),
26800 None,
26801 None,
26802 None,
26803 Some(state_path.clone()),
26804 Vec::new(),
26805 Vec::new(),
26806 Vec::new(),
26807 Vec::new(),
26808 None,
26809 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26810 );
26811 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26812 key: gate4agent_types::ProviderSessionKey::SessionId,
26813 id: "provider-session".to_owned(),
26814 transcript_path: None,
26815 };
26816 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26817 shared
26818 .insert_record(ManagedSessionRecord {
26819 record_id: durable_id.clone(),
26820 display_name: "durable".to_owned(),
26821 provider: agent("claude"),
26822 mode: SessionMode::Pty,
26823 state: ManagedSessionState::Dormant,
26824 workspace_id: workspace.workspace_id.clone(),
26825 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26826 provider_session: Some(persisted_identity.clone()),
26827 active_session: None,
26828 environment_profile: None,
26829 bundle: None,
26830 context_id: None,
26831 context: None,
26832 exported_context: None,
26833 task_binding: None,
26834 created_at_unix_ms: 1,
26835 updated_at_unix_ms: 1,
26836 last_error: None,
26837 })
26838 .unwrap();
26839 let address = SessionAddress {
26840 workspace_id: workspace.workspace_id.clone(),
26841 session: SessionKey {
26842 instance_id: AgentInstanceId(92),
26843 generation: SessionGeneration(2),
26844 },
26845 };
26846 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26847 shared
26848 .insert_record(ManagedSessionRecord {
26849 record_id: pending_id.clone(),
26850 display_name: "pending".to_owned(),
26851 provider: agent("claude"),
26852 mode: SessionMode::Pty,
26853 state: ManagedSessionState::IdentityPending,
26854 workspace_id: workspace.workspace_id.clone(),
26855 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26856 provider_session: None,
26857 active_session: Some(address.clone()),
26858 environment_profile: None,
26859 bundle: None,
26860 context_id: None,
26861 context: None,
26862 exported_context: None,
26863 task_binding: None,
26864 created_at_unix_ms: 2,
26865 updated_at_unix_ms: 2,
26866 last_error: None,
26867 })
26868 .unwrap();
26869 shared.bind_managed_session(
26870 &address,
26871 pending_id.clone(),
26872 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26873 None,
26874 );
26875 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26876 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26877 ..persisted_identity
26878 };
26879 let event = ControlEvent {
26880 sequence: 1,
26881 command_id: None,
26882 instance_id: address.session.instance_id,
26883 generation: address.session.generation,
26884 event: ControlEventKind::ProviderEvent {
26885 sequence: 1,
26886 source: gate4agent_types::ProviderSource {
26887 family: gate4agent_types::AdapterFamily::PtySemantic,
26888 binding: gate4agent_types::AdapterBinding::new(
26889 gate4agent_types::AdapterId::new("claude").unwrap(),
26890 "fixture/v1",
26891 gate4agent_types::AdapterVerification::SyntheticFixture,
26892 )
26893 .unwrap(),
26894 },
26895 source_sequence: 1,
26896 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26897 identity: observed_identity.clone(),
26898 },
26899 },
26900 };
26901 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26902
26903 let snapshot = shared.snapshot();
26904 assert_eq!(snapshot.session_records.len(), 1);
26905 let live = &snapshot.session_records[0];
26906 assert_eq!(live.record_id, durable_id);
26907 assert_eq!(live.state, ManagedSessionState::Live);
26908 assert_eq!(live.active_session.as_ref(), Some(&address));
26909 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26910 let binding = shared
26911 .session_bindings
26912 .lock()
26913 .unwrap_or_else(|poisoned| poisoned.into_inner())
26914 .get(&address.session.instance_id)
26915 .cloned()
26916 .unwrap();
26917 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26918
26919 let mut duplicate = live.clone();
26920 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26921 duplicate.display_name = "duplicate".to_owned();
26922 duplicate.state = ManagedSessionState::Dormant;
26923 duplicate.active_session = None;
26924 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26925 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26926 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26927 assert_eq!(shared.snapshot().session_records.len(), 1);
26928
26929 let terminal_size = gate4agent_types::TerminalSize {
26930 rows: 24,
26931 columns: 80,
26932 };
26933 let (first_busy, second_busy) = tokio::join!(
26934 shared.resume_session_record(&durable_id, terminal_size, None),
26935 shared.resume_session_record(&durable_id, terminal_size, None),
26936 );
26937 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26938 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26939 assert_eq!(busy.message, "session-record-busy");
26940 }
26941
26942 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26943 assert_eq!(loaded.records.len(), 1);
26944 assert_eq!(loaded.records[0].record_id, durable_id);
26945 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26946 assert_eq!(
26947 loaded.records[0]
26948 .provider_session
26949 .as_ref()
26950 .unwrap()
26951 .transcript_path,
26952 None,
26953 );
26954 let catalog = active_registry().unwrap();
26955 let (reloaded_handle, _reloaded_runtime) =
26956 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26957 let reloaded = NodeShared::new_with_incarnation(
26958 reloaded_handle,
26959 "fixture-token".to_owned(),
26960 node_id,
26961 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26962 vec![workspace],
26963 vec![agent("claude")],
26964 ProviderRuntimeStatuses::default(),
26965 None,
26966 Vec::new(),
26967 Vec::new(),
26968 SpawnProfileRegistry::default(),
26969 None,
26970 None,
26971 None,
26972 None,
26973 loaded.records,
26974 loaded.managed_worktrees,
26975 loaded.managed_worktree_tombstones,
26976 loaded.materializations,
26977 loaded.warning,
26978 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26979 );
26980 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26981 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26982 std::fs::remove_dir_all(root).unwrap();
26983 }
26984
26985 #[tokio::test]
26986 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26987 let secondary_root = temporary_workspace_root("workspace-restore");
26988 let other_root = temporary_workspace_root("workspace-conflict");
26989 std::fs::create_dir_all(&secondary_root).unwrap();
26990 std::fs::create_dir_all(&other_root).unwrap();
26991 let catalog = active_registry().unwrap();
26992 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26993 let primary = WorkspaceConfig::new(
26994 WorkspaceId::new("primary").unwrap(),
26995 std::env::current_dir().unwrap(),
26996 )
26997 .unwrap();
26998 let secondary = WorkspaceConfig::new(
26999 WorkspaceId::new("secondary").unwrap(),
27000 &secondary_root,
27001 )
27002 .unwrap();
27003 let shared = NodeShared::new(
27004 handle,
27005 "fixture-token".to_owned(),
27006 NodeId::new("node-1").unwrap(),
27007 vec![primary, secondary.clone()],
27008 vec![agent("claude")],
27009 );
27010 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27011 shared
27012 .insert_record(ManagedSessionRecord {
27013 record_id: record_id.clone(),
27014 display_name: "workspace record".to_owned(),
27015 provider: agent("claude"),
27016 mode: SessionMode::Pty,
27017 state: ManagedSessionState::Dormant,
27018 workspace_id: secondary.workspace_id.clone(),
27019 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27020 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27021 key: gate4agent_types::ProviderSessionKey::SessionId,
27022 id: "provider-session".to_owned(),
27023 transcript_path: None,
27024 }),
27025 active_session: None,
27026 environment_profile: None,
27027 bundle: None,
27028 context_id: None,
27029 context: None,
27030 exported_context: None,
27031 task_binding: None,
27032 created_at_unix_ms: 1,
27033 updated_at_unix_ms: 1,
27034 last_error: None,
27035 })
27036 .unwrap();
27037 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27038 let unavailable = shared.record(&record_id).unwrap();
27039 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27040 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27041
27042 let conflict = shared
27043 .register_workspace(
27044 secondary.workspace_id.clone(),
27045 other_root.to_string_lossy().into_owned(),
27046 )
27047 .await
27048 .unwrap_err();
27049 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27050 shared
27051 .register_workspace(
27052 secondary.workspace_id,
27053 secondary_root.to_string_lossy().into_owned(),
27054 )
27055 .await
27056 .unwrap();
27057 let restored = shared.record(&record_id).unwrap();
27058 assert_eq!(restored.state, ManagedSessionState::Dormant);
27059 assert!(restored.last_error.is_none());
27060 std::fs::remove_dir_all(secondary_root).unwrap();
27061 std::fs::remove_dir_all(other_root).unwrap();
27062 }
27063
27064 #[test]
27065 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27066 let error = validate_workspace_request_root(
27067 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27068 )
27069 .unwrap_err();
27070 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27071 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27072 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27073 }
27074
27075 #[test]
27076 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27077 let catalog = active_registry().unwrap();
27078 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27079 let workspace = WorkspaceConfig::new(
27080 WorkspaceId::new("primary").unwrap(),
27081 std::env::current_dir().unwrap(),
27082 )
27083 .unwrap();
27084 let shared = NodeShared::new(
27085 handle,
27086 "fixture-token".to_owned(),
27087 NodeId::new("node-1").unwrap(),
27088 vec![workspace],
27089 vec![agent("claude")],
27090 );
27091 let publishers = 4_u64;
27092 let events_per_publisher = 256_u64;
27093 let start = std::sync::Barrier::new(publishers as usize + 1);
27094 let finished = AtomicU64::new(0);
27095
27096 std::thread::scope(|scope| {
27097 for _ in 0..publishers {
27098 scope.spawn(|| {
27099 start.wait();
27100 for _ in 0..events_per_publisher {
27101 shared.publish(NodeEvent::ControllerChanged { controller: None });
27102 }
27103 finished.fetch_add(1, Ordering::Release);
27104 });
27105 }
27106 start.wait();
27107 while finished.load(Ordering::Acquire) != publishers {
27108 assert_resync_history_is_linearized(shared.resync(0));
27109 std::thread::yield_now();
27110 }
27111 });
27112 let response = shared.resync(0);
27113 assert_resync_history_is_linearized(response.clone());
27114 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27115 unreachable!("resync helper returned another response");
27116 };
27117 assert_eq!(event_sequence, publishers * events_per_publisher);
27118 assert_eq!(events.len() as u64, event_sequence);
27119 }
27120
27121 fn assert_resync_history_is_linearized(response: NodeResponse) {
27122 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27123 unreachable!("resync helper returned another response");
27124 };
27125 assert!(events.windows(2).all(|pair| {
27126 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27127 }));
27128 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27129 }
27130
27131 #[test]
27132 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27133 assert_eq!(
27134 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27135 r"C:\repo\worktree",
27136 );
27137 assert_eq!(
27138 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27139 r"\\server\share\repo",
27140 );
27141 assert!(validate_workspace_root(
27142 &WorkspaceId::new("network").unwrap(),
27143 r"\\server\share\repo",
27144 )
27145 .is_err());
27146 let workspace = WorkspaceConfig::new(
27147 WorkspaceId::new("primary").unwrap(),
27148 std::env::current_dir().unwrap(),
27149 )
27150 .unwrap();
27151 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27152 }
27153
27154 #[test]
27155 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27156 let catalog = active_registry().unwrap();
27157 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27158 let workspace_id = WorkspaceId::new("primary").unwrap();
27159 let workspace = WorkspaceConfig::new(
27160 workspace_id.clone(),
27161 std::env::current_dir().unwrap(),
27162 )
27163 .unwrap();
27164 let shared = NodeShared::new(
27165 handle,
27166 "fixture-token".to_owned(),
27167 NodeId::new("node-1").unwrap(),
27168 vec![workspace],
27169 vec![agent("claude")],
27170 );
27171 let instance_id = AgentInstanceId(17);
27172 let original = SessionAddress {
27173 workspace_id,
27174 session: SessionKey {
27175 instance_id,
27176 generation: SessionGeneration(3),
27177 },
27178 };
27179 shared.bind_session(
27180 instance_id,
27181 original.workspace_id.clone(),
27182 original.session.generation,
27183 );
27184 shared
27185 .arm_resume(
27186 &original,
27187 CommandId(41),
27188 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27189 )
27190 .unwrap();
27191
27192 shared.publish_control(ControlEvent {
27193 sequence: 1,
27194 command_id: None,
27195 instance_id,
27196 generation: SessionGeneration(4),
27197 event: ControlEventKind::Running { process_id: None },
27198 });
27199 assert_eq!(
27200 shared.address_for(instance_id, SessionGeneration(3)),
27201 Some(original.clone()),
27202 );
27203 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27204
27205 shared.publish_control(ControlEvent {
27206 sequence: 2,
27207 command_id: Some(CommandId(42)),
27208 instance_id,
27209 generation: SessionGeneration(3),
27210 event: ControlEventKind::CommandRejected {
27211 message: "unrelated command".to_owned(),
27212 },
27213 });
27214
27215 shared.publish_control(ControlEvent {
27216 sequence: 3,
27217 command_id: None,
27218 instance_id,
27219 generation: SessionGeneration(4),
27220 event: ControlEventKind::ResumeAuthorized {
27221 session: gate4agent_types::ResumeSessionSummary {
27222 key: gate4agent_types::ProviderSessionKey::SessionId,
27223 id: "fixture-session".to_owned(),
27224 },
27225 },
27226 });
27227 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27228 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27229 assert_eq!(rebound.workspace_id, original.workspace_id);
27230 let history = shared
27231 .history
27232 .lock()
27233 .unwrap_or_else(|poisoned| poisoned.into_inner());
27234 assert!(history.events.iter().any(|envelope| matches!(
27235 &envelope.event,
27236 NodeEvent::Control { address, event }
27237 if address == &rebound
27238 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27239 )));
27240 }
27241
27242 #[test]
27243 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27244 for agent in ["claude", "codex", "kimi"] {
27245 assert_eq!(
27246 prompt_framing(&AgentId::new(agent).unwrap()),
27247 PromptFraming::BracketedPaste
27248 );
27249 }
27250 }
27251
27252 fn generous_walk_deadline() -> Instant {
27253 Instant::now() + Duration::from_secs(30)
27254 }
27255
27256 #[test]
27257 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27258 let root = temporary_workspace_root("tree");
27259 std::fs::create_dir_all(root.join("src")).unwrap();
27260 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27261 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27262 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27263 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27264 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27265 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27271 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27272 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27276 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27277
27278 let (entries, truncated, _budget) =
27279 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27280 assert!(!truncated);
27281 assert!(entries.iter().any(|entry| {
27282 entry.relative_path.as_utf8() == Some("src")
27283 && entry.kind == WorkspaceEntryKind::Directory
27284 }));
27285 assert!(entries.iter().any(|entry| {
27286 entry.relative_path.as_utf8() == Some("src/lib.rs")
27287 && entry.kind == WorkspaceEntryKind::File
27288 }));
27289 assert!(entries.iter().any(|entry| {
27290 entry.relative_path.as_utf8() == Some("target-notes")
27291 && entry.kind == WorkspaceEntryKind::Directory
27292 }));
27293 assert!(entries.iter().any(|entry| {
27294 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27295 && entry.kind == WorkspaceEntryKind::File
27296 }));
27297 assert!(entries.iter().all(|entry| {
27298 let path = entry.relative_path.as_utf8().unwrap();
27299 !path.starts_with(".git")
27300 && !(path == "target" || path.starts_with("target/"))
27301 && !(path == "target-a2" || path.starts_with("target-a2/"))
27302 && !path.starts_with("node_modules")
27303 }));
27304
27305 std::fs::remove_dir_all(&root).unwrap();
27306 }
27307
27308 #[test]
27309 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27310 let root = temporary_workspace_root("tree-entry-cap");
27311 std::fs::create_dir_all(&root).unwrap();
27312 for index in 0..20 {
27313 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27314 }
27315
27316 let (entries, truncated, budget) =
27317 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27318 assert!(truncated);
27319 assert!(budget.entry_cap_exceeded);
27320 assert!(!budget.time_budget_exceeded);
27321 assert!(entries.len() <= 5);
27322
27323 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27324 .expect("an entry-cap-truncated walk must report a truncation marker");
27325 assert!(inspection_truncation.walk_entry_cap_exceeded);
27326 assert!(!inspection_truncation.walk_time_budget_exceeded);
27327 assert!(!inspection_truncation.git_time_budget_exceeded);
27328 assert!(inspection_truncation.entries_visited >= 5);
27329
27330 std::fs::remove_dir_all(&root).unwrap();
27331 }
27332
27333 #[test]
27334 fn workspace_walk_within_budget_reports_no_truncation() {
27335 let root = temporary_workspace_root("tree-no-truncation");
27336 std::fs::create_dir_all(&root).unwrap();
27337 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27338
27339 let (_entries, truncated, budget) =
27340 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27341 assert!(!truncated);
27342 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27343
27344 std::fs::remove_dir_all(&root).unwrap();
27345 }
27346
27347 #[test]
27348 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27349 let root = temporary_workspace_root("tree-prefix-siblings");
27350 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27351 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27352 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27353 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27354
27355 let (entries, truncated, _budget) =
27356 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27357 assert!(!truncated);
27358 let paths = entries
27359 .iter()
27360 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27361 .collect::<Vec<_>>();
27362 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27363 assert_eq!(
27364 paths,
27365 vec![
27366 "gate4agent-c2".to_owned(),
27367 "gate4agent-c2-client".to_owned(),
27368 "gate4agent-c2-client/lib.rs".to_owned(),
27369 "gate4agent-c2/src".to_owned(),
27370 "gate4agent-c2/src/lib.rs".to_owned(),
27371 ],
27372 );
27373
27374 std::fs::remove_dir_all(&root).unwrap();
27375 }
27376
27377 #[test]
27378 fn git_parsers_cap_status_and_commit_results() {
27379 let mut status_output = Vec::new();
27380 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27381 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27382 }
27383 let mut snapshot = git_snapshot_for_parser();
27384 parse_git_status(&status_output, &mut snapshot);
27385 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27386 assert!(snapshot.truncated);
27387 assert_eq!(snapshot.status[0].index_status, " ");
27388 assert_eq!(snapshot.status[0].worktree_status, "M");
27389
27390 snapshot.truncated = false;
27391 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27392 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27393 .collect::<String>();
27394 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27395 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27396 assert!(snapshot.truncated);
27397 }
27398
27399 #[test]
27400 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27401 let mut snapshot = git_snapshot_for_parser();
27402 parse_git_status(
27403 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27404 &mut snapshot,
27405 );
27406
27407 assert!(!snapshot.truncated);
27408 assert_eq!(snapshot.status.len(), 3);
27409 assert_eq!(snapshot.status[0].index_status, "R");
27410 assert_eq!(snapshot.status[0].worktree_status, " ");
27411 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27412 assert_eq!(
27413 snapshot.status[0]
27414 .previous_path
27415 .as_ref()
27416 .and_then(RepositoryPath::as_utf8),
27417 Some("src/previous name.rs"),
27418 );
27419 assert_eq!(snapshot.status[1].index_status, " ");
27420 assert_eq!(snapshot.status[1].worktree_status, "C");
27421 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27422 assert_eq!(
27423 snapshot.status[1]
27424 .previous_path
27425 .as_ref()
27426 .and_then(RepositoryPath::as_utf8),
27427 Some("src/original.rs"),
27428 );
27429 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27430 assert!(snapshot.status[2].previous_path.is_none());
27431 }
27432
27433 #[test]
27434 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27435 let mut snapshot = git_snapshot_for_parser();
27436 parse_git_status(
27437 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27438 &mut snapshot,
27439 );
27440
27441 assert!(snapshot.truncated);
27442 assert_eq!(snapshot.status.len(), 1);
27443 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27444 assert!(snapshot.status[0].previous_path.is_none());
27445 }
27446
27447 #[tokio::test]
27448 async fn observer_can_browse_host_directories_without_controller() {
27449 let root = temporary_workspace_root("host-directory-observer");
27450 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27451 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27452 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27453 let workspace_id = WorkspaceId::new("primary").unwrap();
27454 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27455 let catalog = active_registry().unwrap();
27456 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27457 let shared = NodeShared::new(
27458 handle,
27459 "fixture-token".to_owned(),
27460 NodeId::new("node-1").unwrap(),
27461 vec![workspace],
27462 vec![agent("claude")],
27463 );
27464 let request = NodeRequest::BrowseHostDirectories {
27465 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27466 after: None,
27467 };
27468 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27469 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27470 }));
27471 assert!(!request_uses_unnegotiated_capability(
27472 &request,
27473 &baseline_capabilities().unwrap(),
27474 ));
27475 assert!(request_uses_unnegotiated_capability(&request, &[]));
27476
27477 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27478 .await
27479 .unwrap();
27480 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27481 panic!("host directory browse returned another response");
27482 };
27483 assert_eq!(
27484 listing
27485 .entries
27486 .iter()
27487 .map(|entry| entry.display_name.as_str())
27488 .collect::<Vec<_>>(),
27489 vec!["directory-a", "directory-b"],
27490 );
27491 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27492 assert!(!listing.incomplete);
27493 std::fs::remove_dir_all(root).unwrap();
27494 }
27495
27496 #[tokio::test]
27497 async fn observer_can_inspect_only_a_registered_workspace() {
27498 let root = temporary_workspace_root("observer");
27499 std::fs::create_dir_all(root.join("src")).unwrap();
27500 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27501 let workspace_id = WorkspaceId::new("primary").unwrap();
27502 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27503 let catalog = active_registry().unwrap();
27504 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27505 let shared = NodeShared::new(
27506 handle,
27507 "fixture-token".to_owned(),
27508 NodeId::new("node-1").unwrap(),
27509 vec![workspace],
27510 vec![agent("claude")],
27511 );
27512
27513 let response = process_request_inner(
27514 &shared,
27515 77,
27516 ClientRole::Observer,
27517 NodeRequest::InspectWorkspace {
27518 workspace_id: workspace_id.clone(),
27519 },
27520 )
27521 .await
27522 .unwrap();
27523 let NodeResponse::WorkspaceInspected { inspection } = response else {
27524 panic!("workspace inspection returned another response");
27525 };
27526 assert_eq!(inspection.workspace_id, workspace_id);
27527 assert!(inspection.entries.iter().any(|entry| {
27528 entry.relative_path.as_utf8() == Some("src/main.rs")
27529 }));
27530
27531 let unknown = process_request_inner(
27532 &shared,
27533 77,
27534 ClientRole::Observer,
27535 NodeRequest::InspectWorkspace {
27536 workspace_id: WorkspaceId::new("unknown").unwrap(),
27537 },
27538 )
27539 .await
27540 .unwrap_err();
27541 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27542 std::fs::remove_dir_all(&root).unwrap();
27543 }
27544
27545 #[test]
27546 fn context_pack_source_accepts_only_running_or_successful_exit() {
27547 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27548 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27549 exit_code: Some(0),
27550 }));
27551 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27552 exit_code: Some(1),
27553 }));
27554 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27555 exit_code: None,
27556 }));
27557 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27558 message: "provider failed".to_owned(),
27559 }));
27560 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27561 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27562 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27563 }
27564
27565 #[test]
27566 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27567 let git = git_snapshot_for_parser();
27568 let first = ContextPackRepositoryObservation {
27569 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27570 git: git.clone(),
27571 files: Vec::new(),
27572 };
27573 let second = ContextPackRepositoryObservation {
27574 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27575 git,
27576 files: Vec::new(),
27577 };
27578
27579 let error = stable_context_pack_repository(first, second).unwrap_err();
27580
27581 assert_eq!(
27582 error.code,
27583 NodeFailureCode::ContextPackMaterializationFailed,
27584 );
27585 }
27586
27587 #[test]
27588 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27589 let git = git_snapshot_for_parser();
27590 let first = ContextPackRepositoryObservation {
27591 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27592 git: git.clone(),
27593 files: vec![ContextPackRepositoryFileSource::utf8(
27594 "README.md",
27595 "first\n".to_owned(),
27596 6,
27597 )],
27598 };
27599 let second = ContextPackRepositoryObservation {
27600 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27601 git,
27602 files: vec![ContextPackRepositoryFileSource::utf8(
27603 "README.md",
27604 "other\n".to_owned(),
27605 6,
27606 )],
27607 };
27608
27609 let error = stable_context_pack_repository(first, second).unwrap_err();
27610
27611 assert_eq!(
27612 error.code,
27613 NodeFailureCode::ContextPackMaterializationFailed,
27614 );
27615 }
27616
27617 #[tokio::test]
27618 async fn native_session_catalog_zero_active_session_fixture() {
27619 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27620 let history_root = temporary_workspace_root("native-catalog-history");
27621 let outside_root = temporary_workspace_root("native-catalog-outside");
27622 std::fs::create_dir_all(&workspace_root).unwrap();
27623 std::fs::create_dir_all(&history_root).unwrap();
27624 std::fs::create_dir_all(&outside_root).unwrap();
27625 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27626 [
27627 serde_json::json!({
27628 "type": "user",
27629 "sessionId": session_id,
27630 "cwd": cwd.to_str().unwrap(),
27631 "message": { "content": "raw user text must not cross the catalog wire" }
27632 }),
27633 serde_json::json!({
27634 "type": "assistant",
27635 "sessionId": session_id,
27636 "cwd": cwd.to_str().unwrap(),
27637 "message": { "model": "claude-sonnet", "content": "raw answer" }
27638 }),
27639 serde_json::json!({
27640 "type": "ai-title",
27641 "sessionId": session_id,
27642 "aiTitle": title
27643 }),
27644 ]
27645 .into_iter()
27646 .map(|record| record.to_string())
27647 .collect::<Vec<_>>()
27648 .join("\n")
27649 };
27650 std::fs::write(
27651 history_root.join("stable-session.jsonl"),
27652 transcript("stable-session", &workspace_root, "Workspace review"),
27653 )
27654 .unwrap();
27655 std::fs::write(
27656 history_root.join("outside-session.jsonl"),
27657 transcript("outside-session", &outside_root, "Outside review"),
27658 )
27659 .unwrap();
27660 let history = NativeHistoryConfig::new(vec![
27661 NativeHistoryRoot::new(
27662 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27663 HistorySourceLayout::SingleNdjson,
27664 history_root.clone(),
27665 )
27666 .unwrap(),
27667 ])
27668 .unwrap();
27669 let catalog = active_registry().unwrap();
27670 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27671 assert!(handle.snapshot().sessions.is_empty());
27672 let workspace_id = WorkspaceId::new("primary").unwrap();
27673 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27674 let mut shared = NodeShared::new(
27675 handle,
27676 "fixture-token".to_owned(),
27677 NodeId::new("node-native-catalog").unwrap(),
27678 vec![workspace],
27679 vec![agent("claude")],
27680 );
27681 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27682 NativeSessionCatalogAuthority::new(history),
27683 )));
27684 let route = NativeSessionCatalogRoute::workspace(
27685 workspace_id.clone(),
27686 agent("claude"),
27687 );
27688 let request = NodeRequest::CatalogNativeSessions {
27689 route: route.clone(),
27690 limit: 2,
27691 };
27692 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27693 .await
27694 .unwrap_err();
27695 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27696 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27697 .await
27698 .unwrap();
27699 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27700 panic!("native catalog returned a different response");
27701 };
27702 let summary = summary.expect("node must emit native session catalog summary metadata");
27703 assert_eq!(entries.len(), 1);
27704 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27705 assert_eq!(entries[0].external_group, None);
27706 assert_eq!(entries[0].record_id, None);
27707 assert_eq!(entries[0].title, None);
27708 assert_eq!(entries[0].model, None);
27709 assert_eq!(entries[0].message_count, 0);
27710 let encoded_entries = serde_json::to_string(&entries).unwrap();
27711 assert!(!encoded_entries.contains("stable-session"));
27712 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27713 let page_request = NodeRequest::PageNativeSessions {
27714 route: route.clone(),
27715 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27716 catalog_revision: summary.catalog_revision,
27717 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27718 after_selection_id: None,
27719 limit: 2,
27720 };
27721 let observer = process_request_inner(
27722 &shared,
27723 7,
27724 ClientRole::Observer,
27725 page_request.clone(),
27726 )
27727 .await
27728 .unwrap_err();
27729 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27730 let page_response = process_request_inner(
27731 &shared,
27732 7,
27733 ClientRole::Operator,
27734 page_request.clone(),
27735 )
27736 .await
27737 .unwrap();
27738 assert!(matches!(
27739 page_response,
27740 NodeResponse::NativeSessionsPaged { page, .. }
27741 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27742 ));
27743 let stale = process_request_inner(
27744 &shared,
27745 7,
27746 ClientRole::Operator,
27747 NodeRequest::PageNativeSessions {
27748 route: route.clone(),
27749 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27750 catalog_revision: summary.catalog_revision.wrapping_add(1),
27751 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27752 after_selection_id: None,
27753 limit: 2,
27754 },
27755 )
27756 .await
27757 .unwrap_err();
27758 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27759 let selection = NativeSessionSelection {
27760 route: route.clone(),
27761 catalog_revision: summary.catalog_revision,
27762 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27763 selection_id: entries[0].selection_id.clone(),
27764 };
27765 let preview_response = process_request_inner(
27766 &shared,
27767 7,
27768 ClientRole::Operator,
27769 NodeRequest::PreviewNativeSession {
27770 selection: selection.clone(),
27771 message_limit: 1,
27772 },
27773 )
27774 .await
27775 .unwrap();
27776 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27777 panic!("native preview returned a different response");
27778 };
27779 assert_eq!(preview.messages.len(), 1);
27780 assert!(preview.message_count_exact);
27781 assert_eq!(preview.messages[0].text, "raw answer");
27782 assert!(preview.truncated);
27783
27784 let index_request = NodeRequest::IndexNativeSession {
27785 selection: selection.clone(),
27786 display_name: "Saved Claude".to_owned(),
27787 };
27788 let observer = process_request_inner(
27789 &shared,
27790 7,
27791 ClientRole::Observer,
27792 index_request.clone(),
27793 )
27794 .await
27795 .unwrap_err();
27796 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27797 let without_controller = process_request_inner(
27798 &shared,
27799 7,
27800 ClientRole::Operator,
27801 index_request.clone(),
27802 )
27803 .await
27804 .unwrap_err();
27805 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27806 let acquired = process_request_inner(
27807 &shared,
27808 7,
27809 ClientRole::Operator,
27810 NodeRequest::AcquireController { lease_ms: 5_000 },
27811 )
27812 .await
27813 .unwrap();
27814 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27815 let indexed = process_request_inner(
27816 &shared,
27817 7,
27818 ClientRole::Operator,
27819 index_request,
27820 )
27821 .await
27822 .unwrap();
27823 assert!(indexed.native_session_index_contract_is_valid());
27824 let NodeResponse::NativeSessionIndexed {
27825 selection: echoed_selection,
27826 record,
27827 } = indexed else {
27828 panic!("native session index returned a different response");
27829 };
27830 assert_eq!(echoed_selection, selection);
27831 let recataloged = process_request_inner(
27832 &shared,
27833 7,
27834 ClientRole::Operator,
27835 NodeRequest::CatalogNativeSessions {
27836 route,
27837 limit: 2,
27838 },
27839 )
27840 .await
27841 .unwrap();
27842 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27843 panic!("native catalog returned a different response after indexing");
27844 };
27845 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27846
27847 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27848 let external_catalog = process_request_inner(
27849 &shared,
27850 7,
27851 ClientRole::Operator,
27852 NodeRequest::CatalogNativeSessions {
27853 route: unregistered_route.clone(),
27854 limit: 2,
27855 },
27856 )
27857 .await
27858 .unwrap();
27859 let external_json = serde_json::to_string(&external_catalog).unwrap();
27860 assert!(!external_json.contains("outside-session"));
27861 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27862 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27863 assert!(!external_json.contains("\"session_id\""));
27864 assert!(!external_json.contains("\"cwd\""));
27865 assert!(!external_json.contains("\"path\""));
27866 let NodeResponse::NativeSessionsCataloged {
27867 route: echoed_route,
27868 entries: external_entries,
27869 summary: Some(external_summary),
27870 } = external_catalog
27871 else {
27872 panic!("unregistered native catalog returned a different response");
27873 };
27874 assert_eq!(echoed_route, unregistered_route);
27875 assert_eq!(external_entries.len(), 1);
27876 assert_eq!(external_entries[0].record_id, None);
27877 let external_group = external_entries[0]
27878 .external_group
27879 .as_ref()
27880 .expect("unregistered session must have an external group");
27881 assert_eq!(
27882 external_group.kind,
27883 gate4agent_types::NativeSessionExternalGroupKind::Project,
27884 );
27885 assert!(external_group.group_id.starts_with("external-"));
27886 assert_eq!(
27887 external_group.display_name,
27888 outside_root.file_name().unwrap().to_string_lossy(),
27889 );
27890 let external_selection = NativeSessionSelection {
27891 route: unregistered_route,
27892 catalog_revision: external_summary.catalog_revision,
27893 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27894 selection_id: external_entries[0].selection_id.clone(),
27895 };
27896 let external_preview = process_request_inner(
27897 &shared,
27898 7,
27899 ClientRole::Operator,
27900 NodeRequest::PreviewNativeSession {
27901 selection: external_selection.clone(),
27902 message_limit: 1,
27903 },
27904 )
27905 .await
27906 .unwrap();
27907 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27908 assert!(!external_preview_json.contains("outside-session"));
27909 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27910 assert!(!external_preview_json.contains("\"session_id\""));
27911 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27912 panic!("unregistered native preview returned a different response");
27913 };
27914 assert_eq!(preview.messages.len(), 1);
27915 let external_index = process_request_inner(
27916 &shared,
27917 7,
27918 ClientRole::Operator,
27919 NodeRequest::IndexNativeSession {
27920 selection: external_selection,
27921 display_name: "External Claude".to_owned(),
27922 },
27923 )
27924 .await
27925 .unwrap_err();
27926 assert_eq!(
27927 external_index.code,
27928 NodeFailureCode::WorkspaceRegistrationRequired,
27929 );
27930 let record_response = process_request_inner(
27931 &shared,
27932 7,
27933 ClientRole::Operator,
27934 NodeRequest::PreviewSessionRecord {
27935 record_id: record.record_id.clone(),
27936 message_limit: 2,
27937 },
27938 )
27939 .await
27940 .unwrap();
27941 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27942 panic!("record preview returned a different response");
27943 };
27944 assert_eq!(record_id, record.record_id);
27945 assert_eq!(preview.messages.len(), 2);
27946 let encoded = serde_json::to_string(&preview).unwrap();
27947 assert!(!encoded.contains("stable-session"));
27948 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27949 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27950 panic!("record preview history summary returned a different response");
27951 };
27952 let summaries = events
27953 .iter()
27954 .filter_map(|envelope| match &envelope.event {
27955 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27956 if record_id == &record.record_id =>
27957 {
27958 Some(*summary)
27959 }
27960 _ => None,
27961 })
27962 .collect::<Vec<_>>();
27963 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27964 let summary_json = serde_json::to_string(&summaries).unwrap();
27965 for private in [
27966 "stable-session",
27967 "raw user text must not cross the catalog wire",
27968 "raw answer",
27969 "Workspace review",
27970 "claude-sonnet",
27971 ] {
27972 assert!(!summary_json.contains(private), "{private}");
27973 }
27974 let identity = record.provider_session.clone().unwrap();
27975 assert_eq!(
27976 shared.revalidate_session_record_preview(&record, &identity),
27977 Ok(())
27978 );
27979 shared.remove_record_memory(&record.record_id);
27980 assert_eq!(
27981 shared
27982 .revalidate_session_record_preview(&record, &identity)
27983 .unwrap_err()
27984 .code,
27985 NodeFailureCode::UnknownSessionRecord
27986 );
27987 let released = process_request_inner(
27988 &shared,
27989 7,
27990 ClientRole::Operator,
27991 NodeRequest::ReleaseController,
27992 )
27993 .await
27994 .unwrap();
27995 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27996 assert!(shared.controller_state().is_none());
27997 std::fs::remove_dir_all(workspace_root).unwrap();
27998 std::fs::remove_dir_all(history_root).unwrap();
27999 std::fs::remove_dir_all(outside_root).unwrap();
28000 }
28001
28002 #[tokio::test]
28003 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28004 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28005 std::fs::create_dir_all(&history_root).unwrap();
28006 let history = NativeHistoryConfig::new(vec![
28007 NativeHistoryRoot::new(
28008 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28009 HistorySourceLayout::SingleNdjson,
28010 history_root.clone(),
28011 )
28012 .unwrap(),
28013 ])
28014 .unwrap();
28015 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28016 let held = catalog.lock().unwrap();
28017 let result = timeout(
28018 Duration::from_secs(1),
28019 run_native_session_catalog_operation(
28020 Arc::clone(&catalog),
28021 "native session catalog",
28022 |_| Ok::<(), std::convert::Infallible>(()),
28023 ),
28024 )
28025 .await
28026 .expect("concurrent native catalog request must not wait for the operation deadline")
28027 .unwrap_err();
28028 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28029 drop(held);
28030 std::fs::remove_dir_all(history_root).unwrap();
28031 }
28032
28033 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28034 let unique = std::time::SystemTime::now()
28035 .duration_since(std::time::UNIX_EPOCH)
28036 .unwrap()
28037 .as_nanos();
28038 std::env::temp_dir().join(format!(
28039 "gate4agent-node-{label}-{}-{unique}",
28040 std::process::id(),
28041 ))
28042 }
28043
28044 fn git_snapshot_for_parser() -> GitSnapshot {
28045 GitSnapshot {
28046 is_repository: true,
28047 branch: Some("main".to_owned()),
28048 status: Vec::new(),
28049 recent_commits: Vec::new(),
28050 worktrees: Vec::new(),
28051 managed_worktree: None,
28052 truncated: false,
28053 diagnostic: None,
28054 }
28055 }
28056
28057 #[tokio::test]
28058 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28059 let root = temporary_workspace_root("managed-git-scope");
28060 std::fs::create_dir_all(&root).unwrap();
28061 run_test_git(&root, &["init"]);
28062 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28063 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28064 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28065 run_test_git(&root, &["add", "seed.txt"]);
28066 run_test_git(&root, &["commit", "-m", "seed"]);
28067 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28068
28069 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28070 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28071 let catalog = active_registry().unwrap();
28072 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28073 let shared = NodeShared::new(
28074 handle,
28075 "fixture-token".to_owned(),
28076 NodeId::new("node-1").unwrap(),
28077 vec![workspace],
28078 vec![agent("claude")],
28079 );
28080 let mut lease = ManagedWorktreeLeaseRecord {
28081 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28082 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28083 workspace_id: workspace_id.clone(),
28084 profile_id: WorktreeProfileId::new("default").unwrap(),
28085 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28086 target_root: root.to_string_lossy().into_owned(),
28087 branch: "gate4agent/a".to_owned(),
28088 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28089 expected_head: None,
28090 retention: ManagedWorktreeRetention::Retain,
28091 state: ManagedWorktreeLeaseState::InUse,
28092 session_holders: vec![ManagedWorktreeSessionHolder {
28093 incarnation_id: shared.incarnation_id,
28094 instance_id: AgentInstanceId(7),
28095 generation: SessionGeneration(1),
28096 }],
28097 record_holders: Vec::new(),
28098 cleanup_failure: None,
28099 created_at_unix_ms: 1,
28100 updated_at_unix_ms: 1,
28101 };
28102 *shared
28103 .managed_worktrees
28104 .lock()
28105 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28106 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28107
28108 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28109 let scope = inspection.git.managed_worktree.unwrap();
28110 assert_eq!(scope.lease_id, lease.lease_id);
28111 assert_eq!(scope.branch, "gate4agent/a");
28112 assert_eq!(scope.active_session_count, 1);
28113
28114 lease.branch = "gate4agent/b".to_owned();
28115 *shared
28116 .managed_worktrees
28117 .lock()
28118 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28119 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28120 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28121 assert!(mismatch.git.managed_worktree.is_none());
28122 std::fs::remove_dir_all(root).unwrap();
28123 }
28124
28125 #[tokio::test]
28126 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28127 let root = temporary_workspace_root("workspace-file-write-controller");
28128 std::fs::create_dir_all(root.join("src")).unwrap();
28129 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28130 let workspace_id = WorkspaceId::new("primary").unwrap();
28131 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28132 let catalog = active_registry().unwrap();
28133 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28134 let shared = NodeShared::new(
28135 handle,
28136 "fixture-token".to_owned(),
28137 NodeId::new("node-1").unwrap(),
28138 vec![workspace],
28139 vec![agent("claude")],
28140 );
28141 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28142 let expected_revision = workspace_file_revision(b"old\n");
28143 let request = NodeRequest::WriteWorkspaceFile {
28144 workspace_id: workspace_id.clone(),
28145 path: path.clone(),
28146 expected_revision: expected_revision.clone(),
28147 text: "new\n".to_owned(),
28148 };
28149 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28150 .await
28151 .unwrap_err();
28152 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28153 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28154 .await
28155 .unwrap_err();
28156 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28157 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28158 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28159 let NodeResponse::WorkspaceFileWritten { file } = response else {
28160 panic!("workspace file write returned another response");
28161 };
28162 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28163 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28164 let stale = process_request_inner(
28165 &shared,
28166 77,
28167 ClientRole::Operator,
28168 NodeRequest::WriteWorkspaceFile {
28169 workspace_id,
28170 path,
28171 expected_revision,
28172 text: "lost update\n".to_owned(),
28173 },
28174 )
28175 .await
28176 .unwrap_err();
28177 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28178 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28179 std::fs::remove_dir_all(root).unwrap();
28180 }
28181
28182 #[tokio::test]
28183 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28184 let root = temporary_workspace_root("workspace-entry-create-controller");
28185 std::fs::create_dir_all(root.join("src")).unwrap();
28186 let workspace_id = WorkspaceId::new("primary").unwrap();
28187 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28188 let catalog = active_registry().unwrap();
28189 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28190 let shared = NodeShared::new(
28191 handle,
28192 "fixture-token".to_owned(),
28193 NodeId::new("node-1").unwrap(),
28194 vec![workspace],
28195 vec![agent("claude")],
28196 );
28197 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28198 let request = NodeRequest::CreateWorkspaceFile {
28199 workspace_id: workspace_id.clone(),
28200 path: path.clone(),
28201 };
28202 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28203 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28204 }));
28205 assert!(!request_uses_unnegotiated_capability(
28206 &request,
28207 &baseline_capabilities().unwrap(),
28208 ));
28209 assert!(request_uses_unnegotiated_capability(&request, &[]));
28210 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28211 .await
28212 .unwrap_err();
28213 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28214 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28215 .await
28216 .unwrap_err();
28217 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28218 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28219
28220 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28221 .await
28222 .unwrap();
28223 let NodeResponse::WorkspaceFileCreated { file } = response else {
28224 panic!("workspace file create returned another response");
28225 };
28226 assert_eq!(file.workspace_id, workspace_id);
28227 assert_eq!(file.path, path);
28228 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28229 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28230
28231 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28232 .await
28233 .unwrap_err();
28234 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28235
28236 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28237 let response = process_request_inner(
28238 &shared,
28239 77,
28240 ClientRole::Operator,
28241 NodeRequest::CreateWorkspaceDirectory {
28242 workspace_id: workspace_id.clone(),
28243 path: directory_path.clone(),
28244 },
28245 )
28246 .await
28247 .unwrap();
28248 assert_eq!(
28249 response,
28250 NodeResponse::WorkspaceDirectoryCreated {
28251 workspace_id: workspace_id.clone(),
28252 entry: WorkspaceEntry {
28253 relative_path: directory_path,
28254 kind: WorkspaceEntryKind::Directory,
28255 },
28256 },
28257 );
28258 assert!(root.join("src/nested").is_dir());
28259
28260 let missing_parent = process_request_inner(
28261 &shared,
28262 77,
28263 ClientRole::Operator,
28264 NodeRequest::CreateWorkspaceFile {
28265 workspace_id: workspace_id.clone(),
28266 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28267 },
28268 )
28269 .await
28270 .unwrap_err();
28271 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28272 let non_directory_parent = process_request_inner(
28273 &shared,
28274 77,
28275 ClientRole::Operator,
28276 NodeRequest::CreateWorkspaceFile {
28277 workspace_id,
28278 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28279 },
28280 )
28281 .await
28282 .unwrap_err();
28283 assert_eq!(
28284 non_directory_parent.code,
28285 NodeFailureCode::RepositoryParentNotDirectory,
28286 );
28287 std::fs::remove_dir_all(root).unwrap();
28288 }
28289
28290 #[tokio::test]
28291 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28292 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28293 std::fs::create_dir_all(root.join("src")).unwrap();
28294 #[cfg(windows)]
28295 let root = std::fs::canonicalize(root).unwrap();
28296 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28297 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28298 let barrier = Arc::new(std::sync::Barrier::new(2));
28299 let done = Arc::new(AtomicBool::new(false));
28300 let task_commit_state = Arc::clone(&commit_state);
28301 let task_barrier = Arc::clone(&barrier);
28302 let task_done = Arc::clone(&done);
28303 let task_root = root.clone();
28304 let task = tokio::task::spawn_blocking(move || {
28305 task_barrier.wait();
28306 let result = create_workspace_file_on_disk(
28307 &task_root,
28308 &path,
28309 &task_commit_state,
28310 );
28311 task_done.store(true, Ordering::Release);
28312 result
28313 });
28314
28315 let error = settle_workspace_entry_create(
28316 task,
28317 Arc::clone(&commit_state),
28318 Duration::from_millis(10),
28319 "fixture deadline",
28320 "fixture task failed",
28321 )
28322 .await
28323 .unwrap_err();
28324 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28325 assert_eq!(
28326 commit_state.load(Ordering::Acquire),
28327 WORKSPACE_ENTRY_CREATE_CANCELED,
28328 );
28329
28330 barrier.wait();
28331 timeout(Duration::from_secs(1), async {
28332 while !done.load(Ordering::Acquire) {
28333 tokio::task::yield_now().await;
28334 }
28335 })
28336 .await
28337 .expect("canceled blocking create did not settle");
28338 assert!(!root.join("src/late.rs").exists());
28339 std::fs::remove_dir_all(root).unwrap();
28340 }
28341}
28342
28343#[cfg(test)]
28344mod provisional_spawn_tests {
28345 use super::*;
28346 use std::sync::Arc;
28347
28348 fn shared() -> NodeShared {
28349 let catalog = active_registry().unwrap();
28350 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28351 let workspace = WorkspaceConfig::new(
28352 WorkspaceId::new("primary").unwrap(),
28353 std::env::current_dir().unwrap(),
28354 )
28355 .unwrap()
28356 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28357 NodeShared::new(
28358 handle,
28359 "fixture-token".to_owned(),
28360 NodeId::new("node-provisional-test").unwrap(),
28361 vec![workspace],
28362 vec![AgentId::new("claude").unwrap()],
28363 )
28364 }
28365
28366 #[test]
28369 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28370 let shared = shared();
28371 let ws = WorkspaceId::new("primary").unwrap();
28372 let mut ids = Vec::with_capacity(50);
28373 for i in 1u64..=50 {
28374 let id = AgentInstanceId(i);
28375 ids.push(id);
28376 let address = SessionAddress {
28377 workspace_id: ws.clone(),
28378 session: SessionKey {
28379 instance_id: id,
28380 generation: SessionGeneration(1),
28381 },
28382 };
28383 shared.mark_spawn_provisional(address);
28384 }
28385 assert_eq!(
28386 shared
28387 .provisional_spawns
28388 .lock()
28389 .unwrap_or_else(|p| p.into_inner())
28390 .len(),
28391 50
28392 );
28393 for id in ids.iter().step_by(2) {
28394 shared.sessionize_spawn(*id);
28395 }
28396 assert_eq!(
28397 shared
28398 .provisional_spawns
28399 .lock()
28400 .unwrap_or_else(|p| p.into_inner())
28401 .len(),
28402 25
28403 );
28404 assert!(shared.take_due_provisional_spawns().is_empty());
28405 {
28406 let mut map = shared
28407 .provisional_spawns
28408 .lock()
28409 .unwrap_or_else(|p| p.into_inner());
28410 let past = Instant::now() - Duration::from_secs(91);
28411 for (_, (_, at)) in map.iter_mut() {
28412 *at = past;
28413 }
28414 }
28415 let due = shared.take_due_provisional_spawns();
28416 assert_eq!(due.len(), 25);
28417 let due_ids: std::collections::BTreeSet<_> =
28418 due.iter().map(|a| a.session.instance_id.0).collect();
28419 let expected: std::collections::BTreeSet<_> =
28420 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28421 assert_eq!(due_ids, expected);
28422 assert!(shared
28423 .provisional_spawns
28424 .lock()
28425 .unwrap_or_else(|p| p.into_inner())
28426 .is_empty());
28427 }
28428
28429 #[test]
28432 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28433 let shared = Arc::new(shared());
28434 let ws = WorkspaceId::new("primary").unwrap();
28435 let mut handles = Vec::new();
28436 for i in 1u64..=20 {
28437 let shared = Arc::clone(&shared);
28438 let ws = ws.clone();
28439 handles.push(std::thread::spawn(move || {
28440 let id = AgentInstanceId(i);
28441 let address = SessionAddress {
28442 workspace_id: ws,
28443 session: SessionKey {
28444 instance_id: id,
28445 generation: SessionGeneration(1),
28446 },
28447 };
28448 shared.mark_spawn_provisional(address);
28449 id
28450 }));
28451 }
28452 let mut ids = Vec::new();
28453 for h in handles {
28454 ids.push(h.join().unwrap());
28455 }
28456 ids.sort_by_key(|id| id.0);
28457 assert_eq!(ids.len(), 20);
28458 ids.dedup();
28459 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28460 assert_eq!(
28461 shared
28462 .provisional_spawns
28463 .lock()
28464 .unwrap_or_else(|p| p.into_inner())
28465 .len(),
28466 20
28467 );
28468 shared.sessionize_spawn(AgentInstanceId(7));
28469 let left: Vec<_> = shared
28470 .provisional_spawns
28471 .lock()
28472 .unwrap_or_else(|p| p.into_inner())
28473 .keys()
28474 .map(|id| id.0)
28475 .collect();
28476 assert!(!left.contains(&7));
28477 assert_eq!(left.len(), 19);
28478 }
28479}
28480
28481
28482#[cfg(unix)]
28483extern "C" {
28484 #[link_name = "geteuid"]
28485 fn libc_geteuid() -> u32;
28486}
28487
28488#[cfg(test)]
28489mod call_home_link_tests {
28490 use super::*;
28491
28492 #[test]
28493 fn call_home_backoff_is_capped_and_monotonic() {
28494 let mut last = Duration::ZERO;
28495 for attempt in 1..20 {
28496 let next = call_home_backoff(attempt);
28497 assert!(next >= last);
28498 assert!(next <= Duration::from_secs(8));
28499 last = next;
28500 }
28501 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28502 }
28503
28504 #[tokio::test]
28505 async fn call_home_socket_gets_keepalive() {
28506 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28507 let addr = listener.local_addr().unwrap();
28508 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28509 let client = client.unwrap();
28510 enable_call_home_keepalive(&client).unwrap();
28511 let sock = socket2::SockRef::from(&client);
28512 assert!(sock.keepalive().unwrap());
28513 #[cfg(any(target_os = "linux", target_os = "macos"))]
28514 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28515 }
28516}
28517
28518fn provider_session_exists(provider: &str, id: &str) -> bool {
28520 if provider != "claude" { return false; }
28521 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28522 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28523 let name = format!("{id}.jsonl");
28524 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())))
28525}
28526
28527fn overlay_with_extra_args(
28529 provider: &AgentId,
28530 mode: SessionMode,
28531 overlay: Option<PreparedNativeLaunchOverlay>,
28532 args: Vec<OsString>,
28533) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28534 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28535 Ok(match (overlay, args) {
28536 (prepared, args) if args.is_empty() => prepared,
28537 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28538 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28539 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28540 provider.clone(),
28541 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28542 Vec::new(),
28543 args,
28544 ).map_err(|_| bad())?)),
28545 })
28546}