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 static SPAWN_ORIGIN: Option<String>;
223 static SPAWN_PARENT: Option<String>;
224}
225
226fn spawn_selection(
228 options: &gate4agent_node_protocol::SpawnSessionOptions,
229) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
230 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
231 match (&options.model, &options.effort) {
232 (None, None) => Ok(None),
233 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
234 (Some(model), effort) => {
235 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
236 if let Some(e) = effort {
237 sel = sel.with_value("effort", e.clone());
238 }
239 Ok(Some(sel))
240 }
241 }
242}
243
244mod browser_bridge;
245
246use gate4agent_node_core::drive_loop_profile;
247
248#[path = "bundle_delivery.rs"]
249mod bundle_delivery;
250
251#[path = "context_pack_store.rs"]
252mod context_pack_store;
253
254#[cfg(windows)]
255pub use crate::platform::DEFAULT_NODE_ENDPOINT;
256
257const NODE_EVENT_HISTORY_MAX: usize = 4_096;
258const NODE_BROADCAST_CAPACITY: usize = 1_024;
259const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
260const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
266const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
273const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
274const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
275const MAX_PREAUTH_CONNECTIONS: usize = 32;
276const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
277const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
278const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
279const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
280const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
281const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
287const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
295const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
303const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
310const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
315const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
316const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
317const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
318const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
319const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
320const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
321const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
322const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
323const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
324const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
325const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
326const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
327const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
330const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
337const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
340const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
341const GIT_STATUS_MAX_ENTRIES: usize = 128;
342const GIT_COMMIT_MAX_ENTRIES: usize = 12;
343const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
344const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
345const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
346const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
347const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
348const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
349const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
350const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
351const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
352const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
353 "provider-session-identity-allocation-failed";
354const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
355const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
356const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
357const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
358const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
359const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
360const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
361 "durable-state-path-semantics-unsupported";
362
363fn opaque_windows_path(value: String) -> OpaqueHostPath {
364 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
365}
366
367fn agent_progress_from_provider_snapshot(
368 address: SessionAddress,
369 provider: &ProviderSnapshot,
370) -> Option<SessionAgentProgress> {
371 let mut truncated = false;
372 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
373 let mut active_tool_labels = Vec::with_capacity(
374 provider
375 .active_tools
376 .len()
377 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
378 );
379 for tool in &provider.active_tools {
380 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
381 truncated = true;
382 break;
383 }
384 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
385 active_tool_labels.push(label);
386 }
387 }
388 let attention = provider
389 .interactions
390 .iter()
391 .find(|interaction| {
392 matches!(
393 interaction.status,
394 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
395 )
396 })
397 .map(|interaction| AgentProgressAttentionV1 {
398 kind: match interaction.interaction_kind {
399 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
400 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
401 },
402 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
403 });
404 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
405 let usage = (provider.completed_turns > 0
406 || provider.usage.input_tokens > 0
407 || provider.usage.output_tokens > 0
408 || provider.usage.cache_read_tokens > 0
409 || provider.usage.cache_write_tokens > 0
410 || provider.usage.reasoning_tokens > 0)
411 .then_some(AgentProgressUsageV1 {
412 input_tokens: provider.usage.input_tokens,
413 output_tokens: provider.usage.output_tokens,
414 cache_read_tokens: provider.usage.cache_read_tokens,
415 cache_write_tokens: provider.usage.cache_write_tokens,
416 reasoning_tokens: provider.usage.reasoning_tokens,
417 });
418 let progress = AgentProgressV1 {
419 provider_sequence: provider.sequence,
420 activity: provider.activity,
421 completed_turns: provider.completed_turns,
422 usage,
423 current: AgentProgressCurrentV1::from(provider.activity),
424 active_tool_labels,
425 active_tool_count,
426 attention,
427 subagent_count,
428 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
429 gap_count: provider.gap_count,
430 stale: provider.stale,
431 truncated,
432 };
433 let entry = SessionAgentProgress { address, progress };
434 serde_json::to_vec(&entry)
435 .ok()
436 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
437 .map(|_| entry)
438}
439
440fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
441 match u32::try_from(count) {
442 Ok(count) => count,
443 Err(_) => {
444 *truncated = true;
445 u32::MAX
446 }
447 }
448}
449
450fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
451 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
452 return None;
453 }
454 Some(match event {
455 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
456 ProviderEvent::SessionIdentityObserved { .. } => {
457 AgentProgressEventKindV1::SessionIdentityObserved
458 }
459 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
460 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
461 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
462 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
463 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
464 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
465 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
466 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
467 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
468 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
469 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
470 ProviderEvent::InteractionRequested { .. } => {
471 AgentProgressEventKindV1::InteractionRequested
472 }
473 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
474 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
475 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
476 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
477 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
478 ProviderEvent::UnrecognizedNotification { .. } => {
479 AgentProgressEventKindV1::UnrecognizedNotification
480 }
481 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
482 ProviderEvent::UserMessage { .. }
493 | ProviderEvent::Plan { .. }
494 | ProviderEvent::AvailableCommandsUpdated { .. }
495 | ProviderEvent::ModeChanged { .. }
496 | ProviderEvent::SessionInfoUpdated { .. }
497 | ProviderEvent::UsageUpdated { .. }
498 | ProviderEvent::ConfigOptionsUpdated { .. } => {
499 AgentProgressEventKindV1::UnrecognizedNotification
500 }
501 })
502}
503
504fn provider_family_is_live(family: AdapterFamily) -> bool {
517 match family {
518 AdapterFamily::PtySemantic
519 | AdapterFamily::Hook
520 | AdapterFamily::ManagedHook
521 | AdapterFamily::Pipe
522 | AdapterFamily::OneShot
523 | AdapterFamily::Acp => true,
524 AdapterFamily::History
525 | AdapterFamily::Resume
526 | AdapterFamily::SessionOptions
527 | AdapterFamily::CapabilityProbe => false,
528 }
529}
530
531fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
532 match by {
533 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
534 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
535 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
536 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
537 }
538}
539
540fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
552 match authority {
553 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
554 BlockAuthorityV1::HostPolicy => "denied by host policy",
555 BlockAuthorityV1::HostDeadline => {
556 "denied by host policy after the operator deadline expired"
557 }
558 BlockAuthorityV1::Operator => "declined by the operator",
559 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
560 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
561 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
562 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
563 BlockAuthorityV1::UserRejected => "rejected by the user",
564 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
565 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
566 }
567}
568
569fn host_request_denied_block(
572 method: &str,
573 by: ProviderHostDecisionAuthority,
574 reason: Option<&str>,
575) -> (String, BlockAuthorityV1, String) {
576 let authority = block_authority(by);
577 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
578 let (reason_text, _) =
582 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
583 (tool_class_label(method), authority, reason_text)
584}
585
586fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
605 match kind? {
606 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
607 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
608 _ => None,
609 }
610}
611
612fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
624 let trimmed = text.trim();
625 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
626 return (trimmed.to_owned(), None);
627 };
628 let tail = after_reason.trim_start();
629 let Some((reason, help)) = tail.split_once(". ") else {
630 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
634 return (reason.to_owned(), None);
635 };
636 let help = help.trim();
637 if help.is_empty() {
638 (reason.trim().to_owned(), None)
639 } else {
640 (reason.trim().to_owned(), Some(help.to_owned()))
641 }
642}
643
644fn tool_class_from_denial_help(help: Option<&str>) -> String {
654 help.and_then(|text| text.split("add a ").nth(1))
655 .and_then(|tail| tail.split(' ').next())
656 .filter(|word| !word.is_empty())
657 .map(str::to_owned)
658 .unwrap_or_else(|| "Tool".to_owned())
659}
660
661fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
669 fn names_quota(s: &str) -> bool {
670 let lower = s.to_ascii_lowercase();
671 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
672 }
673 vendor_code.is_some_and(names_quota) || names_quota(message)
674}
675
676fn provider_session_end_block(
692 stop_reason: &ProviderStopReason,
693) -> Option<(BlockAuthorityV1, String, String)> {
694 match stop_reason {
695 ProviderStopReason::Refusal => Some((
696 BlockAuthorityV1::ProviderRefusal,
697 "refusal".to_owned(),
698 "the provider ended the turn with stopReason: refusal".to_owned(),
699 )),
700 ProviderStopReason::ProviderError { message, vendor_code, .. }
701 if provider_error_is_quota(vendor_code.as_deref(), message) =>
702 {
703 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
704 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
705 }
706 _ => None,
707 }
708}
709
710fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
751 let ControlEventKind::ProviderEvent {
752 sequence: provider_sequence,
753 source,
754 source_sequence,
755 event: provider_event,
756 } = &event.event
757 else {
758 return None;
759 };
760 if !provider_family_is_live(source.family) {
761 return None;
762 }
763 let kind = match provider_event {
764 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
765 text: text.clone(),
766 is_delta: *is_delta,
767 },
768 ProviderEvent::Thinking { text } => {
769 AgentStreamChunkKindV1::Thinking { text: text.clone() }
770 }
771 ProviderEvent::InteractionRequested {
772 interaction_kind,
773 tool_name,
774 title,
775 prompt,
776 options,
777 ..
778 } => AgentStreamChunkKindV1::InteractionPrompt {
779 correlation_id: interaction_correlation(
780 event.instance_id,
781 event.generation,
782 *provider_sequence,
783 ),
784 interaction_kind: *interaction_kind,
785 tool_name: tool_name.clone(),
786 title: title.clone(),
795 prompt: prompt.clone(),
796 options: options
797 .iter()
798 .map(|option| AgentStreamInteractionOptionV1 {
799 option_id: option.option_id.clone(),
800 name: option.name.clone(),
801 kind: option.kind.clone(),
802 })
803 .collect(),
804 },
805 ProviderEvent::ConfigOptionsUpdated { options } => {
806 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
807 }
808 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
819 current: Some(mode_id.clone()),
820 available: available
821 .iter()
822 .map(|mode| AgentStreamNamedIdV1 {
823 id: mode.id.clone(),
824 name: mode.name.clone(),
825 description: mode.description.clone(),
826 })
827 .collect(),
828 },
829 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
833 let ProviderHostRequestDecision::Denied { by } = decision else {
834 return None;
835 };
836 let (tool_class, authority, reason_text) =
837 host_request_denied_block(method, *by, reason.as_deref());
838 AgentStreamChunkKindV1::Blocked {
839 correlation_id: None,
840 tool_class,
841 authority,
842 reason_kind: None,
843 reason: reason_text,
844 help: None,
845 }
846 }
847 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
851 let (authority, reason_kind) =
852 provider_tool_block_authority(non_execution_kind.as_deref())?;
853 let (reason, help) = split_provider_denial_text(output);
854 let (reason, _) =
855 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
856 let help = help.map(|help| {
857 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
858 });
859 AgentStreamChunkKindV1::Blocked {
860 correlation_id: Some(tool_correlation(event, source, id)),
861 tool_class: tool_class_from_denial_help(help.as_deref()),
862 authority,
863 reason_kind: Some(reason_kind.to_owned()),
864 reason,
865 help,
866 }
867 }
868 ProviderEvent::SessionEnded { stop_reason, .. } => {
872 let (authority, reason_kind, reason) =
873 stop_reason.as_ref().and_then(provider_session_end_block)?;
874 let (reason, _) =
875 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
876 AgentStreamChunkKindV1::Blocked {
877 correlation_id: None,
878 tool_class: "Session".to_owned(),
879 authority,
880 reason_kind: Some(reason_kind),
881 reason,
882 help: None,
883 }
884 }
885 ProviderEvent::SessionStarted { .. }
886 | ProviderEvent::SessionIdentityObserved { .. }
887 | ProviderEvent::TurnStarted { .. }
888 | ProviderEvent::WorkingObserved
889 | ProviderEvent::ToolStarted { .. }
890 | ProviderEvent::TurnCompleted { .. }
891 | ProviderEvent::ContextWindowUsage { .. }
892 | ProviderEvent::TurnInterrupted
893 | ProviderEvent::Error { .. }
894 | ProviderEvent::Ready
895 | ProviderEvent::InteractionResolved { .. }
896 | ProviderEvent::SubagentStarted { .. }
897 | ProviderEvent::SubagentStopped { .. }
898 | ProviderEvent::RateLimited { .. }
899 | ProviderEvent::UnrecognizedNotification { .. }
900 | ProviderEvent::UserMessage { .. }
901 | ProviderEvent::Plan { .. }
902 | ProviderEvent::AvailableCommandsUpdated { .. }
903 | ProviderEvent::SessionInfoUpdated { .. }
904 | ProviderEvent::UsageUpdated { .. } => return None,
905 };
906 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
907}
908
909async fn run_native_session_catalog_operation<T, E, F>(
910 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
911 operation: &'static str,
912 execute: F,
913) -> Result<T, NodeFailure>
914where
915 T: Send + 'static,
916 E: std::fmt::Display + Send + 'static,
917 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
918{
919 let task = tokio::task::spawn_blocking(move || {
920 let mut catalog = match catalog.try_lock() {
921 Ok(catalog) => catalog,
922 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
923 Err(std::sync::TryLockError::WouldBlock) => {
924 return Err(failure(
925 NodeFailureCode::BackendBusy,
926 "native session catalog operation is already running",
927 ));
928 }
929 };
930 execute(&mut catalog).map_err(|error| {
931 failure(
932 NodeFailureCode::BackendOperationFailed,
933 &format!("{operation} failed: {error}"),
934 )
935 })
936 });
937 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
938 .await
939 .map_err(|_| {
940 failure(
941 NodeFailureCode::BackendBusy,
942 &format!("{operation} exceeded its bounded deadline"),
943 )
944 })?
945 .map_err(|_| {
946 failure(
947 NodeFailureCode::BackendOperationFailed,
948 &format!("{operation} task failed"),
949 )
950 })?
951}
952
953fn windows_path_text(path: &OpaqueHostPath) -> &str {
954 path.as_utf8()
955 .expect("Windows node only creates UTF-8 opaque host paths")
956}
957
958fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
959 path.as_utf8()
960 .map(str::to_owned)
961 .ok_or_else(|| failure(
962 NodeFailureCode::InvalidRequest,
963 "this node requires UTF-8 host paths",
964 ))
965}
966
967fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
968 GitWorktreeSnapshot {
969 path: opaque_windows_path(worktree.path),
970 head: worktree.head,
971 branch: worktree.branch,
972 is_bare: worktree.is_bare,
973 is_main: worktree.is_main,
974 locked: worktree.locked,
975 lock_reason: worktree.lock_reason,
976 prunable: worktree.prunable,
977 prunable_reason: worktree.prunable_reason,
978 workspace_id: worktree.workspace_id.take(),
979 }
980}
981
982#[derive(Clone, Debug, Eq, PartialEq)]
983pub struct WorkspaceConfig {
984 workspace_id: WorkspaceId,
985 canonical_root: String,
986 worktree_service_mode: WorktreeServiceMode,
987 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
988}
989
990impl WorkspaceConfig {
991 pub fn new(
992 workspace_id: WorkspaceId,
993 root: impl AsRef<Path>,
994 ) -> Result<Self, NodeServerError> {
995 let root = root.as_ref();
996 if !root.is_absolute() {
997 return Err(NodeServerError::InvalidWorkspaceRoot {
998 workspace_id,
999 path: root.to_string_lossy().into_owned(),
1000 message: "path is not absolute".to_owned(),
1001 });
1002 }
1003 let canonical = std::fs::canonicalize(root).map_err(|error| {
1004 NodeServerError::InvalidWorkspaceRoot {
1005 workspace_id: workspace_id.clone(),
1006 path: root.to_string_lossy().into_owned(),
1007 message: error.to_string(),
1008 }
1009 })?;
1010 if !canonical.is_dir() {
1011 return Err(NodeServerError::InvalidWorkspaceRoot {
1012 workspace_id,
1013 path: root.to_string_lossy().into_owned(),
1014 message: "path is not a directory".to_owned(),
1015 });
1016 }
1017 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1018 NodeServerError::InvalidWorkspaceRoot {
1019 workspace_id: workspace_id.clone(),
1020 path: path.to_string_lossy().into_owned(),
1021 message: "canonical path is not valid Unicode".to_owned(),
1022 }
1023 })?;
1024 let canonical_root = platform::normalize_canonical_root(canonical_root);
1025 validate_workspace_root(&workspace_id, &canonical_root)?;
1026 Ok(Self {
1027 workspace_id,
1028 canonical_root,
1029 worktree_service_mode: WorktreeServiceMode::Manual,
1030 managed_worktree_profiles: BTreeMap::new(),
1031 })
1032 }
1033
1034 pub fn workspace_id(&self) -> &WorkspaceId {
1035 &self.workspace_id
1036 }
1037
1038 pub fn canonical_root(&self) -> &str {
1039 &self.canonical_root
1040 }
1041
1042 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1043 self.worktree_service_mode = mode;
1044 self
1045 }
1046
1047 pub fn with_managed_worktree_profile(
1048 mut self,
1049 profile: ManagedWorktreeProfile,
1050 ) -> Result<Self, NodeServerError> {
1051 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1052 NodeServerError::InvalidManagedWorktreeProfile {
1053 workspace_id: self.workspace_id.clone(),
1054 profile_id: profile.profile_id().clone(),
1055 message,
1056 }
1057 })?;
1058 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1059 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1060 workspace_id: self.workspace_id,
1061 profile_id: profile.profile_id().clone(),
1062 });
1063 }
1064 if self.managed_worktree_profiles.len()
1065 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1066 {
1067 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1068 workspace_id: self.workspace_id,
1069 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1070 });
1071 }
1072 self.managed_worktree_profiles
1073 .insert(profile.profile_id().clone(), profile);
1074 Ok(self)
1075 }
1076}
1077
1078#[derive(Clone)]
1079pub struct NodeServerConfig {
1080 pub endpoint: String,
1081 api_listen: Option<std::net::SocketAddr>,
1082 bridge_listen: Option<std::net::SocketAddr>,
1085 bridge_token: Option<String>,
1088 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1091 call_home: Option<std::net::SocketAddr>,
1094 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1098 pub node_id: NodeId,
1099 pub workspaces: Vec<WorkspaceConfig>,
1100 access_token: String,
1101 pub runtime: NativeRuntimeConfig,
1102 session_record_retention: SessionRecordRetentionConfig,
1103 state_path: Option<PathBuf>,
1104 spawn_profiles: SpawnProfileRegistry,
1105 session_environment: Option<NodeSessionEnvironmentConfig>,
1106 history: Option<NativeHistoryConfig>,
1107 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1108 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1112 #[cfg(feature = "fixture")]
1113 fixture_raw_pty_runtime: bool,
1114}
1115
1116#[derive(Clone)]
1117struct NodeSessionEnvironmentConfig {
1118 root: PathBuf,
1119 resolver: Arc<dyn NodeSecretResolver>,
1120}
1121
1122impl NodeServerConfig {
1123 pub fn new(
1124 endpoint: impl Into<String>,
1125 access_token: impl Into<String>,
1126 node_id: NodeId,
1127 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1128 ) -> Result<Self, NodeServerError> {
1129 let endpoint = endpoint.into();
1130 let access_token = access_token.into();
1131 if !platform::validate_endpoint(&endpoint) {
1132 return Err(NodeServerError::InvalidEndpoint);
1133 }
1134 if access_token.is_empty() || access_token.len() > 4_096 {
1135 return Err(NodeServerError::InvalidAccessToken);
1136 }
1137 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1138 if workspaces.is_empty() {
1139 return Err(NodeServerError::NoWorkspaces);
1140 }
1141 let mut workspace_ids = BTreeMap::new();
1142 let mut workspace_roots = BTreeMap::new();
1143 for workspace in &workspaces {
1144 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1145 && workspace.managed_worktree_profiles.is_empty()
1146 {
1147 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1148 workspace.workspace_id.clone(),
1149 ));
1150 }
1151 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1152 && !workspace.managed_worktree_profiles.is_empty()
1153 {
1154 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1155 workspace.workspace_id.clone(),
1156 ));
1157 }
1158 if workspace_ids
1159 .insert(workspace.workspace_id.clone(), ())
1160 .is_some()
1161 {
1162 return Err(NodeServerError::DuplicateWorkspaceId(
1163 workspace.workspace_id.clone(),
1164 ));
1165 }
1166 let root_key = platform::root_identity(&workspace.canonical_root);
1167 if let Some(existing) = workspace_roots.insert(
1168 root_key,
1169 workspace.workspace_id.clone(),
1170 ) {
1171 return Err(NodeServerError::DuplicateWorkspaceRoot {
1172 first: existing,
1173 second: workspace.workspace_id.clone(),
1174 root: workspace.canonical_root.clone(),
1175 });
1176 }
1177 }
1178 Ok(Self {
1179 endpoint,
1180 api_listen: None,
1181 bridge_listen: None,
1182 bridge_token: None,
1183 bridge_underlay: None,
1184 call_home: None,
1185 call_home_identity: None,
1186 node_id,
1187 workspaces,
1188 access_token,
1189 runtime: NativeRuntimeConfig {
1192 worker_poll_interval_ms: 4,
1193 ..NativeRuntimeConfig::default()
1194 },
1195 session_record_retention: SessionRecordRetentionConfig::default(),
1196 state_path: None,
1197 spawn_profiles: SpawnProfileRegistry::default(),
1198 session_environment: None,
1199 history: None,
1200 harness_mcp_helper: None,
1201 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1202 #[cfg(feature = "fixture")]
1203 fixture_raw_pty_runtime: false,
1204 })
1205 }
1206
1207 pub fn with_api_listen(
1208 mut self,
1209 api_listen: std::net::SocketAddr,
1210 ) -> Result<Self, NodeServerError> {
1211 if !api_listen.ip().is_loopback() {
1212 return Err(NodeServerError::InvalidApiListen(api_listen));
1213 }
1214 self.api_listen = Some(api_listen);
1215 Ok(self)
1216 }
1217
1218 pub fn with_bridge_listen(
1222 mut self,
1223 bridge_listen: std::net::SocketAddr,
1224 ) -> Result<Self, NodeServerError> {
1225 if !bridge_listen.ip().is_loopback() {
1226 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1227 }
1228 self.bridge_listen = Some(bridge_listen);
1229 Ok(self)
1230 }
1231
1232 pub fn with_bridge_token(
1235 mut self,
1236 bridge_token: impl Into<String>,
1237 ) -> Result<Self, NodeServerError> {
1238 let bridge_token = bridge_token.into();
1239 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1240 return Err(NodeServerError::InvalidBridgeToken);
1241 }
1242 self.bridge_token = Some(bridge_token);
1243 Ok(self)
1244 }
1245
1246 pub fn with_bridge_underlay(
1251 mut self,
1252 bind: std::net::SocketAddr,
1253 transport_key: [u8; 32],
1254 underlay_token: impl Into<String>,
1255 ) -> Result<Self, NodeServerError> {
1256 if self.bridge_listen.is_none() {
1257 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1258 }
1259 if self.bridge_token.is_none() {
1260 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1261 }
1262 let underlay_token = underlay_token.into();
1263 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1264 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1265 }
1266 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1268 bind,
1269 transport_key,
1270 underlay_token,
1271 });
1272 Ok(self)
1273 }
1274
1275 pub fn with_call_home(
1296 mut self,
1297 relay: std::net::SocketAddr,
1298 ) -> Result<Self, NodeServerError> {
1299 if relay.port() == 0 || relay.ip().is_unspecified() {
1302 return Err(NodeServerError::InvalidCallHome(relay));
1303 }
1304 self.call_home = Some(relay);
1305 Ok(self)
1306 }
1307
1308 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1310 self.call_home_identity = Some(identity);
1311 self
1312 }
1313
1314 pub fn with_state_path(
1315 mut self,
1316 state_path: impl Into<PathBuf>,
1317 ) -> Result<Self, NodeServerError> {
1318 let state_path = state_path.into();
1319 if !state_path.is_absolute() || state_path.file_name().is_none() {
1320 return Err(NodeServerError::InvalidStatePath(
1321 state_path.to_string_lossy().into_owned(),
1322 ));
1323 }
1324 self.state_path = Some(state_path);
1325 Ok(self)
1326 }
1327
1328 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1329 self.spawn_profiles = spawn_profiles;
1330 self
1331 }
1332
1333 pub fn with_session_record_retention(
1339 mut self,
1340 retention: SessionRecordRetentionConfig,
1341 ) -> Self {
1342 self.session_record_retention = retention;
1343 self
1344 }
1345
1346 pub fn with_session_environment_materialization(
1347 mut self,
1348 root: impl Into<PathBuf>,
1349 resolver: Arc<dyn NodeSecretResolver>,
1350 ) -> Result<Self, NodeServerError> {
1351 let root = root.into();
1352 if !root.is_absolute() {
1353 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1354 }
1355 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1356 Ok(self)
1357 }
1358
1359 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1362 self.history = Some(history);
1363 self
1364 }
1365
1366 pub fn with_harness_mcp_helper(
1372 mut self,
1373 helper_program: impl Into<PathBuf>,
1374 ) -> Result<Self, NodeServerError> {
1375 self.harness_mcp_helper = Some(
1376 ReviewedHarnessMcpProgram::review(helper_program.into())
1377 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1378 );
1379 Ok(self)
1380 }
1381
1382 pub fn with_network_allowlist_catalog(
1387 mut self,
1388 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1389 ) -> Result<Self, NodeServerError> {
1390 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1391 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1392 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1393 });
1394 }
1395 self.network_allowlist_catalog = catalog;
1396 Ok(self)
1397 }
1398
1399 pub fn with_network_allowlist_catalog_file(
1403 self,
1404 path: impl AsRef<std::path::Path>,
1405 ) -> Result<Self, NodeServerError> {
1406 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1407 .map_err(NodeServerError::from)?;
1408 self.with_network_allowlist_catalog(catalog)
1409 }
1410}
1411
1412pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1413 platform::default_state_path(node_id.as_str())
1414 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1415}
1416
1417pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1418 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1419}
1420
1421fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1422 let parent = state_path.parent()?;
1423 let mut name = state_path.file_name()?.to_os_string();
1424 name.push(".delivery-store");
1425 Some(parent.join(name))
1426}
1427
1428fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1429 let parent = state_path.parent()?;
1430 let mut name = state_path.file_name()?.to_os_string();
1431 name.push(".context-pack-store");
1432 Some(parent.join(name))
1433}
1434
1435pub struct NodeServer {
1436 config: NodeServerConfig,
1437 runtime: NativeRuntime,
1438 shared: Arc<NodeShared>,
1439 events: EventSubscription,
1440 state_path_lock: Option<session_registry::StatePathLock>,
1441}
1442
1443#[cfg(feature = "fixture")]
1444#[derive(Clone, Default)]
1445pub struct SpawnManagedWorktreeV2FailureProbe {
1446 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1447}
1448
1449#[cfg(feature = "fixture")]
1450impl SpawnManagedWorktreeV2FailureProbe {
1451 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1452 *self
1453 .latest_code
1454 .lock()
1455 .unwrap_or_else(|poisoned| poisoned.into_inner())
1456 }
1457
1458 fn record(&self, code: Option<NodeFailureCode>) {
1459 *self
1460 .latest_code
1461 .lock()
1462 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1463 }
1464}
1465
1466#[cfg(all(feature = "fixture", windows))]
1467struct IsolatedCodexHomeFixtureEnvironment;
1468
1469#[cfg(all(feature = "fixture", windows))]
1470impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1471 for IsolatedCodexHomeFixtureEnvironment
1472{
1473 fn resolve_child_environment(
1474 &self,
1475 ) -> Result<
1476 Vec<gate4agent_catalog::EnvMutation>,
1477 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1478 > {
1479 Ok(vec![gate4agent_catalog::EnvMutation {
1480 key: OsString::from("USERPROFILE"),
1481 value: None,
1482 }])
1483 }
1484}
1485
1486impl NodeServer {
1487 #[cfg(unix)]
1491 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1492 self.shared.signer_proxy.set(proxy).is_ok()
1493 }
1494
1495 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1496 self.shared.m4a_accounts.as_ref()?;
1497 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1498 }
1499
1500 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1501 let catalog = active_registry()?;
1502 Self::new_with_registry(config, catalog)
1503 }
1504
1505 #[cfg(feature = "fixture")]
1506 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1507 let mut spec = gate4agent_testkit::interactive_agent_spec();
1508 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1509 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1510 Self::new_fixture_with_spec(config, spec)
1511 }
1512
1513 #[cfg(feature = "fixture")]
1514 pub fn new_clean_exit_fixture(
1515 config: NodeServerConfig,
1516 fixture_root: PathBuf,
1517 started_marker: PathBuf,
1518 release_signal: PathBuf,
1519 ) -> Result<Self, NodeServerError> {
1520 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1521 &fixture_root,
1522 &started_marker,
1523 &release_signal,
1524 )
1525 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1526 spec.id = AgentId::new("claude")
1527 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1528 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1529 Self::new_fixture_with_spec(config, spec)
1530 }
1531
1532 #[cfg(all(feature = "fixture", windows))]
1542 pub fn new_durable_context_pack_clean_exit_fixture(
1543 config: NodeServerConfig,
1544 fixture_root: PathBuf,
1545 started_marker: PathBuf,
1546 release_signal: PathBuf,
1547 session_id: &str,
1548 ) -> Result<Self, NodeServerError> {
1549 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1550 &fixture_root,
1551 &started_marker,
1552 &release_signal,
1553 session_id,
1554 )
1555 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1556 let source_id = AgentId::new("claude")
1557 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1558 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1559 NodeServerError::Registry(
1560 "durable context pack clean-exit fixture source adapter is unavailable"
1561 .to_owned(),
1562 )
1563 })?;
1564 source_spec.capabilities.adapters.history =
1565 source_provider.capabilities.adapters.history.clone();
1566 source_spec.id = source_id;
1567 source_spec.display_name =
1568 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1569 source_spec.detection.command =
1575 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1576 source_spec.detection.aliases.clear();
1577
1578 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1579 target_spec.id = AgentId::new("codex")
1580 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1581 target_spec.display_name =
1582 "Controlled Codex durable context-pack target fixture".to_owned();
1583 target_spec.detection.command =
1584 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1585 target_spec.detection.aliases.clear();
1586
1587 let catalog = AgentRegistry::new([source_spec, target_spec])
1588 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1589 let mut server = Self::new_with_registry(config, catalog)?;
1590 Arc::get_mut(&mut server.shared)
1591 .expect("fixture Node shared state must remain uniquely owned before run")
1592 .fixture_semantic_hook_policy = true;
1593 Ok(server)
1594 }
1595
1596 #[cfg(feature = "fixture")]
1597 pub fn new_monitoring_hook_fixture(
1598 config: NodeServerConfig,
1599 ) -> Result<Self, NodeServerError> {
1600 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1601 spec.id = AgentId::new("claude")
1602 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1603 let mut server = Self::new_fixture_with_spec(config, spec)?;
1604 Arc::get_mut(&mut server.shared)
1605 .expect("fixture Node shared state must remain uniquely owned before run")
1606 .fixture_semantic_hook_policy = true;
1607 Ok(server)
1608 }
1609
1610 #[cfg(all(feature = "fixture", windows))]
1611 pub fn new_provider_bundle_argv_fixture(
1612 config: NodeServerConfig,
1613 agent_id: AgentId,
1614 proof_path: PathBuf,
1615 ) -> Result<Self, NodeServerError> {
1616 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1617 Self::new_fixture_with_spec(config, spec)
1618 }
1619
1620 #[cfg(all(feature = "fixture", windows))]
1621 pub fn new_provider_bundle_argv_hold_fixture(
1622 config: NodeServerConfig,
1623 agent_id: AgentId,
1624 proof_path: PathBuf,
1625 release_signal: PathBuf,
1626 ) -> Result<Self, NodeServerError> {
1627 let spec = Self::provider_bundle_argv_fixture_spec(
1628 agent_id,
1629 proof_path,
1630 Some(release_signal),
1631 )?;
1632 Self::new_fixture_with_spec(config, spec)
1633 }
1634
1635 #[cfg(all(feature = "fixture", windows))]
1636 fn provider_bundle_argv_fixture_spec(
1637 agent_id: AgentId,
1638 proof_path: PathBuf,
1639 release_signal: Option<PathBuf>,
1640 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1641 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1642 return Err(NodeServerError::Registry(
1643 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1644 ));
1645 }
1646 let mut spec = gate4agent_testkit::interactive_agent_spec();
1647 spec.id = agent_id.clone();
1648 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1649 let provider = builtin_registry()
1650 .get(&agent_id)
1651 .ok_or_else(|| {
1652 NodeServerError::Registry(
1653 "provider bundle argv fixture adapter is unavailable".to_owned(),
1654 )
1655 })?;
1656 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1657 if !proof_path.is_absolute()
1658 || !proof_path
1659 .parent()
1660 .is_some_and(|parent| parent.is_dir())
1661 {
1662 return Err(NodeServerError::Registry(
1663 "provider bundle argv proof path requires an existing absolute parent"
1664 .to_owned(),
1665 ));
1666 }
1667 if let Some(release_signal) = release_signal.as_ref() {
1668 if !release_signal.is_absolute()
1669 || !release_signal
1670 .parent()
1671 .is_some_and(|parent| parent.is_dir())
1672 {
1673 return Err(NodeServerError::Registry(
1674 "provider bundle argv release path requires an existing absolute parent"
1675 .to_owned(),
1676 ));
1677 }
1678 if release_signal.exists() {
1679 return Err(NodeServerError::Registry(
1680 "provider bundle argv release path must not exist".to_owned(),
1681 ));
1682 }
1683 let proof_parent = std::fs::canonicalize(
1684 proof_path
1685 .parent()
1686 .expect("validated provider bundle proof parent"),
1687 )
1688 .map_err(|_| {
1689 NodeServerError::Registry(
1690 "provider bundle argv proof parent is unavailable".to_owned(),
1691 )
1692 })?;
1693 let release_parent = std::fs::canonicalize(
1694 release_signal
1695 .parent()
1696 .expect("validated provider bundle release parent"),
1697 )
1698 .map_err(|_| {
1699 NodeServerError::Registry(
1700 "provider bundle argv release parent is unavailable".to_owned(),
1701 )
1702 })?;
1703 if proof_parent != release_parent {
1704 return Err(NodeServerError::Registry(
1705 "provider bundle argv release path must share the proof parent".to_owned(),
1706 ));
1707 }
1708 }
1709 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1710 NodeServerError::Registry(
1711 "provider bundle argv proof path is not valid Unicode".to_owned(),
1712 )
1713 })?;
1714 let release_signal = release_signal.map(|release_signal| {
1715 release_signal.into_os_string().into_string().map_err(|_| {
1716 NodeServerError::Registry(
1717 "provider bundle argv release path is not valid Unicode".to_owned(),
1718 )
1719 })
1720 }).transpose()?;
1721 let script = spec
1722 .launch
1723 .fixed_args
1724 .pop()
1725 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1726 let bundle_validation = if agent_id.as_str() == "codex" {
1727 "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') }"
1728 } else if agent_id.as_str() == "claude" {
1729 "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 }"
1730 } else {
1731 "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 }"
1732 };
1733 if let Some(release_signal) = release_signal {
1734 spec.launch.fixed_args.push(format!(
1735 "& {{ 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} }}",
1736 ));
1737 spec.launch.fixed_args.push(proof_path);
1738 spec.launch.fixed_args.push(release_signal);
1739 } else {
1740 spec.launch.fixed_args.push(format!(
1741 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1742 ));
1743 spec.launch.fixed_args.push(proof_path);
1744 }
1745 Ok(spec)
1746 }
1747
1748 #[cfg(all(feature = "fixture", windows))]
1749 pub fn new_context_pack_fixture(
1750 config: NodeServerConfig,
1751 proof_path: PathBuf,
1752 ) -> Result<Self, NodeServerError> {
1753 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1754 Self::new_with_registry(config, catalog)
1755 }
1756
1757 #[cfg(all(feature = "fixture", windows))]
1758 pub fn new_monitoring_context_pack_fixture(
1759 config: NodeServerConfig,
1760 proof_path: PathBuf,
1761 ) -> Result<Self, NodeServerError> {
1762 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1763 let mut server = Self::new_with_registry(config, catalog)?;
1764 Arc::get_mut(&mut server.shared)
1765 .expect("fixture Node shared state must remain uniquely owned before run")
1766 .fixture_semantic_hook_policy = true;
1767 Ok(server)
1768 }
1769
1770 #[cfg(all(feature = "fixture", windows))]
1771 pub fn install_isolated_codex_home_environment_fixture(
1772 &mut self,
1773 profile_id: SpawnEnvironmentProfileId,
1774 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1775 ) -> Result<(), NodeServerError> {
1776 let provider = AgentId::new("codex")
1777 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1778 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1779 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1780 "{}-pty",
1781 profile_id.as_str(),
1782 ))
1783 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1784 provider.clone(),
1785 TransportKind::Pty,
1786 vec![OsString::from("USERPROFILE")],
1787 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1788 )
1789 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1790 let materialization = NodeSessionMaterializationProfile::new(
1791 Vec::new(),
1792 vec![crate::session_environment::NodeSessionPathBinding::new(
1793 "CODEX_HOME",
1794 crate::session_environment::NodeSessionPathClass::ProviderHome,
1795 )
1796 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1797 Vec::new(),
1798 )
1799 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1800 self.install_environment_profile(
1801 NodeEnvironmentProfile::new_with_materialization(
1802 profile_id,
1803 profile_revision,
1804 provider,
1805 [native_profile],
1806 Some(materialization),
1807 )
1808 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1809 )
1810 }
1811
1812 #[cfg(all(feature = "fixture", windows))]
1813 pub fn new_context_only_proof_fixture(
1814 config: NodeServerConfig,
1815 target_provider: AgentId,
1816 proof_path: PathBuf,
1817 ) -> Result<Self, NodeServerError> {
1818 if target_provider.as_str() != "kimi" {
1819 return Err(NodeServerError::Registry(
1820 "context-only proof fixture requires Kimi".to_owned(),
1821 ));
1822 }
1823 let catalog = Self::context_pack_fixture_catalog(
1824 proof_path,
1825 Some(&target_provider),
1826 false,
1827 )?;
1828 Self::new_with_registry(config, catalog)
1829 }
1830
1831 #[cfg(all(feature = "fixture", windows))]
1832 fn context_pack_fixture_catalog(
1833 proof_path: PathBuf,
1834 context_only_target: Option<&AgentId>,
1835 monitoring_claude_source: bool,
1836 ) -> Result<AgentRegistry, NodeServerError> {
1837 if !proof_path.is_absolute()
1838 || !proof_path
1839 .parent()
1840 .is_some_and(|parent| parent.is_dir())
1841 {
1842 return Err(NodeServerError::Registry(
1843 "context pack proof path requires an existing absolute parent".to_owned(),
1844 ));
1845 }
1846 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1847 NodeServerError::Registry(
1848 "context pack proof path is not valid Unicode".to_owned(),
1849 )
1850 })?;
1851 let providers = ["claude", "codex", "grok", "kimi"];
1852 let builtins = builtin_registry();
1853 let mut specs = Vec::with_capacity(providers.len());
1854 for provider_id in providers {
1855 let agent_id = AgentId::new(provider_id)
1856 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1857 let provider = builtins.get(&agent_id).ok_or_else(|| {
1858 NodeServerError::Registry(
1859 "context pack fixture provider adapter is unavailable".to_owned(),
1860 )
1861 })?;
1862 let monitored_provider =
1863 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1864 let mut spec = if monitored_provider {
1865 gate4agent_testkit::monitoring_hook_agent_spec()
1866 } else {
1867 gate4agent_testkit::interactive_agent_spec()
1868 };
1869 spec.id = agent_id.clone();
1870 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1871 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1872 spec.detection.aliases.clear();
1873 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1874 if provider_id == "codex" {
1875 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1876 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1877 })?;
1878 let validation = r#"
1879$bundleArgs = @($bundleArgs)
1880if ($bundleArgs.Count -ne 0) { exit 91 }
1881$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1882$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1883$cwd = (Get-Location).ProviderPath
1884if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1885$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1886if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1887$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1888if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1889$sha = [Security.Cryptography.SHA256]::Create()
1890try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1891if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1892if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1893$contextPath = Join-Path $contextRoot 'context-pack.json'
1894if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1895$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1896if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1897$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1898$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1899$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1900$separator = [IO.Path]::DirectorySeparatorChar
1901if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1902if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1903$rawContext = [IO.File]::ReadAllText($contextPath)
1904if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1905try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1906$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1907$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1908foreach ($property in @($document.PSObject.Properties.Name)) {
1909 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1910}
1911foreach ($property in $requiredTopLevel) {
1912 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1913}
1914if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1915if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1916$messages = @($document.retained_messages)
1917if ($messages.Count -eq 0) { exit 107 }
1918foreach ($message in $messages) {
1919 $messageProperties = @($message.PSObject.Properties.Name)
1920 foreach ($property in $messageProperties) {
1921 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1922 }
1923 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1924}
1925$sha = [Security.Cryptography.SHA256]::Create()
1926try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1927[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))
1928$rawProof = [IO.File]::ReadAllText($proofPath)
1929if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1930[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1931"#;
1932 spec.launch.fixed_args.push(format!(
1933 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1934 ));
1935 spec.launch.fixed_args.push(proof_path.clone());
1936 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1937 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1938 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1939 })?;
1940 let validation = r#"
1941$bundleArgs = @($bundleArgs)
1942if ($bundleArgs.Count -ne 0) { exit 91 }
1943$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1944$cwd = (Get-Location).ProviderPath
1945if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1946$contextPath = Join-Path $contextRoot 'context-pack.json'
1947if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1948$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1949if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1950$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1951$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1952$separator = [IO.Path]::DirectorySeparatorChar
1953if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1954try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1955if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1956if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1957$messages = @($document.retained_messages)
1958if ($messages.Count -eq 0) { exit 99 }
1959$hasUser = $false
1960$hasAssistant = $false
1961foreach ($message in $messages) {
1962 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1963 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1964 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1965 else { exit 101 }
1966}
1967if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1968$sha = [Security.Cryptography.SHA256]::Create()
1969try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1970[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1971[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1972"#;
1973 spec.launch.fixed_args.push(format!(
1974 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1975 ));
1976 spec.launch.fixed_args.push(proof_path.clone());
1977 }
1978 specs.push(spec);
1979 }
1980 let catalog = AgentRegistry::new(specs)
1981 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1982 Ok(catalog)
1983 }
1984
1985 #[cfg(feature = "fixture")]
1986 pub fn new_exact_launcher_fixture(
1987 config: NodeServerConfig,
1988 agent_id: AgentId,
1989 launcher: String,
1990 ) -> Result<Self, NodeServerError> {
1991 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1992 return Err(NodeServerError::Registry(
1993 "exact launcher fixture requires a supported provider".to_owned(),
1994 ));
1995 }
1996 if launcher.contains('\0') {
1997 return Err(NodeServerError::Registry(
1998 "exact launcher fixture path contains NUL".to_owned(),
1999 ));
2000 }
2001 let launcher_path = Path::new(&launcher);
2002 if !launcher_path.is_absolute() {
2003 return Err(NodeServerError::Registry(
2004 "exact launcher fixture path must be absolute".to_owned(),
2005 ));
2006 }
2007 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2008 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2009 })?;
2010 if !metadata.is_file() {
2011 return Err(NodeServerError::Registry(
2012 "exact launcher fixture path must be a regular file".to_owned(),
2013 ));
2014 }
2015 let mut spec = builtin_registry()
2016 .get(&agent_id)
2017 .ok_or_else(|| {
2018 NodeServerError::Registry(
2019 "exact launcher fixture provider is unavailable".to_owned(),
2020 )
2021 })?
2022 .clone();
2023 spec.launch.program = launcher;
2024 Self::new_fixture_with_spec(config, spec)
2025 }
2026
2027 #[cfg(feature = "fixture")]
2036 fn h3b_provider_spec_with_program_and_args(
2037 agent_id: AgentId,
2038 provider_program: PathBuf,
2039 fixed_args: Vec<OsString>,
2040 ) -> Result<AgentSpec, NodeServerError> {
2041 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2042 return Err(NodeServerError::Registry(
2043 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2044 ));
2045 }
2046 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2047 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2048 })?;
2049 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2050 return Err(NodeServerError::Registry(
2051 "H3B fixture provider must be an exact regular file".to_owned(),
2052 ));
2053 }
2054 let program = provider_program.into_os_string().into_string().map_err(|_| {
2055 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2056 })?;
2057 let fixed_args = fixed_args.into_iter().map(|argument| {
2058 argument.into_string().map_err(|_| NodeServerError::Registry(
2059 "H3B fixture argument is not Unicode".to_owned(),
2060 ))
2061 }).collect::<Result<Vec<_>, _>>()?;
2062 let argument_bytes = fixed_args.iter()
2063 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2064 if program.contains('\0')
2065 || fixed_args.iter().any(|argument| argument.contains('\0'))
2066 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2067 {
2068 return Err(NodeServerError::Registry(
2069 "H3B fixture provider or arguments are invalid".to_owned(),
2070 ));
2071 }
2072 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2073 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2074 })?.clone();
2075 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2076 .ok_or_else(|| NodeServerError::Registry(
2077 "H3B fixture provider basename is unavailable".to_owned(),
2078 ))?.to_owned();
2079 spec.detection.command = process_name.clone();
2080 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2081 name: process_name,
2082 }];
2083 spec.launch.program = program;
2084 spec.launch.fixed_args = fixed_args;
2085 Ok(spec)
2086 }
2087
2088 #[cfg(feature = "fixture")]
2089 pub fn new_harness_mcp_proxy_fixture(
2090 config: NodeServerConfig,
2091 provider_program: PathBuf,
2092 fixed_args: Vec<OsString>,
2093 harness_mcp_program: PathBuf,
2094 ) -> Result<Self, NodeServerError> {
2095 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2096 let agent_id = AgentId::new("codex")
2097 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2098 let mut spec =
2099 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2100 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2101 config.fixture_raw_pty_runtime = true;
2102 Self::new_fixture_with_spec(config, spec)
2103 }
2104
2105 #[cfg(feature = "fixture")]
2129 pub fn new_harness_mcp_acp_launcher_fixture(
2130 config: NodeServerConfig,
2131 helper_program: PathBuf,
2132 provider: AgentId,
2133 program: PathBuf,
2134 args: Vec<OsString>,
2135 ) -> Result<Self, NodeServerError> {
2136 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2137 return Err(NodeServerError::Registry(
2138 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2139 ));
2140 }
2141 let config = config.with_harness_mcp_helper(helper_program)?;
2142 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2143 let launch = spec.launch.clone();
2144 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2145 NodeServerError::Registry(
2146 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2147 .to_owned(),
2148 )
2149 })?;
2150 acp.launch_override = Some(launch);
2158 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2159 Self::new_fixture_with_spec(config, spec)
2160 }
2161
2162 #[cfg(feature = "fixture")]
2163 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2164 let mut spec = gate4agent_testkit::interactive_agent_spec();
2165 let claude_id = AgentId::new("claude")
2166 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2167 let claude = builtin_registry()
2168 .get(&claude_id)
2169 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2170 spec.id = claude_id;
2171 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2172 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2173 name: "claude".to_owned(),
2174 }];
2175 spec.capabilities.transports.pty_adapter = claude
2176 .capabilities
2177 .transports
2178 .pty_adapter
2179 .clone();
2180 spec.capabilities.adapters.resume = claude
2181 .capabilities
2182 .adapters
2183 .resume
2184 .clone();
2185 let script = spec
2186 .launch
2187 .fixed_args
2188 .pop()
2189 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2190 spec.launch.fixed_args.push(format!(
2191 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2192 ));
2193 Self::new_fixture_with_spec(config, spec)
2194 }
2195
2196 #[cfg(feature = "fixture")]
2197 pub fn new_cmd_cwd_fixture(
2198 config: NodeServerConfig,
2199 command: String,
2200 ) -> Result<Self, NodeServerError> {
2201 let mut spec = gate4agent_testkit::interactive_agent_spec();
2202 spec.id = AgentId::new("claude")
2203 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2204 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2205 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2206 spec.launch.program = command;
2207 spec.launch.fixed_args.clear();
2208 Self::new_fixture_with_spec(config, spec)
2209 }
2210
2211 #[cfg(feature = "fixture")]
2212 fn new_fixture_with_spec(
2213 config: NodeServerConfig,
2214 spec: gate4agent_types::AgentSpec,
2215 ) -> Result<Self, NodeServerError> {
2216 let catalog = AgentRegistry::new([spec])
2217 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2218 Self::new_with_registry(config, catalog)
2219 }
2220
2221 fn new_with_registry(
2222 config: NodeServerConfig,
2223 catalog: AgentRegistry,
2224 ) -> Result<Self, NodeServerError> {
2225 for profile in config.spawn_profiles.iter() {
2226 if catalog.get(&profile.provider).is_none() {
2227 return Err(NodeServerError::Registry(
2228 "spawn profile references an unavailable provider".to_owned(),
2229 ));
2230 }
2231 }
2232 let input_settle_timeout_ms = catalog
2233 .iter()
2234 .map(|spec| spec.readiness.timeout_ms)
2235 .max()
2236 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2237 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2238 .max(MUTATION_SETTLE_TIMEOUT_MS);
2239 let (provider_contracts, provider_adapter_contracts) =
2240 provider_contract_manifest(&catalog)?;
2241 let compatibility_support = node_compatibility_support_for_manifest(
2242 &provider_contracts,
2243 &provider_adapter_contracts,
2244 )?;
2245 validate_node_negotiated_handshake_capacity(&compatibility_support)
2246 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2247 let enabled_providers = provider_contracts
2248 .iter()
2249 .map(|contract| contract.provider.clone())
2250 .collect();
2251 #[cfg(feature = "fixture")]
2252 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2253 #[cfg(not(feature = "fixture"))]
2254 let fixture_raw_pty_runtime = false;
2255 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2256 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2257 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2258 })).expect("fixture provider catalog remains bounded");
2259 (None, statuses)
2260 } else {
2261 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2262 let statuses = monitor.collect();
2263 (Some(monitor), statuses)
2264 };
2265 let state_path_lock =
2266 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2267 .map_err(|error| {
2268 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2269 })?;
2270 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2271 Some(state_path) => {
2272 let delivery_root = delivery_store_root_for_state_path(state_path)
2273 .ok_or_else(|| {
2274 NodeServerError::DeliveryStore(
2275 "delivery store root could not be derived from state path".to_owned(),
2276 )
2277 })?;
2278 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2279 let detail = match &error {
2280 DeliveryStoreError::Storage(io_error) => {
2281 format!("{error}: {io_error}")
2282 }
2283 _ => error.to_string(),
2284 };
2285 NodeServerError::DeliveryStore(detail)
2286 })?;
2287 (Some(store), bundles)
2288 }
2289 None => (None, Vec::new()),
2290 };
2291 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2292 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2293 })?;
2294 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2295 Some(state_path) => {
2296 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2297 .ok_or(NodeServerError::ContextPackStore)?;
2298 let (store, packs) = ContextPackStore::open(context_pack_root)
2299 .map_err(|_| NodeServerError::ContextPackStore)?;
2300 (Some(store), packs)
2301 }
2302 None => (None, Vec::new()),
2303 };
2304 let mut durable_context_catalog = ContextPackCatalog::default();
2305 for pack in durable_context_packs {
2306 durable_context_catalog
2307 .insert(pack)
2308 .map_err(|_| NodeServerError::ContextPackStore)?;
2309 }
2310 let session_environment_materializer = config
2311 .session_environment
2312 .as_ref()
2313 .map(|environment| {
2314 SessionEnvironmentMaterializer::new(
2315 environment.root.clone(),
2316 Arc::clone(&environment.resolver),
2317 )
2318 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2319 })
2320 .transpose()?;
2321 #[cfg(unix)]
2324 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2325 let native_session_catalog = config.history.clone().map(|history| {
2326 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2327 });
2328 let (handle, runtime) = match config.history.clone() {
2329 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2330 None => NativeRuntime::new(catalog, config.runtime),
2331 };
2332 let native_launch_profile_control = runtime.native_launch_profile_control();
2333 let harness_mcp_registry = config
2334 .harness_mcp_helper
2335 .clone()
2336 .map(HarnessMcpProxyRegistry::new);
2337 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2338 let incarnation_id = random_incarnation_id()
2339 .map_err(NodeServerError::IncarnationIdentity)?;
2340 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2341 .map_err(durable_state_load_error)?;
2342 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2343 let managed_worktree_tombstones =
2344 std::mem::take(&mut loaded.managed_worktree_tombstones);
2345 let materializations = std::mem::take(&mut loaded.materializations);
2346 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2347 let (workspaces, records, persistence_warning) =
2348 merge_durable_state(&config.workspaces, loaded)?;
2349 let mut shared = NodeShared::new_with_incarnation(
2350 handle,
2351 config.access_token.clone(),
2352 config.node_id.clone(),
2353 incarnation_id,
2354 workspaces,
2355 enabled_providers,
2356 provider_runtime_statuses,
2357 provider_runtime_monitor,
2358 provider_contracts,
2359 provider_adapter_contracts,
2360 config.spawn_profiles.clone(),
2361 Some(native_launch_profile_control),
2362 native_session_catalog,
2363 session_environment_materializer,
2364 config.state_path.clone(),
2365 records,
2366 managed_worktrees,
2367 managed_worktree_tombstones,
2368 materializations,
2369 persistence_warning,
2370 input_settle_timeout_ms,
2371 );
2372 shared.harness_mcp_registry = harness_mcp_registry.clone();
2373 shared.session_record_retention = config.session_record_retention;
2374 shared.network_allowlist_catalog =
2375 RwLock::new(config.network_allowlist_catalog.clone());
2376 shared.bundle_catalog = RwLock::new(delivered_catalog);
2377 shared.delivery_store = Mutex::new(delivery_store);
2378 shared.context_catalog = RwLock::new(durable_context_catalog);
2379 shared.context_pack_store = Mutex::new(context_pack_store);
2380 shared.managed_spawn_replays = Mutex::new(
2381 managed_spawn_replays
2382 .into_iter()
2383 .map(|record| (record.idempotency_key.clone(), record))
2384 .collect(),
2385 );
2386 let shared = Arc::new(shared);
2387 if let Some(registry) = harness_mcp_registry {
2388 let weak = Arc::downgrade(&shared);
2389 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2390 Some(shared) => shared.publish_transient(event),
2391 None => false,
2392 }));
2393 }
2394 if shared.persistence_error().is_none() {
2395 shared.persist_state().map_err(|error| {
2396 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2397 })?;
2398 }
2399 Ok(Self {
2400 config,
2401 runtime,
2402 shared,
2403 events,
2404 state_path_lock,
2405 })
2406 }
2407
2408 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2409 NodeShutdownHandle {
2410 shared: Arc::clone(&self.shared),
2411 }
2412 }
2413
2414 #[cfg(feature = "fixture")]
2415 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2416 &self,
2417 ) -> SpawnManagedWorktreeV2FailureProbe {
2418 self.shared
2419 .fixture_spawn_managed_worktree_v2_failure_probe
2420 .clone()
2421 }
2422
2423 pub fn install_environment_profile(
2428 &mut self,
2429 profile: NodeEnvironmentProfile,
2430 ) -> Result<(), NodeServerError> {
2431 if !self.shared.enabled_providers.contains(profile.provider()) {
2432 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2433 profile.provider().clone(),
2434 ));
2435 }
2436 let (binding, native_profiles, materialization) = profile.into_parts();
2437 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2438 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2439 }
2440 let mut profiles = self
2441 .shared
2442 .environment_profiles
2443 .write()
2444 .unwrap_or_else(|poisoned| poisoned.into_inner());
2445 if profiles.contains_key(&binding.id) {
2446 return Err(NodeServerError::DuplicateEnvironmentProfile(
2447 binding.id.clone(),
2448 ));
2449 }
2450 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2451 return Err(NodeServerError::EnvironmentProfileCapacity {
2452 max: MAX_NODE_ENVIRONMENT_PROFILES,
2453 });
2454 }
2455 for native_id in binding.native_profile_ids() {
2456 if profiles.values().any(|current| {
2457 current.native_profile_ids().any(|current_id| current_id == native_id)
2458 }) {
2459 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2460 native_id.to_string(),
2461 ));
2462 }
2463 }
2464
2465 let mut installed = Vec::with_capacity(native_profiles.len());
2466 for native_profile in native_profiles {
2467 let native_id = native_profile.id().clone();
2468 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2469 for installed_id in installed.into_iter().rev() {
2470 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2471 }
2472 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2473 }
2474 installed.push(native_id);
2475 }
2476 if let Some(materialization) = materialization {
2477 self.shared
2478 .environment_materialization_profiles
2479 .write()
2480 .unwrap_or_else(|poisoned| poisoned.into_inner())
2481 .insert(binding.id.clone(), materialization);
2482 }
2483 profiles.insert(binding.id.clone(), binding);
2484 Ok(())
2485 }
2486
2487 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2489 if self.shared.session_environment_materializer.is_none() {
2490 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2491 }
2492 let mut catalog = self
2493 .shared
2494 .bundle_catalog
2495 .write()
2496 .unwrap_or_else(|poisoned| poisoned.into_inner());
2497 catalog
2498 .insert_idempotent(bundle)
2499 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2500 Ok(())
2501 }
2502
2503 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2504 let shutdown = self.shutdown_handle();
2505 let run = self.run();
2506 tokio::pin!(run);
2507 tokio::select! {
2508 result = &mut run => result,
2509 signal = wait_for_ctrl_signal() => {
2510 signal?;
2511 let shutdown_result = shutdown.request_shutdown().await;
2512 let run_result = run.await;
2513 shutdown_result.and(run_result)
2514 }
2515 }
2516 }
2517
2518 pub async fn run(self) -> Result<(), NodeServerError> {
2519 let NodeServer {
2520 config,
2521 mut runtime,
2522 shared,
2523 events,
2524 state_path_lock: _state_path_lock,
2525 } = self;
2526 shared.reconcile_materializations();
2527 shared.reconcile_managed_worktrees().await;
2528 shared.retire_unavailable_session_records().await;
2529 shared.reconcile_context_pack_exports().await;
2530 {
2531 let restore = Arc::clone(&shared);
2532 tokio::spawn(async move {
2533 sleep(Duration::from_secs(3)).await;
2534 restore.restore_journalled_sessions().await;
2535 loop {
2536 restore.refresh_session_journal();
2537 if restore.shutdown.load(Ordering::Acquire) { break; }
2538 sleep(Duration::from_secs(10)).await;
2539 }
2540 });
2541 }
2542 let endpoint = config.endpoint.clone();
2546 let call_home = config.call_home;
2547 let call_home_identity = config.call_home_identity.clone();
2548 let api_listen = config.api_listen;
2549 let bridge_listen = config.bridge_listen;
2550 let bridge_underlay = config.bridge_underlay;
2551 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2552 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2553 let accept_shared = Arc::clone(&shared);
2554 let api_shared = Arc::clone(&shared);
2555 let bridge_shared = Arc::clone(&shared);
2556 let shutdown_shared = Arc::clone(&shared);
2557 let shutdown_timeout = Duration::from_millis(
2558 config.runtime.provider_shutdown_timeout_ms.max(1),
2559 );
2560 let result = {
2561 let runtime_loop = drive_runtime_until_shutdown(
2562 &mut runtime,
2563 events,
2564 Arc::clone(&shared),
2565 shutdown_timeout,
2566 );
2567 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2568 let api_loop = http_api::run(api_listen, api_shared);
2569 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2570 tokio::pin!(runtime_loop);
2571 tokio::pin!(accept_loop);
2572 tokio::pin!(api_loop);
2573 tokio::pin!(bridge_loop);
2574 tokio::select! {
2575 runtime_result = &mut runtime_loop => {
2576 let shutdown_result = shutdown_shared
2577 .begin_shutdown()
2578 .await
2579 .map_err(NodeServerError::ShutdownDispatch);
2580 let (accept_result, api_result, bridge_result) =
2581 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2582 match runtime_result {
2583 Err(error) => Err(error),
2584 Ok(()) => shutdown_result
2585 .and(accept_result)
2586 .and(api_result.map_err(NodeServerError::HttpApi))
2587 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2588 }
2589 }
2590 accept_result = &mut accept_loop => {
2591 let shutdown_result = shutdown_shared
2592 .begin_shutdown()
2593 .await
2594 .map_err(NodeServerError::ShutdownDispatch);
2595 let (runtime_result, api_result, bridge_result) =
2596 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2597 match accept_result {
2598 Err(error) => Err(error),
2599 Ok(()) => shutdown_result
2600 .and(runtime_result)
2601 .and(api_result.map_err(NodeServerError::HttpApi))
2602 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2603 }
2604 }
2605 api_result = &mut api_loop => {
2606 let shutdown_result = shutdown_shared
2607 .begin_shutdown()
2608 .await
2609 .map_err(NodeServerError::ShutdownDispatch);
2610 let (runtime_result, accept_result, bridge_result) =
2611 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2612 match api_result {
2613 Err(error) => Err(NodeServerError::HttpApi(error)),
2614 Ok(()) => shutdown_result
2615 .and(runtime_result)
2616 .and(accept_result)
2617 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2618 }
2619 }
2620 bridge_result = &mut bridge_loop => {
2621 let shutdown_result = shutdown_shared
2622 .begin_shutdown()
2623 .await
2624 .map_err(NodeServerError::ShutdownDispatch);
2625 let (runtime_result, accept_result, api_result) =
2626 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2627 match bridge_result {
2628 Err(error) => Err(NodeServerError::BridgeApi(error)),
2629 Ok(()) => shutdown_result
2630 .and(runtime_result)
2631 .and(accept_result)
2632 .and(api_result.map_err(NodeServerError::HttpApi)),
2633 }
2634 }
2635 }
2636 };
2637 if let Ok(mut guard) = shared.delivery_store.lock() {
2644 *guard = None;
2645 }
2646 if let Ok(mut guard) = shared.context_pack_store.lock() {
2647 *guard = None;
2648 }
2649 result
2650 }
2651}
2652
2653async fn drive_runtime_until_shutdown(
2654 runtime: &mut NativeRuntime,
2655 events: EventSubscription,
2656 shared: Arc<NodeShared>,
2657 shutdown_timeout: Duration,
2658) -> Result<(), NodeServerError> {
2659 let _ = shared
2663 .shell_efficiency_profile
2664 .set(runtime.shell_efficiency_profile());
2665 let mut shutdown_started = None;
2666 let mut last_retention_sweep = Instant::now();
2667 let mut last_provisional_reap = Instant::now();
2668 loop {
2669 let runtime_tick_start = Instant::now();
2676 let tick_result = runtime.tick().await;
2677 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2678
2679 let event_drain_start = Instant::now();
2680 for outcome in &tick_result.command_outcomes {
2688 if let Err(error) = &outcome.result {
2689 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2690 tracing::warn!(
2691 command_id = outcome.command_id.0,
2692 cause = %error,
2693 "control command rejected"
2694 );
2695 shared.record_rejected_command(
2696 outcome.command_id,
2697 error.to_string(),
2698 error.is_unsupported_transport(),
2699 );
2700 }
2701 }
2702 let mut events_drained: u64 = 0;
2703 while let Ok(event) = events.try_recv() {
2704 events_drained += 1;
2705 let clean_exit = matches!(
2706 event.event,
2707 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2708 );
2709 let instance_id = event.instance_id;
2710 let generation = event.generation;
2711 if clean_exit {
2723 if let Some((record_id, session)) =
2724 shared.managed_record_export_target_for_instance(instance_id, generation)
2725 {
2726 let export_shared = Arc::clone(&shared);
2727 tokio::spawn(async move {
2728 export_shared
2729 .reconcile_context_pack_export_for_record(&record_id, session)
2730 .await;
2731 });
2732 }
2733 }
2734 shared.publish_control(event);
2735 }
2736 let event_drain_elapsed = event_drain_start.elapsed();
2737
2738 let publish_terminal_frames_start = Instant::now();
2739 shared.publish_terminal_frames();
2740 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2741
2742 shared
2748 .native_session_gauge
2749 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2750 *shared
2751 .runtime_tick_profile
2752 .lock()
2753 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2754
2755 let remainder_start = Instant::now();
2756 if shared.shutdown.load(Ordering::Acquire) {
2757 let started = *shutdown_started.get_or_insert_with(Instant::now);
2758 let snapshot = shared.handle.snapshot();
2759 let sessions_settled = snapshot.sessions.iter().all(|session| {
2760 !matches!(
2761 session.status,
2762 gate4agent_types::SessionStatus::Starting
2763 | gate4agent_types::SessionStatus::Running
2764 | gate4agent_types::SessionStatus::Stopping
2765 )
2766 });
2767 if runtime.active_native_sessions() == 0 && sessions_settled {
2768 return Ok(());
2769 }
2770 if started.elapsed() >= shutdown_timeout {
2771 return Err(NodeServerError::ShutdownTimedOut {
2772 active_native_sessions: runtime.active_native_sessions(),
2773 });
2774 }
2775 }
2776 let remainder_elapsed = remainder_start.elapsed();
2777
2778 {
2779 let mut profile = shared
2780 .drive_loop_profile
2781 .lock()
2782 .unwrap_or_else(|poisoned| poisoned.into_inner());
2783 profile.record_runtime_tick(runtime_tick_elapsed);
2784 profile.record_event_drain(event_drain_elapsed);
2785 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2786 profile.record_remainder(remainder_elapsed);
2787 profile.note_iteration(Instant::now());
2788 }
2789
2790 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2802 if tick_result.observations_applied == 0 && events_drained == 0 {
2803 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2804 }
2805
2806 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2811 shared.retire_unavailable_session_records().await;
2812 last_retention_sweep = Instant::now();
2813 }
2814 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2816 for address in shared.take_due_provisional_spawns() {
2817 shared.reap_provisional_spawn(address).await;
2818 }
2819 last_provisional_reap = Instant::now();
2820 }
2821
2822 let hot = shared
2825 .pty_input_marks
2826 .lock()
2827 .unwrap_or_else(|poisoned| poisoned.into_inner())
2828 .values()
2829 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2830 if hot {
2831 sleep(Duration::from_millis(1)).await;
2832 } else {
2833 tokio::select! {
2834 _ = sleep(Duration::from_millis(10)) => {}
2835 _ = shared.pty_input_wake.notified() => {}
2836 }
2837 }
2838 }
2839}
2840
2841#[derive(Clone)]
2842pub struct NodeShutdownHandle {
2843 shared: Arc<NodeShared>,
2844}
2845
2846impl NodeShutdownHandle {
2847 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2848 self.shared
2849 .begin_shutdown()
2850 .await
2851 .map_err(NodeServerError::ShutdownDispatch)
2852 }
2853}
2854
2855async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2856 #[cfg(windows)]
2857 {
2858 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2859 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2860 return tokio::select! {
2861 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2862 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2863 };
2864 }
2865 #[cfg(unix)]
2866 {
2867 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2868 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2869 tokio::select! {
2870 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2871 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2872 }
2873 }
2874}
2875
2876fn shell_host_spec() -> AgentSpec {
2890 #[cfg(windows)]
2894 let (detect_command, program, fixed_args, process_name) = {
2895 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2896 r"C:\Windows\System32\cmd.exe".to_owned()
2897 });
2898 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2899 };
2900 #[cfg(not(windows))]
2901 let (detect_command, program, fixed_args, process_name) = (
2902 "bash".to_owned(),
2903 "/bin/bash".to_owned(),
2904 vec!["-i".to_owned()],
2905 "bash".to_owned(),
2906 );
2907 AgentSpec {
2908 id: AgentId::new("shell").expect("shell agent id"),
2909 revision: "hatchery:shell-pty-1".to_owned(),
2910 display_name: "Host shell".to_owned(),
2911 detection: DetectionSpec {
2912 command: detect_command,
2913 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2914 required_commands: Vec::new(),
2915 unsupported_platforms: Vec::new(),
2916 },
2917 launch: LaunchSpec {
2918 program,
2919 fixed_args,
2920 },
2921 expected_processes: vec![ProcessMatcher::Exact {
2922 name: process_name,
2923 }],
2924 prompt: PromptSpec {
2925 initial: InitialPromptMode::None,
2926 native_draft: None,
2927 },
2928 readiness: AgentReadinessSpec::default(),
2929 capabilities: AgentCapabilities {
2930 agent_commands: None,
2931 transports: AgentTransportCapabilities {
2932 pty: true,
2933 pty_adapter: None,
2934 pipe: None,
2935 acp: None,
2936 },
2937 adapters: AgentAdapterCapabilities::default(),
2938 },
2939 verification: SpecVerification::Gate4AgentVerified,
2940 }
2941}
2942
2943fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2944 let mut specs = vec![shell_host_spec()];
2945 specs.extend(
2946 builtin_registry()
2947 .iter()
2948 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2949 .cloned(),
2950 );
2951 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2952}
2953
2954fn provider_contract_manifest(
2955 catalog: &AgentRegistry,
2956) -> Result<
2957 (
2958 Vec<ProviderContractSupport>,
2959 Vec<ProviderAdapterContractSupport>,
2960 ),
2961 NodeServerError,
2962> {
2963 let mut provider_contracts = Vec::new();
2964 let mut provider_adapter_contracts = Vec::new();
2965 for spec in catalog.iter() {
2966 let provider = spec.id.clone();
2967 let revision = ProviderContractRevision::new(spec.revision.clone())
2968 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2969 provider_contracts.push(ProviderContractSupport {
2970 provider: provider.clone(),
2971 revision,
2972 });
2973 for (family, binding) in declared_adapter_bindings(spec)? {
2974 let revision = AdapterContractRevision::new(binding.revision.clone())
2975 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2976 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2977 provider: provider.clone(),
2978 family,
2979 adapter_id: binding.id.clone(),
2980 revision,
2981 });
2982 }
2983 }
2984 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2985 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2986 Ok((provider_contracts, provider_adapter_contracts))
2987}
2988
2989fn declared_adapter_bindings(
2990 spec: &AgentSpec,
2991) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2992 let mut bindings = Vec::new();
2993 push_declared_binding(
2994 &mut bindings,
2995 AdapterFamily::PtySemantic,
2996 spec.capabilities.transports.pty_adapter.as_ref(),
2997 )?;
2998 push_declared_binding(
2999 &mut bindings,
3000 AdapterFamily::Pipe,
3001 spec.capabilities.adapters.pty_sidecar.as_ref(),
3002 )?;
3003 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3004 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3005 gate4agent_types::PipeProtocol::SemanticNdjson
3006 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3007 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3008 });
3009 push_declared_binding(
3010 &mut bindings,
3011 AdapterFamily::Pipe,
3012 pipe_transport
3013 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3014 .map(|transport| &transport.adapter),
3015 )?;
3016 push_declared_binding(
3017 &mut bindings,
3018 AdapterFamily::Acp,
3019 spec.capabilities
3020 .transports
3021 .acp
3022 .as_ref()
3023 .map(|transport| &transport.adapter),
3024 )?;
3025 push_declared_binding(
3026 &mut bindings,
3027 AdapterFamily::Hook,
3028 spec.capabilities.adapters.hook.as_ref(),
3029 )?;
3030 push_declared_binding(
3031 &mut bindings,
3032 AdapterFamily::ManagedHook,
3033 spec.capabilities.adapters.managed_hook.as_ref(),
3034 )?;
3035 push_declared_binding(
3036 &mut bindings,
3037 AdapterFamily::OneShot,
3038 pipe_transport
3039 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3040 .map(|transport| &transport.adapter),
3041 )?;
3042 push_declared_binding(
3043 &mut bindings,
3044 AdapterFamily::OneShot,
3045 spec.capabilities.adapters.one_shot.as_ref(),
3046 )?;
3047 push_declared_binding(
3048 &mut bindings,
3049 AdapterFamily::History,
3050 spec.capabilities.adapters.history.as_ref(),
3051 )?;
3052 push_declared_binding(
3053 &mut bindings,
3054 AdapterFamily::Resume,
3055 spec.capabilities.adapters.resume.as_ref(),
3056 )?;
3057 push_declared_binding(
3058 &mut bindings,
3059 AdapterFamily::SessionOptions,
3060 spec.capabilities.adapters.session_options.as_ref(),
3061 )?;
3062 push_declared_binding(
3063 &mut bindings,
3064 AdapterFamily::CapabilityProbe,
3065 spec.capabilities.adapters.capability_probe.as_ref(),
3066 )?;
3067 Ok(bindings)
3068}
3069
3070fn push_declared_binding<'a>(
3071 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3072 family: AdapterFamily,
3073 binding: Option<&'a AdapterBinding>,
3074) -> Result<(), NodeServerError> {
3075 let Some(binding) = binding else {
3076 return Ok(());
3077 };
3078 if let Some((_, existing)) = bindings
3079 .iter()
3080 .find(|(existing_family, _)| *existing_family == family)
3081 {
3082 if *existing == binding {
3083 return Ok(());
3084 }
3085 return Err(NodeServerError::ProviderContractManifest(format!(
3086 "provider declares conflicting {family:?} adapter bindings"
3087 )));
3088 }
3089 bindings.push((family, binding));
3090 Ok(())
3091}
3092
3093fn merge_durable_state(
3094 configured: &[WorkspaceConfig],
3095 mut loaded: LoadedNodeState,
3096) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3097 let mut workspaces = configured
3098 .iter()
3099 .cloned()
3100 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3101 .collect::<BTreeMap<_, _>>();
3102 let mut roots = workspaces
3103 .values()
3104 .map(|workspace| {
3105 (
3106 platform::root_identity(&workspace.canonical_root),
3107 workspace.workspace_id.clone(),
3108 )
3109 })
3110 .collect::<BTreeMap<_, _>>();
3111 let mut warning = loaded
3112 .warning
3113 .take()
3114 .map(|message| session_registry::sanitized_persistence_summary(&message));
3115
3116 for (workspace_id, canonical_root) in loaded.workspaces {
3117 if let Some(existing) = workspaces.get(&workspace_id) {
3118 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3119 return Err(NodeServerError::DurableState(io::Error::new(
3120 io::ErrorKind::InvalidData,
3121 DURABLE_STATE_CONFLICT_ERROR,
3122 )));
3123 }
3124 continue;
3125 }
3126 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3127 return Err(NodeServerError::DurableState(io::Error::new(
3128 io::ErrorKind::InvalidData,
3129 DURABLE_STATE_CONFLICT_ERROR,
3130 )));
3131 }
3132 if !Path::new(&canonical_root).is_dir() {
3133 warning.get_or_insert_with(|| {
3134 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3135 });
3136 continue;
3137 }
3138 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3139 NodeServerError::DurableState(io::Error::new(
3140 io::ErrorKind::InvalidData,
3141 DURABLE_STATE_CONFLICT_ERROR,
3142 ))
3143 })?;
3144 roots.insert(
3145 platform::root_identity(&workspace.canonical_root),
3146 workspace.workspace_id.clone(),
3147 );
3148 workspaces.insert(workspace_id, workspace);
3149 }
3150
3151 for record in &mut loaded.records {
3152 match workspaces.get(&record.workspace_id) {
3153 Some(workspace)
3154 if platform::roots_equal(
3155 &workspace.canonical_root,
3156 windows_path_text(&record.canonical_root),
3157 ) => {}
3158 Some(_) => {
3159 return Err(NodeServerError::DurableState(io::Error::new(
3160 io::ErrorKind::InvalidData,
3161 DURABLE_STATE_CONFLICT_ERROR,
3162 )));
3163 }
3164 None => {
3165 record.state = ManagedSessionState::Unavailable;
3166 record.active_session = None;
3167 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3168 }
3169 }
3170 }
3171 Ok((workspaces.into_values().collect(), loaded.records, warning))
3172}
3173
3174struct RejectedCommandReason {
3177 message: String,
3178 unsupported_transport: bool,
3185 expires_at: Instant,
3186}
3187
3188struct NodeShared {
3189 handle: Gate4AgentHandle,
3190 access_token: String,
3191 node_id: NodeId,
3192 incarnation_id: NodeIncarnationId,
3193 started_at_unix_ms: u64,
3194 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3195 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3196 managed_worktree_profiles:
3197 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3198 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3199 enabled_providers: Vec<AgentId>,
3200 provider_runtime_statuses: ProviderRuntimeStatuses,
3201 provider_runtime_status_updates:
3202 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3203 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3204 provider_contracts: Vec<ProviderContractSupport>,
3205 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3206 spawn_profiles: SpawnProfileRegistry,
3207 environment_profiles:
3208 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3209 environment_materialization_profiles:
3210 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3211 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3219 #[cfg(feature = "dig2-station-probe")]
3223 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3224 bundle_catalog: RwLock<BundleCatalog>,
3225 delivery_store: Mutex<Option<DeliveryStore>>,
3226 context_pack_store: Mutex<Option<ContextPackStore>>,
3227 context_catalog: RwLock<ContextPackCatalog>,
3228 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3229 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3230 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3231 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3232 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3234 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3236 m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3238 m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3240 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3242 #[cfg(unix)]
3245 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3246 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3247 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3248 managed_spawn_replays:
3249 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3250 controller: Mutex<Option<ControllerLease>>,
3251 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3254 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3256 history: Mutex<NodeEventHistory>,
3257 event_tx: broadcast::Sender<NodeEventEnvelope>,
3258 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3259 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3267 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3276 terminal_frame_watermarks:
3277 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3278 pty_input_wake: tokio::sync::Notify,
3280 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3283 next_connection_id: AtomicU64,
3284 next_instance_id: AtomicU64,
3285 next_command_id: AtomicU64,
3286 shutdown: AtomicBool,
3287 shutdown_notify: Notify,
3288 preauth_slots: Arc<Semaphore>,
3289 authenticated_slots: Arc<Semaphore>,
3290 inspection_slots: Arc<Semaphore>,
3291 mutation_gate: AsyncMutex<()>,
3292 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3293 session_records: Mutex<SessionRecords>,
3294 state_path: Option<PathBuf>,
3295 persistence_error: RwLock<Option<String>>,
3296 state_transaction: Mutex<()>,
3297 input_settle_timeout_ms: u64,
3298 drive_loop_profile: Mutex<DriveLoopProfiler>,
3302 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3308 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3320 native_session_gauge: AtomicUsize,
3327 connection_loop_iterations: AtomicU64,
3338 connection_events_sent: AtomicU64,
3339 rejected_commands_total: AtomicU64,
3348 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3368 drive_loop_iterations_total: AtomicU64,
3369 drive_loop_iterations_idle: AtomicU64,
3370 session_record_retention: SessionRecordRetentionConfig,
3374 retired_records_total: AtomicU64,
3380 #[cfg(feature = "fixture")]
3381 fixture_semantic_hook_policy: bool,
3382 #[cfg(feature = "fixture")]
3383 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3384}
3385
3386impl Drop for NodeShared {
3387 fn drop(&mut self) {
3388 SessionLifecycleHook::on_shutdown(self);
3389 }
3390}
3391
3392#[derive(Clone, Debug, Eq, PartialEq)]
3393struct SessionBinding {
3394 workspace_id: WorkspaceId,
3395 generation: SessionGeneration,
3396 runtime_policy: ProviderRuntimePolicy,
3397 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3398 record_id: Option<SessionRecordId>,
3399 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3400 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3401 bundle: Option<ResolvedBundleReceipt>,
3402 context: Option<ResolvedContextPackReceipt>,
3403 materialization_id: Option<MaterializationId>,
3404}
3405
3406#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3407enum SpawnRecordPolicy {
3408 ProviderIdentityOnly,
3409 Always,
3410}
3411
3412struct NativeEnvironmentSelectionGuard {
3413 control: Option<NativeLaunchProfileControl>,
3414 instance_id: AgentInstanceId,
3415}
3416
3417struct NativeInstanceOverlayGuard {
3418 control: Option<NativeLaunchProfileControl>,
3419 instance_id: AgentInstanceId,
3420}
3421
3422struct NativeHarnessMcpOverlayGuard {
3423 control: NativeLaunchProfileControl,
3424 instance_id: AgentInstanceId,
3425}
3426
3427impl NativeHarnessMcpOverlayGuard {
3428 fn retain(self) { std::mem::forget(self); }
3429}
3430
3431impl Drop for NativeHarnessMcpOverlayGuard {
3432 fn drop(&mut self) {
3433 self.control
3434 .clear_native_mcp_server_launch_overlay(self.instance_id);
3435 }
3436}
3437
3438impl NativeInstanceOverlayGuard {
3439 fn retain(mut self) {
3440 self.control = None;
3441 }
3442}
3443
3444impl Drop for NativeInstanceOverlayGuard {
3445 fn drop(&mut self) {
3446 if let Some(control) = self.control.as_ref() {
3447 control.clear_native_instance_launch_overlay(self.instance_id);
3448 }
3449 }
3450}
3451
3452enum PreparedNativeLaunchOverlay {
3453 Environment(NativeLaunchEnvironmentOverlay),
3454 Instance(NativeInstanceLaunchOverlay),
3455}
3456
3457impl NativeEnvironmentSelectionGuard {
3458 fn retain(mut self) {
3459 self.control = None;
3460 }
3461}
3462
3463impl Drop for NativeEnvironmentSelectionGuard {
3464 fn drop(&mut self) {
3465 if let Some(control) = self.control.as_ref() {
3466 control.clear_native_launch_profile_selection(self.instance_id);
3467 }
3468 }
3469}
3470
3471struct SessionMaterializationGuard<'a> {
3472 shared: &'a NodeShared,
3473 id: Option<MaterializationId>,
3474 provider_home: Option<PathBuf>,
3477}
3478
3479impl SessionMaterializationGuard<'_> {
3480 fn id(&self) -> Option<&MaterializationId> {
3481 self.id.as_ref()
3482 }
3483
3484 fn provider_home(&self) -> Option<&Path> {
3485 self.provider_home.as_deref()
3486 }
3487
3488 fn retain(mut self) {
3489 self.id = None;
3490 }
3491}
3492
3493impl Drop for SessionMaterializationGuard<'_> {
3494 fn drop(&mut self) {
3495 if let Some(id) = self.id.take() {
3496 let _ = self.shared.cleanup_materialization(&id);
3497 }
3498 }
3499}
3500
3501
3502trait SessionLifecycleHook {
3505 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3507 fn on_session_ended(&self, address: &SessionAddress);
3509 fn on_shutdown(&self);
3511 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3513 fn cleanup_session_materialization(
3515 &self,
3516 address: &SessionAddress,
3517 binding: &SessionBinding,
3518 ) -> Result<(), NodeFailure>;
3519}
3520
3521impl SessionLifecycleHook for NodeShared {
3522 fn on_session_ended(&self, address: &SessionAddress) {
3523 self.retire_session_grok_leader(address.session.instance_id);
3524 if !self.shutdown.load(Ordering::Acquire) {
3525 if let Some(file) = self.session_resume_journal() {
3526 crate::pty_resume::remove(&file, address.session.instance_id.0);
3527 }
3528 }
3529 if let Some(accounts) = self.m4a_accounts.as_ref() {
3530 let instance = address.session.instance_id;
3531 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3532 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3533 accounts.remove_session_file(nick);
3534 }
3535 }
3536 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3537 registry.revoke_session(address);
3538 }
3539 if let Some(control) = self.native_launch_profile_control.as_ref() {
3540 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3541 }
3542 }
3543
3544 fn on_shutdown(&self) {
3545 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3546 .into_values()
3547 .collect();
3548 for socket in leaders {
3549 crate::grok_leader::retire(&socket);
3550 }
3551 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3552 let instances = registry.shutdown();
3553 if let Some(control) = self.native_launch_profile_control.as_ref() {
3554 for instance_id in instances {
3555 control.clear_native_mcp_server_launch_overlay(instance_id);
3556 }
3557 }
3558 }
3559 }
3560
3561 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3562 if let Some(control) = self.native_launch_profile_control.as_ref() {
3563 control.clear_native_mcp_server_launch_overlay(instance_id);
3564 }
3565 }
3566
3567 fn cleanup_session_materialization(
3568 &self,
3569 address: &SessionAddress,
3570 binding: &SessionBinding,
3571 ) -> Result<(), NodeFailure> {
3572 self.cleanup_session_owned_materialization(address, binding)
3573 }
3574
3575 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3576 #[cfg(feature = "dig2-station-probe")]
3579 {
3580 crate::dig2_station_lease::release_lease_for_holder(
3581 &self.browser_station_leases,
3582 address.session.instance_id,
3583 );
3584 }
3585 if let Some(control) = self.native_launch_profile_control.as_ref() {
3586 control.clear_native_mcp_server_launch_overlay(
3587 address.session.instance_id,
3588 );
3589 }
3590 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3591 registry.revoke_session(address);
3592 }
3593 if removed
3594 .is_some_and(|binding| {
3595 binding.environment_profile.is_some()
3596 || binding.bundle.is_some()
3597 || binding.context.is_some()
3598 })
3599 {
3600 if let Some(control) = self.native_launch_profile_control.as_ref() {
3601 control.clear_native_launch_profile_selection(
3602 address.session.instance_id,
3603 );
3604 control.clear_native_instance_launch_overlay(
3605 address.session.instance_id,
3606 );
3607 }
3608 }
3609 }
3610}
3611
3612struct SpawnPrepareRequest<'r> {
3613 instance_id: AgentInstanceId,
3614 address: &'r SessionAddress,
3615 provider: &'r AgentId,
3616 mode: SessionMode,
3617 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3618 bundle: Option<&'r ResolvedBundleReceipt>,
3619 context: Option<&'r ResolvedContextPackReceipt>,
3620 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3621 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3622 approval_level: ApprovalLevel,
3623 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3624 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3625}
3626
3627struct PreparedSpawn<'a> {
3631 instance_overlay: Option<NativeInstanceOverlayGuard>,
3632 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3633 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3634 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3635 #[cfg(feature = "dig2-station-probe")]
3636 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3637 materialization_id: Option<MaterializationId>,
3638}
3639
3640impl PreparedSpawn<'_> {
3641 fn retain_session_guards(&mut self) {
3643 if let Some(selection) = self.environment_selection.take() {
3644 selection.retain();
3645 }
3646 if let Some(overlay) = self.instance_overlay.take() {
3647 overlay.retain();
3648 }
3649 if let Some(guard) = self.materialization_guard.take() {
3650 guard.retain();
3651 }
3652 }
3653
3654 fn retain_launch_guards(&mut self) {
3656 if let Some(overlay) = self.harness_mcp_overlay.take() {
3657 overlay.retain();
3658 }
3659 #[cfg(feature = "dig2-station-probe")]
3660 if let Some(guard) = self.browser_station_lease_guard.take() {
3661 guard.retain();
3662 }
3663 }
3664}
3665
3666trait SpawnPreparer {
3670 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3671 fn select_environment(
3673 &self,
3674 instance_id: AgentInstanceId,
3675 provider: &AgentId,
3676 mode: SessionMode,
3677 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3678 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3679 fn install_launch_overlay(
3681 &self,
3682 instance_id: AgentInstanceId,
3683 overlay: PreparedNativeLaunchOverlay,
3684 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3685}
3686
3687impl NodeShared {
3688 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3691 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3692 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3693 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3694 if let Some(file) = self.session_resume_journal() {
3695 crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3696 }
3697 key
3698 }
3699
3700 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3701 let accounts = self.m4a_accounts.as_ref()?;
3702 let nick = self.m4a_nick_of(instance_id)?;
3703 match accounts.session_workdir(root, provider.as_str(), &nick) {
3704 Ok(dir) => dir,
3705 Err(error) => {
3706 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3707 None
3708 }
3709 }
3710 }
3711
3712 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3713 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3714 }
3715
3716 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3719 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3720 for record in records.records.values_mut() {
3721 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3722 record.display_name = nick.to_owned();
3723 }
3724 }
3725 }
3726
3727 fn m4a_session_overlay(
3730 &self,
3731 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3732 m4a_provider: &str,
3733 provider: &AgentId,
3734 instance_id: AgentInstanceId,
3735 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3736 session_key: &str,
3737 mode: SessionMode,
3738 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3739 let session_key = session_key.to_owned();
3740 if mode == SessionMode::Acp {
3741 return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3742 }
3743 Ok( match accounts.provision(m4a_provider, &session_key) {
3744 Ok(account) => {
3745 crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3746 let mut identity = account.env_mutations();
3747 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3748 let merged = match environment_overlay {
3749 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3750 .map(PreparedNativeLaunchOverlay::Instance),
3751 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3752 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3753 }
3754 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3755 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3756 }
3757 }
3758 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3759 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3760 let mut merged = merged;
3761 match accounts.write_session_file(&account) {
3762 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3763 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3764 Ok(args) if !args.is_empty() => {
3765 merged = match merged {
3766 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3767 .append_extra_args(args)
3768 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3769 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3770 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3771 .into_instance_with_extra_args(args)
3772 .map(PreparedNativeLaunchOverlay::Instance)
3773 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3774 };
3775 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3776 }
3777 Ok(_) => {}
3778 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3779 },
3780 }
3781 self.m4a_live
3782 .lock()
3783 .unwrap_or_else(|p| p.into_inner())
3784 .insert(account.nick.clone(), (instance_id, provider.clone()));
3785 self.name_record_after_nick(instance_id, &account.nick);
3786 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3787 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3789 Some(merged)
3790 }
3791 Err(error) => {
3792 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3793 environment_overlay
3794 }
3795 })
3796 }
3797}
3798
3799impl NodeShared {
3800 fn m4a_acp_mcp_overlay(
3803 &self,
3804 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3805 m4a_provider: &str,
3806 provider: &AgentId,
3807 instance_id: AgentInstanceId,
3808 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3809 session_key: &str,
3810 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3811 let Some(control) = self.native_launch_profile_control.as_ref() else {
3812 tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3813 return Ok(environment_overlay);
3814 };
3815 let account = match accounts.provision(m4a_provider, session_key) {
3816 Ok(a) => a,
3817 Err(error) => {
3818 tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3819 return Ok(environment_overlay);
3820 }
3821 };
3822 let session_file = match accounts.write_session_file(&account) {
3823 Ok(p) => p,
3824 Err(error) => {
3825 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3826 return Ok(environment_overlay);
3827 }
3828 };
3829 let env_entries: Vec<(OsString, OsString)> = account
3830 .env_mutations()
3831 .into_iter()
3832 .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3833 .filter_map(|m| m.value.map(|v| (m.key, v)))
3834 .collect();
3835 let args: Vec<OsString> = vec![
3836 "--session-file".into(),
3837 session_file.into_os_string(),
3838 "mcp".into(),
3839 "--provider".into(),
3840 provider.as_str().into(),
3841 ];
3842 let overlay = NativeMcpServerLaunchOverlay::new(
3843 provider.clone(),
3844 crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3845 accounts.bin().as_os_str().to_os_string(),
3846 args,
3847 env_entries,
3848 Vec::new(),
3849 );
3850 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())) {
3851 Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3852 Err(error) => {
3853 tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3854 return Ok(environment_overlay);
3855 }
3856 }
3857 self.m4a_live
3858 .lock()
3859 .unwrap_or_else(|p| p.into_inner())
3860 .insert(account.nick.clone(), (instance_id, provider.clone()));
3861 self.name_record_after_nick(instance_id, &account.nick);
3862 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3863 Ok(environment_overlay)
3864 }
3865}
3866
3867impl SpawnPreparer for NodeShared {
3868 fn select_environment(
3869 &self,
3870 instance_id: AgentInstanceId,
3871 provider: &AgentId,
3872 mode: SessionMode,
3873 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3874 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3875 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3876 }
3877
3878 fn install_launch_overlay(
3879 &self,
3880 instance_id: AgentInstanceId,
3881 overlay: PreparedNativeLaunchOverlay,
3882 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3883 self.install_prepared_launch_overlay(instance_id, overlay)
3884 }
3885
3886 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3887 let SpawnPrepareRequest {
3888 instance_id,
3889 address,
3890 provider,
3891 mode,
3892 environment_profile,
3893 bundle,
3894 context,
3895 managed_authority,
3896 harness_mcp,
3897 approval_level,
3898 network_allowlist,
3899 browser_profile_id,
3900 } = request;
3901 #[cfg(feature = "dig2-station-probe")]
3906 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3907 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3908 match acquire_lease(
3909 &self.browser_station_leases,
3910 profile_id,
3911 instance_id,
3912 unix_time_ms(),
3913 ) {
3914 Ok(guard) => Some(guard),
3915 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3916 return Err(failure(
3917 NodeFailureCode::BrowserStationProfileBusy,
3918 "dig2browser station profile is already leased by another session",
3919 ));
3920 }
3921 Err(BrowserStationLeaseError::CapacityExceeded) => {
3922 return Err(failure(
3923 NodeFailureCode::BrowserStationProfileBusy,
3924 "dig2browser station profile lease capacity exhausted",
3925 ));
3926 }
3927 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3928 return Err(failure(
3929 NodeFailureCode::BrowserStationProfileBusy,
3930 "session already holds a dig2browser station profile lease",
3931 ));
3932 }
3933 }
3934 } else {
3935 None
3936 };
3937 #[cfg(not(feature = "dig2-station-probe"))]
3938 let _ = browser_profile_id;
3939 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3940 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3949 {
3950 return Err(failure(
3951 NodeFailureCode::BindingMismatch,
3952 "harness MCP reservation does not match the exact PTY/ACP provider",
3953 ));
3954 }
3955 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3956 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3957 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3958 })?;
3959 let launch = prepared.launch();
3964 let mut env_entries = vec![
3965 (
3966 OsString::from(launch.endpoint_env.as_str()),
3967 prepared.endpoint().as_os_str().to_os_string(),
3968 ),
3969 (
3970 OsString::from(launch.token_env.as_str()),
3971 prepared.token().expose().into(),
3972 ),
3973 (
3974 OsString::from(launch.program_env.as_str()),
3975 prepared.helper_program().as_os_str().to_os_string(),
3976 ),
3977 ];
3978 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3979 env_entries.push(trace_entry);
3980 }
3981 if let Some(door) = crate::door::door_listen_addr() {
3982 env_entries.push((
3983 OsString::from("HATCHERY_NODE_DOOR"),
3984 OsString::from(door.to_string()),
3985 ));
3986 let session_key = format!(
3990 "pty-{}-{}-{}",
3991 self.incarnation_id,
3992 address.workspace_id.as_str(),
3993 address.session.instance_id.0
3994 );
3995 env_entries.push((
3996 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3997 OsString::from(session_key),
3998 ));
3999 }
4000 let overlay = NativeMcpServerLaunchOverlay::new(
4001 provider.clone(),
4002 launch.server_name.as_str(),
4003 prepared.helper_program().as_os_str().to_os_string(),
4004 launch.args.iter().map(OsString::from).collect(),
4005 env_entries,
4006 launch.scrub_env.iter().map(OsString::from).collect(),
4007 ).map_err(|_| failure(
4008 NodeFailureCode::HarnessMcpUnavailable,
4009 "harness MCP launch overlay is unavailable",
4010 ))?;
4011 control
4012 .install_native_mcp_server_launch_overlay(instance_id, overlay)
4013 .map_err(|_| failure(
4014 NodeFailureCode::HarnessMcpUnavailable,
4015 "harness MCP launch overlay could not be installed",
4016 ))?;
4017 tracing::info!(
4023 node_id = %self.node_id,
4024 instance_id = ?instance_id,
4025 provider = %provider,
4026 mode = ?mode,
4027 "harness MCP overlay installed for spawn",
4028 );
4029 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4030 } else {
4031 None
4032 };
4033 let prepared_materialization = self.prepare_session_materialization(
4034 &address,
4035 &provider,
4036 mode,
4037 environment_profile,
4038 bundle,
4039 context,
4040 managed_authority.cloned(),
4041 )?;
4042 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4043 Some((overlay, guard)) => (Some(overlay), Some(guard)),
4044 None => (None, None),
4045 };
4046 let materialization_id = materialization_guard
4047 .as_ref()
4048 .and_then(SessionMaterializationGuard::id)
4049 .cloned();
4050 let mut environment_selection = SpawnPreparer::select_environment(self,
4051 instance_id,
4052 &provider,
4053 mode,
4054 environment_profile,
4055 )?;
4056 let provider_home = materialization_guard
4059 .as_ref()
4060 .and_then(SessionMaterializationGuard::provider_home);
4061 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4062 &provider,
4063 mode,
4064 approval_level,
4065 network_allowlist,
4066 provider_home,
4067 )?;
4068 let mut network_overlay_args = network_overlay_args;
4069 {
4071 let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4072 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4073 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4074 homes.extend(std::env::split_paths(&extra));
4075 }
4076 if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4077 crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4078 network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4079 }
4080 if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4081 crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4082 }
4083 if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4085 if let Some(file) = self.session_resume_journal() {
4086 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4087 instance_id: instance_id.0,
4088 provider: provider.as_str().to_owned(),
4089 workspace_id: address.workspace_id.as_str().to_owned(),
4090 provider_session_id: String::new(),
4091 session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4092 .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4093 .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4094 cwd: String::new(),
4095 acp: mode == SessionMode::Acp,
4096 options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4097 cols: 0,
4098 rows: 0,
4099 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4100 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4101 });
4102 if let Some(dir) = file.parent() {
4103 crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4104 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4105 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4106 provider: Some(provider.as_str().to_owned()),
4107 ..crate::session_meta::Meta::default()
4108 });
4109 crate::session_meta::revive(dir, instance_id.0);
4110 }
4111 tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4112 }
4113 }
4114 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4115 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4116 }
4117 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4118 (prepared, args) if args.is_empty() => prepared,
4119 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4120 overlay.append_extra_args(args).map_err(|_| {
4121 failure(
4122 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4123 "network allowlist overlay could not merge with instance launch args",
4124 )
4125 })?;
4126 Some(PreparedNativeLaunchOverlay::Instance(overlay))
4127 }
4128 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4129 Some(PreparedNativeLaunchOverlay::Instance(
4130 overlay
4131 .into_instance_with_extra_args(args)
4132 .map_err(|_| {
4133 failure(
4134 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4135 "network allowlist overlay could not merge with environment overlay",
4136 )
4137 })?,
4138 ))
4139 }
4140 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4141 NativeInstanceLaunchOverlay::new(
4142 provider.clone(),
4143 match mode {
4144 SessionMode::Pty => TransportKind::Pty,
4145 SessionMode::Acp => TransportKind::Acp,
4146 SessionMode::Inline => TransportKind::Pipe,
4147 },
4148 Vec::new(),
4149 args,
4150 )
4151 .map_err(|_| {
4152 failure(
4153 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4154 "network allowlist overlay is invalid for this transport",
4155 )
4156 })?,
4157 )),
4158 };
4159 #[cfg(unix)]
4162 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4164 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4165 });
4166 #[cfg(unix)]
4167 let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4170 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4171 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4172 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4173 let uid = unsafe { libc_geteuid() };
4174 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4175 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4176 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4177 Some(vec![
4178 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4179 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4180 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4181 ])
4182 }
4183 _ => None,
4184 };
4185 #[cfg(not(unix))]
4186 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4187 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4188 (SessionMode::Pty, _, _, Some(identity)) => {
4189 let merged = match environment_overlay {
4190 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4191 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4192 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4193 }
4194 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4195 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4196 }
4197 }
4198 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4199 Some(merged)
4200 }
4201 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4202 let session_key = self.m4a_session_key(instance_id);
4203 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4204 }
4205 (SessionMode::Pty, Some(_), None, _) => {
4206 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4207 environment_overlay
4208 }
4209 _ => environment_overlay,
4210 };
4211 let mut instance_overlay = environment_overlay
4212 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4213 .transpose()?
4214 .flatten();
4215 Ok(PreparedSpawn {
4216 instance_overlay,
4217 environment_selection,
4218 materialization_guard,
4219 harness_mcp_overlay,
4220 #[cfg(feature = "dig2-station-probe")]
4221 browser_station_lease_guard,
4222 materialization_id,
4223 })
4224 }
4225}
4226
4227struct SessionRecords {
4228 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4229}
4230
4231struct SpawnIdempotencyCache {
4232 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4233}
4234
4235struct SpawnIdempotencyEntry {
4236 value: SpawnIdempotencyValue,
4237 expires_at: Instant,
4238}
4239
4240enum SpawnIdempotencyValue {
4241 Standard {
4242 spec: SpawnSpec,
4243 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4244 },
4245 Managed {
4246 request: ManagedWorktreeSpawnRequest,
4247 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4248 },
4249 HarnessMcp,
4250}
4251
4252enum DurableManagedSpawnReplayDecision {
4253 Reserved,
4254 PendingLinked,
4255 Committed(ManagedWorktreeSpawnReceipt),
4256}
4257
4258impl SpawnIdempotencyValue {
4259 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4260 match self {
4261 Self::Standard { result, .. } => result
4262 .as_ref()
4263 .ok()
4264 .and_then(|receipt| receipt.context.as_ref()),
4265 Self::Managed { result, .. } => result
4266 .as_ref()
4267 .ok()
4268 .and_then(|receipt| receipt.spawn.context.as_ref()),
4269 Self::HarnessMcp => None,
4270 }
4271 .is_some_and(|receipt| &receipt.id == context_id)
4272 }
4273}
4274
4275#[derive(Clone, Copy)]
4276struct ControllerLease {
4277 connection_id: u64,
4278 expires_at: Instant,
4279}
4280
4281struct NodeEventHistory {
4282 last_sequence: u64,
4283 replay_floor_sequence: u64,
4284 events: VecDeque<NodeEventEnvelope>,
4285 record_providers: BTreeMap<SessionRecordId, AgentId>,
4286 removed_record_providers: BTreeMap<u64, AgentId>,
4287}
4288
4289impl NodeEventHistory {
4290 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4291 Self {
4292 last_sequence: 0,
4293 replay_floor_sequence: 1,
4294 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4295 record_providers,
4296 removed_record_providers: BTreeMap::new(),
4297 }
4298 }
4299}
4300
4301impl NodeShared {
4302 fn session_resume_journal(&self) -> Option<PathBuf> {
4306 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4307 }
4308
4309 async fn restore_journalled_sessions(&self) {
4312 let Some(file) = self.session_resume_journal() else { return };
4313 if !crate::pty_resume::enabled() { return; }
4314 for e in crate::pty_resume::load(&file) {
4315 crate::pty_resume::remove(&file, e.instance_id);
4316 if !e.session_key.is_empty() {
4318 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4319 }
4320 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4321 if e.acp {
4322 let record_id = {
4323 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4324 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())
4325 };
4326 let cur = self.next_instance_id.load(Ordering::Acquire);
4327 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4328 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4329 let resumed = match record_id {
4330 Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4331 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4332 };
4333 let r = match resumed {
4335 Ok(a) => Ok(a),
4336 Err(err) => {
4337 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4338 self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4339 SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4340 }
4341 };
4342 match r {
4343 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4344 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4345 }
4346 continue;
4347 }
4348 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4349 let cur = self.next_instance_id.load(Ordering::Acquire);
4351 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4352 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4353 match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4354 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4355 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4356 }
4357 continue;
4358 }
4359 let folder = std::path::Path::new(&e.cwd);
4360 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4361 let name = match (in_session_dir, folder.file_name()) {
4362 (true, Some(n)) => n.to_string_lossy().into_owned(),
4363 _ => format!("{} #{}", e.provider, e.instance_id),
4364 };
4365 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4366 let record = match self.index_provider_session(ws, provider, identity, name) {
4367 Ok(r) => r,
4368 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4369 };
4370 let cur = self.next_instance_id.load(Ordering::Acquire);
4371 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4372 let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4373 let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4374 match r {
4375 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4376 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4377 }
4378 }
4379 }
4380
4381 fn refresh_session_journal(&self) {
4382 let Some(file) = self.session_resume_journal() else { return };
4383 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4384 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4385 crate::pty_resume::refresh(&file, &homes);
4386 let ids: Vec<(u64, String)> = {
4387 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4388 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4389 };
4390 for (inst, sid) in ids {
4391 crate::pty_resume::set_session_id(&file, inst, &sid);
4392 }
4393 }
4394
4395 fn session_grok_leader_socket(
4396 &self,
4397 provider: &AgentId,
4398 mode: SessionMode,
4399 instance_id: AgentInstanceId,
4400 ) -> Option<std::path::PathBuf> {
4401 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4402 return None;
4403 }
4404 let root = self
4405 .m4a_accounts
4406 .as_ref()
4407 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4408 .unwrap_or_else(std::env::temp_dir);
4409 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4410 if let Some(previous) = self
4411 .grok_leaders
4412 .lock()
4413 .unwrap_or_else(|p| p.into_inner())
4414 .insert(instance_id, socket.clone())
4415 {
4416 crate::grok_leader::retire(&previous);
4417 }
4418 Some(socket)
4419 }
4420
4421 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4423 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4424 if let Some(socket) = socket {
4425 crate::grok_leader::retire(&socket);
4426 }
4427 }
4428
4429 fn new_with_incarnation(
4430 handle: Gate4AgentHandle,
4431 access_token: String,
4432 node_id: NodeId,
4433 incarnation_id: NodeIncarnationId,
4434 workspaces: Vec<WorkspaceConfig>,
4435 enabled_providers: Vec<AgentId>,
4436 provider_runtime_statuses: ProviderRuntimeStatuses,
4437 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4438 provider_contracts: Vec<ProviderContractSupport>,
4439 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4440 spawn_profiles: SpawnProfileRegistry,
4441 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4442 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4443 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4444 state_path: Option<PathBuf>,
4445 records: Vec<ManagedSessionRecord>,
4446 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4447 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4448 materializations: Vec<MaterializationOwnershipRecord>,
4449 persistence_warning: Option<String>,
4450 input_settle_timeout_ms: u64,
4451 ) -> Self {
4452 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4453 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4454 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4455 let (harness_mcp_proxy_event_tx, _) =
4456 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4457 let record_providers = records
4458 .iter()
4459 .map(|record| (record.record_id.clone(), record.provider.clone()))
4460 .collect();
4461 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4462 .iter()
4463 .map(|workspace| {
4464 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4465 })
4466 .collect();
4467 for lease in &managed_worktrees {
4468 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4469 }
4470 let managed_worktree_profiles = workspaces.iter()
4471 .map(|workspace| (
4472 workspace.workspace_id.clone(),
4473 workspace.managed_worktree_profiles.clone(),
4474 ))
4475 .collect();
4476 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4477 managed_worktrees,
4478 managed_worktree_tombstones,
4479 ).expect("durable managed worktree registry was validated while loading");
4480 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4481 managed_worktrees.reattach_record_holders(
4482 &managed_worktree_record_holders(&records),
4483 unix_time_ms(),
4484 );
4485 Self {
4486 handle,
4487 access_token,
4488 node_id,
4489 incarnation_id,
4490 started_at_unix_ms: std::time::SystemTime::now()
4491 .duration_since(std::time::UNIX_EPOCH)
4492 .unwrap_or_default()
4493 .as_millis()
4494 .min(u64::MAX as u128) as u64,
4495 workspaces: RwLock::new(
4496 workspaces
4497 .into_iter()
4498 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4499 .collect(),
4500 ),
4501 worktree_service_modes,
4502 managed_worktree_profiles,
4503 managed_worktrees: Mutex::new(managed_worktrees),
4504 enabled_providers,
4505 provider_runtime_statuses,
4506 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4507 provider_runtime_monitor,
4508 provider_contracts,
4509 provider_adapter_contracts,
4510 spawn_profiles,
4511 environment_profiles: RwLock::new(BTreeMap::new()),
4512 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4513 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4514 #[cfg(feature = "dig2-station-probe")]
4515 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4516 bundle_catalog: RwLock::new(BundleCatalog::default()),
4517 delivery_store: Mutex::new(None),
4518 context_pack_store: Mutex::new(None),
4519 context_catalog: RwLock::new(ContextPackCatalog::default()),
4520 native_launch_profile_control,
4521 harness_mcp_registry: None,
4522 native_session_catalog,
4523 session_environment_materializer,
4524 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4525 m4a_live: Mutex::new(BTreeMap::new()),
4526 m4a_keys: Mutex::new(BTreeMap::new()),
4527 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4528 grok_leaders: Mutex::new(BTreeMap::new()),
4529 #[cfg(unix)]
4530 signer_proxy: std::sync::OnceLock::new(),
4531 materializations: Mutex::new(
4532 materializations
4533 .into_iter()
4534 .map(|record| (record.id().clone(), record))
4535 .collect(),
4536 ),
4537 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4538 entries: BTreeMap::new(),
4539 }),
4540 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4541 controller: Mutex::new(None),
4542 provisional_spawns: Mutex::new(BTreeMap::new()),
4543 pending_acp_options: Mutex::new(BTreeMap::new()),
4544 history: Mutex::new(NodeEventHistory::new(record_providers)),
4545 event_tx,
4546 terminal_event_tx,
4547 agent_stream_event_tx,
4548 harness_mcp_proxy_event_tx,
4549 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4550 pty_input_wake: tokio::sync::Notify::new(),
4551 pty_input_marks: Mutex::new(BTreeMap::new()),
4552 next_connection_id: AtomicU64::new(1),
4553 next_instance_id: AtomicU64::new(1),
4554 next_command_id: AtomicU64::new(1),
4555 shutdown: AtomicBool::new(false),
4556 shutdown_notify: Notify::new(),
4557 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4558 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4559 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4560 mutation_gate: AsyncMutex::new(()),
4561 session_bindings: Mutex::new(BTreeMap::new()),
4562 session_records: Mutex::new(SessionRecords {
4563 records: records
4564 .into_iter()
4565 .map(|record| (record.record_id.clone(), record))
4566 .collect(),
4567 }),
4568 state_path,
4569 persistence_error: RwLock::new(
4570 persistence_warning
4571 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4572 ),
4573 state_transaction: Mutex::new(()),
4574 input_settle_timeout_ms,
4575 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4576 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4577 shell_efficiency_profile: OnceLock::new(),
4578 native_session_gauge: AtomicUsize::new(0),
4579 connection_loop_iterations: AtomicU64::new(0),
4580 connection_events_sent: AtomicU64::new(0),
4581 rejected_commands_total: AtomicU64::new(0),
4582 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4583 drive_loop_iterations_total: AtomicU64::new(0),
4584 drive_loop_iterations_idle: AtomicU64::new(0),
4585 session_record_retention: SessionRecordRetentionConfig::default(),
4586 retired_records_total: AtomicU64::new(0),
4587 #[cfg(feature = "fixture")]
4588 fixture_semantic_hook_policy: false,
4589 #[cfg(feature = "fixture")]
4590 fixture_spawn_managed_worktree_v2_failure_probe:
4591 SpawnManagedWorktreeV2FailureProbe::default(),
4592 }
4593 }
4594
4595 #[cfg(test)]
4596 fn new(
4597 handle: Gate4AgentHandle,
4598 access_token: String,
4599 node_id: NodeId,
4600 workspaces: Vec<WorkspaceConfig>,
4601 enabled_providers: Vec<AgentId>,
4602 ) -> Self {
4603 Self::new_with_incarnation(
4604 handle,
4605 access_token,
4606 node_id,
4607 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4608 workspaces,
4609 enabled_providers,
4610 ProviderRuntimeStatuses::default(),
4611 None,
4612 Vec::new(),
4613 Vec::new(),
4614 SpawnProfileRegistry::default(),
4615 None,
4616 None,
4617 None,
4618 None,
4619 Vec::new(),
4620 Vec::new(),
4621 Vec::new(),
4622 Vec::new(),
4623 None,
4624 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4625 )
4626 }
4627
4628 fn persistence_error(&self) -> Option<String> {
4629 self.persistence_error
4630 .read()
4631 .unwrap_or_else(|poisoned| poisoned.into_inner())
4632 .clone()
4633 }
4634
4635 async fn admit_provider_runtime(
4636 &self,
4637 provider: &AgentId,
4638 requirement: ProviderRuntimeRequirement,
4639 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4640 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4641 let pipe_transport = monitor.supports_pipe_transport(provider);
4642 let acp_transport = monitor.supports_acp_transport(provider);
4643 let provider = provider.clone();
4644 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4645 let (status, admission) = timeout(
4646 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4647 refresh,
4648 )
4649 .await
4650 .map_err(|_| failure(
4651 NodeFailureCode::BackendBusy,
4652 "provider runtime probe exceeded its bounded deadline",
4653 ))?
4654 .map_err(|_| failure(
4655 NodeFailureCode::BackendOperationFailed,
4656 "provider runtime probe task failed",
4657 ))?;
4658 if let Some(status) = status {
4659 self
4660 .provider_runtime_status_updates
4661 .write()
4662 .unwrap_or_else(|poisoned| poisoned.into_inner())
4663 .insert(status.provider().clone(), status);
4664 }
4665 admission.and_then(|policy| {
4666 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4667 })
4668 } else {
4669 crate::provider_runtime::admit_status(
4675 &self.provider_runtime_statuses,
4676 provider,
4677 false,
4678 false,
4679 requirement,
4680 )
4681 };
4682 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4683 admission.map_err(|error| match error {
4684 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4685 NodeFailureCode::BackendOperationFailed,
4686 "provider launcher is unavailable",
4687 ),
4688 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4689 NodeFailureCode::UnsupportedCapability,
4690 "provider semantic capability is not verified",
4691 ),
4692 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4693 NodeFailureCode::BackendBusy,
4694 "provider runtime probe is already in progress",
4695 ),
4696 })
4697 }
4698
4699 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4705 match self.provider_runtime_monitor.as_deref() {
4706 Some(monitor) => monitor.supports_pipe_transport(provider),
4707 None => false,
4708 }
4709 }
4710
4711 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4717 match self.provider_runtime_monitor.as_deref() {
4718 Some(monitor) => monitor.supports_acp_transport(provider),
4719 None => false,
4720 }
4721 }
4722
4723 fn admit_declared_hook_semantics(
4739 &self,
4740 provider: &AgentId,
4741 policy: ProviderRuntimePolicy,
4742 ) -> ProviderRuntimePolicy {
4743 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4744 return policy;
4745 }
4746 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4747 contract.provider == *provider && contract.family == AdapterFamily::Hook
4748 });
4749 if !declares_hook {
4750 return policy;
4751 }
4752 ProviderRuntimePolicy::new(
4753 policy.raw_pty_lifecycle,
4754 policy.semantic_readiness,
4755 policy.structured_prompt,
4756 policy.provider_session_identity,
4757 policy.semantic_resume,
4758 true,
4759 )
4760 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4761 }
4762
4763 fn resolve_network_allowlist_overlay_args(
4778 &self,
4779 provider: &AgentId,
4780 mode: SessionMode,
4781 approval_level: ApprovalLevel,
4782 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4783 provider_home: Option<&Path>,
4784 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4785 let Some(allowlist_id) = network_allowlist else {
4786 return Ok(Vec::new());
4787 };
4788 let catalog = self
4789 .network_allowlist_catalog
4790 .read()
4791 .unwrap_or_else(|poisoned| poisoned.into_inner());
4792 let Some(entry) = catalog.get(allowlist_id) else {
4793 return Err(failure(
4794 NodeFailureCode::UnknownNetworkAllowlist,
4795 "spawn network allowlist is unavailable on this node",
4796 ));
4797 };
4798
4799 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4801 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4802 |message| {
4803 failure(
4804 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4805 &message,
4806 )
4807 },
4808 )?;
4809 if !matches!(mode, SessionMode::Pty) {
4810 return Err(failure(
4811 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4812 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4813 ));
4814 }
4815 let Some(home) = provider_home else {
4816 return Err(failure(
4817 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4818 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4819 ));
4820 };
4821 let domains =
4822 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4823 if domains.is_empty() {
4824 return Err(failure(
4825 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4826 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4827 ));
4828 }
4829 let settings_path = home.join(
4830 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4831 );
4832 let json =
4833 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4834 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4835 |_| {
4836 failure(
4837 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4838 "Claude station network settings sidecar could not be written under ProviderHome",
4839 )
4840 },
4841 )?;
4842 let overlay =
4843 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4844 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4845 }
4846
4847 let Some(native) = entry.provider_native.as_ref() else {
4848 return Ok(Vec::new());
4849 };
4850 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4851 provider.as_str(),
4852 approval_level,
4853 native,
4854 )
4855 .map_err(|message| {
4856 failure(
4857 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4858 &message,
4859 )
4860 })?;
4861 if overlay.is_empty() {
4862 return Ok(Vec::new());
4863 }
4864 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4865 return Err(failure(
4866 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4867 "Codex network_access -c overlay requires PTY or ACP transport",
4868 ));
4869 }
4870 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4871 }
4872
4873 fn resolve_codex_network_access_overlay_args(
4875 &self,
4876 provider: &AgentId,
4877 mode: SessionMode,
4878 approval_level: ApprovalLevel,
4879 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4880 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4881 self.resolve_network_allowlist_overlay_args(
4882 provider,
4883 mode,
4884 approval_level,
4885 network_allowlist,
4886 None,
4887 )
4888 }
4889
4890 fn resolve_environment_profile(
4891 &self,
4892 resolved: &ResolvedSpawnSpec,
4893 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4894 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4900 let catalog = self
4901 .network_allowlist_catalog
4902 .read()
4903 .unwrap_or_else(|poisoned| poisoned.into_inner());
4904 let Some(entry) = catalog.get(allowlist_id) else {
4905 return Err(failure(
4906 NodeFailureCode::UnknownNetworkAllowlist,
4907 "spawn network allowlist is unavailable on this node",
4908 ));
4909 };
4910 if let Some(native) = entry.provider_native.as_ref() {
4913 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4914 resolved.provider.as_str(),
4915 resolved.approval_level,
4916 native,
4917 ) {
4918 return Err(failure(
4919 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4920 &message,
4921 ));
4922 }
4923 }
4924 }
4925 #[cfg(feature = "dig2-station-probe")]
4934 if resolved.browser_profile_id.is_some() {
4935 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4936 match probe_station_reachable() {
4937 Ok(()) => {}
4938 Err(StationProbeError::UnsupportedPlatform)
4939 | Err(StationProbeError::InvalidPipeSuffix) => {
4940 return Err(failure(
4941 NodeFailureCode::BrowserStationProbeUnavailable,
4942 "dig2browser station probe unavailable on this platform",
4943 ));
4944 }
4945 Err(StationProbeError::Unreachable) => {
4946 return Err(failure(
4947 NodeFailureCode::BrowserStationUnreachable,
4948 "dig2browser station named-pipe is unreachable",
4949 ));
4950 }
4951 }
4952 }
4953 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4954 return Ok(None);
4955 };
4956 let profiles = self
4957 .environment_profiles
4958 .read()
4959 .unwrap_or_else(|poisoned| poisoned.into_inner());
4960 let binding = profiles.get(profile_id).ok_or_else(|| {
4961 failure(
4962 NodeFailureCode::UnknownEnvironmentProfile,
4963 "spawn environment profile is unavailable on this node",
4964 )
4965 })?;
4966 if binding.provider != resolved.provider
4967 || binding.native_profile_id(resolved.mode).is_none()
4968 {
4969 return Err(failure(
4970 NodeFailureCode::EnvironmentProfileBindingMismatch,
4971 "spawn environment profile does not match the provider and mode",
4972 ));
4973 }
4974 Ok(Some(ResolvedEnvironmentProfileReceipt {
4978 profile_id: binding.id.clone(),
4979 profile_revision: binding.revision.clone(),
4980 network_allowlist: resolved.network_allowlist.clone(),
4981 browser_profile_id: resolved.browser_profile_id.clone(),
4982 }))
4983 }
4984
4985 fn resolve_bundle(
4986 &self,
4987 resolved: &ResolvedSpawnSpec,
4988 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4989 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4990 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4991 return Ok(None);
4992 };
4993 let catalog = self.bundle_catalog
4994 .read()
4995 .unwrap_or_else(|poisoned| poisoned.into_inner());
4996 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4997 failure(
4998 NodeFailureCode::UnknownBundle,
4999 "spawn bundle is unavailable on this node",
5000 )
5001 })?;
5002 self.bundle_layout(
5003 &resolved.provider,
5004 resolved.mode,
5005 environment_profile,
5006 bundle,
5007 )?;
5008 Ok(Some(bundle.receipt()))
5009 }
5010
5011 fn begin_delivery_stage(
5012 &self,
5013 manifest: DeliveryBundleManifestV2,
5014 ) -> Result<NodeResponse, NodeFailure> {
5015 let mut store = self
5016 .delivery_store
5017 .lock()
5018 .unwrap_or_else(|poisoned| poisoned.into_inner());
5019 let store = store.as_mut().ok_or_else(|| {
5020 failure(
5021 NodeFailureCode::DeliveryStageStorageFailed,
5022 "node delivery store is unavailable",
5023 )
5024 })?;
5025 let begun = store.begin(manifest).map_err(delivery_failure)?;
5026 Ok(NodeResponse::DeliveryStageBegun {
5027 stage_id: begun.stage_id,
5028 manifest_digest: begun.manifest_digest,
5029 missing_blobs: begun.missing_blobs,
5030 })
5031 }
5032
5033 fn put_delivery_blob_chunk(
5034 &self,
5035 stage_id: DeliveryStageId,
5036 blob_digest: DeliveryBlobDigestV1,
5037 offset: u64,
5038 chunk_hex: DeliveryBlobChunkHexV1,
5039 ) -> Result<NodeResponse, NodeFailure> {
5040 let mut store = self
5041 .delivery_store
5042 .lock()
5043 .unwrap_or_else(|poisoned| poisoned.into_inner());
5044 let store = store.as_mut().ok_or_else(|| {
5045 failure(
5046 NodeFailureCode::DeliveryStageStorageFailed,
5047 "node delivery store is unavailable",
5048 )
5049 })?;
5050 let accepted = store
5051 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5052 .map_err(delivery_failure)?;
5053 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5054 stage_id: accepted.stage_id,
5055 blob_digest: accepted.blob_digest,
5056 next_offset: accepted.next_offset,
5057 })
5058 }
5059
5060 fn commit_delivery_stage(
5061 &self,
5062 stage_id: DeliveryStageId,
5063 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5064 let mut store = self
5065 .delivery_store
5066 .lock()
5067 .unwrap_or_else(|poisoned| poisoned.into_inner());
5068 let store = store.as_mut().ok_or_else(|| {
5069 failure(
5070 NodeFailureCode::DeliveryStageStorageFailed,
5071 "node delivery store is unavailable",
5072 )
5073 })?;
5074 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5075 let mut catalog = self
5076 .bundle_catalog
5077 .write()
5078 .unwrap_or_else(|poisoned| poisoned.into_inner());
5079 let mut next_catalog = catalog.clone();
5080 next_catalog
5081 .insert_idempotent(prepared.bundle().clone())
5082 .map_err(|_| {
5083 failure(
5084 NodeFailureCode::DeliveryStageConflict,
5085 "delivery bundle identity conflicts with installed content",
5086 )
5087 })?;
5088 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5089 *catalog = next_catalog;
5090 Ok(receipt)
5091 }
5092
5093 fn abort_delivery_stage(
5094 &self,
5095 stage_id: &DeliveryStageId,
5096 ) -> Result<(), NodeFailure> {
5097 let mut store = self
5098 .delivery_store
5099 .lock()
5100 .unwrap_or_else(|poisoned| poisoned.into_inner());
5101 let store = store.as_mut().ok_or_else(|| {
5102 failure(
5103 NodeFailureCode::DeliveryStageStorageFailed,
5104 "node delivery store is unavailable",
5105 )
5106 })?;
5107 store.abort(stage_id).map_err(delivery_failure)
5108 }
5109
5110 fn resolve_context(
5111 &self,
5112 resolved: &ResolvedSpawnSpec,
5113 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5114 let Some(context_id) = resolved.context_id.as_ref() else {
5115 return Ok(None);
5116 };
5117 self.context_catalog
5118 .read()
5119 .unwrap_or_else(|poisoned| poisoned.into_inner())
5120 .get(context_id)
5121 .map(|context| context.receipt().clone())
5122 .ok_or_else(|| {
5123 failure(
5124 NodeFailureCode::UnknownContextPack,
5125 "spawn context pack is unavailable on this node",
5126 )
5127 })
5128 .map(Some)
5129 }
5130
5131 fn bundle_layout(
5132 &self,
5133 provider: &AgentId,
5134 mode: SessionMode,
5135 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5136 bundle: &NodeBundle,
5137 ) -> Result<BundleProviderLayout, NodeFailure> {
5138 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5139 failure(
5140 NodeFailureCode::BundleBindingMismatch,
5141 "spawn bundle does not match the provider and mode",
5142 )
5143 })?;
5144 if layout != BundleProviderLayout::Codex {
5145 return Ok(layout);
5146 }
5147 let profile = environment_profile.and_then(|receipt| {
5148 let profiles = self
5149 .environment_profiles
5150 .read()
5151 .unwrap_or_else(|poisoned| poisoned.into_inner());
5152 let binding = profiles.get(&receipt.profile_id)?;
5153 if binding.revision != receipt.profile_revision
5154 || &binding.provider != provider
5155 || binding.native_profile_id(mode).is_none()
5156 {
5157 return None;
5158 }
5159 self.environment_materialization_profiles
5160 .read()
5161 .unwrap_or_else(|poisoned| poisoned.into_inner())
5162 .get(&receipt.profile_id)
5163 .cloned()
5164 });
5165 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5166 return Err(failure(
5167 NodeFailureCode::BundleBindingMismatch,
5168 "Codex bundle requires an exact isolated CODEX_HOME profile",
5169 ));
5170 }
5171 Ok(layout)
5172 }
5173
5174 fn select_environment_profile(
5175 &self,
5176 instance_id: AgentInstanceId,
5177 provider: &AgentId,
5178 mode: SessionMode,
5179 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5180 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5181 let Some(environment_profile) = environment_profile else {
5182 return Ok(None);
5183 };
5184 let native_profile_id = {
5185 let profiles = self
5186 .environment_profiles
5187 .read()
5188 .unwrap_or_else(|poisoned| poisoned.into_inner());
5189 let binding = profiles
5190 .get(&environment_profile.profile_id)
5191 .ok_or_else(|| {
5192 failure(
5193 NodeFailureCode::UnknownEnvironmentProfile,
5194 "session environment profile is unavailable on this node",
5195 )
5196 })?;
5197 if &binding.revision != &environment_profile.profile_revision
5198 || &binding.provider != provider
5199 {
5200 return Err(failure(
5201 NodeFailureCode::EnvironmentProfileBindingMismatch,
5202 "session environment profile revision or provider changed",
5203 ));
5204 }
5205 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5206 failure(
5207 NodeFailureCode::EnvironmentProfileBindingMismatch,
5208 "session environment profile does not support this mode",
5209 )
5210 })?
5211 };
5212 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5213 failure(
5214 NodeFailureCode::BackendOperationFailed,
5215 "native environment profile controller is unavailable",
5216 )
5217 })?;
5218 control
5219 .select_native_launch_profile(instance_id, native_profile_id)
5220 .map_err(|_| {
5221 failure(
5222 NodeFailureCode::BackendOperationFailed,
5223 "native environment profile selection failed",
5224 )
5225 })?;
5226 Ok(Some(NativeEnvironmentSelectionGuard {
5227 control: Some(control),
5228 instance_id,
5229 }))
5230 }
5231
5232 fn install_prepared_launch_overlay(
5233 &self,
5234 instance_id: AgentInstanceId,
5235 overlay: PreparedNativeLaunchOverlay,
5236 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5237 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5238 failure(
5239 NodeFailureCode::BackendOperationFailed,
5240 "native launch profile controller is unavailable",
5241 )
5242 })?;
5243 match overlay {
5244 PreparedNativeLaunchOverlay::Environment(overlay) => {
5245 control
5246 .install_native_launch_environment_overlay(instance_id, overlay)
5247 .map_err(|_| failure(
5248 NodeFailureCode::BackendOperationFailed,
5249 "session environment overlay installation failed",
5250 ))?;
5251 Ok(None)
5252 }
5253 PreparedNativeLaunchOverlay::Instance(overlay) => {
5254 control
5255 .install_native_instance_launch_overlay(instance_id, overlay)
5256 .map_err(|_| failure(
5257 NodeFailureCode::BundleBindingMismatch,
5258 "session bundle launch overlay installation failed",
5259 ))?;
5260 Ok(Some(NativeInstanceOverlayGuard {
5261 control: Some(control),
5262 instance_id,
5263 }))
5264 }
5265 }
5266 }
5267
5268 fn environment_materialization_profile(
5269 &self,
5270 environment_profile: &ResolvedEnvironmentProfileReceipt,
5271 ) -> Option<NodeSessionMaterializationProfile> {
5272 self.environment_materialization_profiles
5273 .read()
5274 .unwrap_or_else(|poisoned| poisoned.into_inner())
5275 .get(&environment_profile.profile_id)
5276 .cloned()
5277 }
5278
5279 fn materialization_profile(
5280 &self,
5281 provider: &AgentId,
5282 mode: SessionMode,
5283 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5284 bundle: Option<&ResolvedBundleReceipt>,
5285 context: Option<&ResolvedContextPackReceipt>,
5286 ) -> Result<Option<(
5287 NodeSessionMaterializationProfile,
5288 bool,
5289 Option<BundleProviderLayout>,
5290 )>, NodeFailure> {
5291 let environment = environment_profile
5292 .and_then(|receipt| self.environment_materialization_profile(receipt));
5293 let (bundle, bundle_layout) = match bundle {
5294 Some(receipt) => {
5295 let catalog = self.bundle_catalog.read()
5296 .unwrap_or_else(|poisoned| poisoned.into_inner());
5297 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5298 NodeFailureCode::UnknownBundle,
5299 "session bundle is unavailable on this node",
5300 ))?;
5301 if installed.receipt() != *receipt {
5302 return Err(failure(
5303 NodeFailureCode::BundleBindingMismatch,
5304 "session bundle revision or digest changed",
5305 ));
5306 }
5307 let layout = self.bundle_layout(
5308 provider,
5309 mode,
5310 environment_profile,
5311 installed,
5312 )?;
5313 (Some(installed.clone()), Some(layout))
5314 }
5315 None => (None, None),
5316 };
5317 let has_environment_overlay = environment.is_some() || context.is_some();
5318 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5319 (None, None, None) => None,
5320 (Some(profile), None, None) => Some(Ok(profile)),
5321 (None, Some(bundle), Some(layout)) => {
5322 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5323 }
5324 (Some(profile), Some(bundle), Some(layout)) => {
5325 Some(profile.with_bundle(bundle, layout))
5326 }
5327 _ => {
5328 return Err(failure(
5329 NodeFailureCode::BundleBindingMismatch,
5330 "session bundle provider layout is unavailable",
5331 ));
5332 }
5333 };
5334 let profile = profile
5335 .transpose()
5336 .map_err(|_| failure(
5337 NodeFailureCode::BundleMaterializationFailed,
5338 "session bundle materialization profile is invalid",
5339 ))?;
5340 let profile = match (profile, context) {
5341 (Some(profile), Some(receipt)) => {
5342 let catalog = self.context_catalog
5343 .read()
5344 .unwrap_or_else(|poisoned| poisoned.into_inner());
5345 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5346 NodeFailureCode::UnknownContextPack,
5347 "session context pack is unavailable on this node",
5348 ))?;
5349 if pack.receipt() != receipt {
5350 return Err(failure(
5351 NodeFailureCode::ContextPackMaterializationFailed,
5352 "session context pack identity changed",
5353 ));
5354 }
5355 profile.with_context(pack)
5356 }
5357 (None, Some(receipt)) => {
5358 let catalog = self.context_catalog
5359 .read()
5360 .unwrap_or_else(|poisoned| poisoned.into_inner());
5361 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5362 NodeFailureCode::UnknownContextPack,
5363 "session context pack is unavailable on this node",
5364 ))?;
5365 if pack.receipt() != receipt {
5366 return Err(failure(
5367 NodeFailureCode::ContextPackMaterializationFailed,
5368 "session context pack identity changed",
5369 ));
5370 }
5371 NodeSessionMaterializationProfile::from_context(pack)
5372 }
5373 (Some(profile), None) => Ok(profile),
5374 (None, None) => return Ok(None),
5375 }
5376 .map_err(|_| failure(
5377 NodeFailureCode::ContextPackMaterializationFailed,
5378 "session context pack materialization profile is invalid",
5379 ))?;
5380 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5381 }
5382
5383 fn prepare_session_materialization<'a>(
5384 &'a self,
5385 address: &SessionAddress,
5386 provider: &AgentId,
5387 mode: SessionMode,
5388 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5389 bundle: Option<&ResolvedBundleReceipt>,
5390 context: Option<&ResolvedContextPackReceipt>,
5391 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5392 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5393 let Some((profile, has_environment_overlay, bundle_layout)) =
5394 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5395 else {
5396 return Ok(None);
5397 };
5398 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5399 failure(
5400 NodeFailureCode::BackendOperationFailed,
5401 "session environment materializer is unavailable",
5402 )
5403 })?;
5404 let materialization_failure_code = if context.is_some() {
5405 NodeFailureCode::ContextPackMaterializationFailed
5406 } else if bundle.is_some() {
5407 NodeFailureCode::BundleMaterializationFailed
5408 } else {
5409 NodeFailureCode::BackendOperationFailed
5410 };
5411 let id = MaterializationId::new(format!(
5412 "mat-{}-{}-{}",
5413 self.incarnation_id,
5414 address.session.instance_id.0,
5415 address.session.generation.0,
5416 ))
5417 .map_err(|_| {
5418 failure(
5419 NodeFailureCode::BackendOperationFailed,
5420 "session environment materialization identity failed",
5421 )
5422 })?;
5423 let owner = MaterializationOwner::Session {
5424 incarnation_id: self.incarnation_id,
5425 instance_id: address.session.instance_id,
5426 generation: address.session.generation,
5427 };
5428 let mut ownership = materializer
5429 .begin(
5430 id.clone(),
5431 environment_profile.cloned(),
5432 bundle.cloned(),
5433 context.cloned(),
5434 owner,
5435 managed_lease_id,
5436 &profile,
5437 unix_time_ms(),
5438 )
5439 .map_err(|_| {
5440 failure(
5441 materialization_failure_code,
5442 "session materialization preparation failed",
5443 )
5444 })?;
5445 {
5446 let _transaction = self
5447 .state_transaction
5448 .lock()
5449 .unwrap_or_else(|poisoned| poisoned.into_inner());
5450 let duplicate = self
5451 .materializations
5452 .lock()
5453 .unwrap_or_else(|poisoned| poisoned.into_inner())
5454 .insert(id.clone(), ownership.clone());
5455 if duplicate.is_some() {
5456 return Err(failure(
5457 NodeFailureCode::BackendOperationFailed,
5458 "session environment materialization identity conflict",
5459 ));
5460 }
5461 if let Err(error) = self.persist_state_locked() {
5462 self.materializations
5463 .lock()
5464 .unwrap_or_else(|poisoned| poisoned.into_inner())
5465 .remove(&id);
5466 return Err(persistence_failure(error));
5467 }
5468 }
5469 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5470 Ok(environment) => environment,
5471 Err(_) => {
5472 let transaction = self
5473 .state_transaction
5474 .lock()
5475 .unwrap_or_else(|poisoned| poisoned.into_inner());
5476 self.materializations
5477 .lock()
5478 .unwrap_or_else(|poisoned| poisoned.into_inner())
5479 .insert(id.clone(), ownership.clone());
5480 if self.persist_state_locked().is_err() {
5481 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5482 }
5483 drop(transaction);
5484 if ownership.state() == MaterializationState::CleanupRequired {
5485 let _ = self.cleanup_materialization(&id);
5486 }
5487 return Err(failure(
5488 materialization_failure_code,
5489 "session materialization failed",
5490 ));
5491 }
5492 };
5493 {
5494 let _transaction = self
5495 .state_transaction
5496 .lock()
5497 .unwrap_or_else(|poisoned| poisoned.into_inner());
5498 self.materializations
5499 .lock()
5500 .unwrap_or_else(|poisoned| poisoned.into_inner())
5501 .insert(id.clone(), ownership.clone());
5502 if let Err(error) = self.persist_state_locked() {
5503 drop(_transaction);
5504 let _ = self.cleanup_materialization(&id);
5505 return Err(persistence_failure(error));
5506 }
5507 }
5508 let guard = SessionMaterializationGuard {
5509 shared: self,
5510 id: Some(id),
5511 provider_home: Some(ownership.provider_home().to_path_buf()),
5512 };
5513 let bundle_arguments = match bundle {
5514 Some(receipt) => {
5515 let catalog = self.bundle_catalog.read()
5516 .unwrap_or_else(|poisoned| poisoned.into_inner());
5517 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5518 NodeFailureCode::UnknownBundle,
5519 "session bundle is unavailable on this node",
5520 ))?;
5521 if installed.receipt() != *receipt {
5522 return Err(failure(
5523 NodeFailureCode::BundleBindingMismatch,
5524 "session bundle revision or digest changed",
5525 ));
5526 }
5527 let layout = bundle_layout.ok_or_else(|| failure(
5528 NodeFailureCode::BundleBindingMismatch,
5529 "materialized bundle provider layout is unavailable",
5530 ))?;
5531 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5532 failure(
5533 NodeFailureCode::BundleMaterializationFailed,
5534 "session bundle byte revalidation failed",
5535 )
5536 })?;
5537 Some(bundle_launch_arguments(
5538 layout,
5539 ownership.bundle_root(),
5540 ownership.provider_home(),
5541 ).map_err(|_| failure(
5542 NodeFailureCode::BundleBindingMismatch,
5543 "session bundle launch binding is invalid",
5544 ))?)
5545 }
5546 None => None,
5547 };
5548 if let Some(receipt) = context {
5549 materializer
5550 .revalidate_context(&ownership, receipt)
5551 .map_err(|_| failure(
5552 NodeFailureCode::ContextPackMaterializationFailed,
5553 "session context pack byte revalidation failed",
5554 ))?;
5555 }
5556 let transport = match mode {
5557 SessionMode::Pty => TransportKind::Pty,
5558 SessionMode::Inline => TransportKind::Pipe,
5559 SessionMode::Acp => TransportKind::Acp,
5560 };
5561 let overlay = if let Some(extra_args) = bundle_arguments {
5562 Some(PreparedNativeLaunchOverlay::Instance(
5563 NativeInstanceLaunchOverlay::new(
5564 provider.clone(),
5565 transport,
5566 if has_environment_overlay {
5567 environment
5568 } else {
5569 Vec::new()
5570 },
5571 extra_args,
5572 )
5573 .map_err(|_| {
5574 failure(
5575 NodeFailureCode::BundleBindingMismatch,
5576 "session bundle launch overlay is invalid",
5577 )
5578 })?,
5579 ))
5580 } else if has_environment_overlay {
5581 let context_only = environment_profile.is_none()
5582 && bundle.is_none()
5583 && context.is_some();
5584 let overlay = if context_only {
5585 NativeLaunchEnvironmentOverlay::new_context_root(
5586 provider.clone(),
5587 transport,
5588 environment,
5589 )
5590 .map_err(|_| {
5591 failure(
5592 NodeFailureCode::ContextPackMaterializationFailed,
5593 "context-only launch overlay is invalid",
5594 )
5595 })?
5596 } else {
5597 NativeLaunchEnvironmentOverlay::new(
5598 provider.clone(),
5599 transport,
5600 environment,
5601 )
5602 .map_err(|_| {
5603 failure(
5604 NodeFailureCode::BackendOperationFailed,
5605 "session environment overlay is invalid",
5606 )
5607 })?
5608 };
5609 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5610 } else {
5611 None
5612 };
5613 Ok(Some((overlay, guard)))
5614 }
5615
5616 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5617 let Some(mut ownership) = self
5618 .materializations
5619 .lock()
5620 .unwrap_or_else(|poisoned| poisoned.into_inner())
5621 .get(id)
5622 .cloned()
5623 else {
5624 return Ok(());
5625 };
5626 if ownership.state() != MaterializationState::CleanupRequired {
5627 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5628 failure(
5629 NodeFailureCode::BackendOperationFailed,
5630 "session environment cleanup state is invalid",
5631 )
5632 })?;
5633 }
5634 {
5635 let _transaction = self
5636 .state_transaction
5637 .lock()
5638 .unwrap_or_else(|poisoned| poisoned.into_inner());
5639 let previous = self
5640 .materializations
5641 .lock()
5642 .unwrap_or_else(|poisoned| poisoned.into_inner())
5643 .insert(id.clone(), ownership.clone());
5644 if let Err(error) = self.persist_state_locked() {
5645 let mut materializations = self
5646 .materializations
5647 .lock()
5648 .unwrap_or_else(|poisoned| poisoned.into_inner());
5649 if let Some(previous) = previous {
5650 materializations.insert(id.clone(), previous);
5651 }
5652 return Err(persistence_failure(error));
5653 }
5654 }
5655 let cleanup = self
5656 .session_environment_materializer
5657 .as_ref()
5658 .ok_or(())
5659 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5660 if cleanup.is_err() {
5661 let _ = ownership.mark_recovery_required(unix_time_ms());
5662 self.materializations
5663 .lock()
5664 .unwrap_or_else(|poisoned| poisoned.into_inner())
5665 .insert(id.clone(), ownership);
5666 if self.persist_state().is_err() {
5667 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5668 }
5669 return Err(failure(
5670 NodeFailureCode::BackendOperationFailed,
5671 "session environment cleanup requires recovery",
5672 ));
5673 }
5674 let _transaction = self
5675 .state_transaction
5676 .lock()
5677 .unwrap_or_else(|poisoned| poisoned.into_inner());
5678 self.materializations
5679 .lock()
5680 .unwrap_or_else(|poisoned| poisoned.into_inner())
5681 .remove(id);
5682 if let Err(error) = self.persist_state_locked() {
5683 self.materializations
5684 .lock()
5685 .unwrap_or_else(|poisoned| poisoned.into_inner())
5686 .insert(id.clone(), ownership);
5687 return Err(persistence_failure(error));
5688 }
5689 Ok(())
5690 }
5691
5692 fn resolve_record_materialization(
5693 &self,
5694 record_id: &SessionRecordId,
5695 provider: &AgentId,
5696 mode: SessionMode,
5697 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5698 bundle: Option<&ResolvedBundleReceipt>,
5699 context: Option<&ResolvedContextPackReceipt>,
5700 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5701 let ownership = {
5702 let materializations = self
5703 .materializations
5704 .lock()
5705 .unwrap_or_else(|poisoned| poisoned.into_inner());
5706 let mut matches = materializations.values().filter(|record| {
5707 record.owner()
5708 == &MaterializationOwner::Record {
5709 record_id: record_id.clone(),
5710 }
5711 });
5712 let ownership = matches.next().cloned();
5713 if matches.next().is_some() {
5714 return Err(failure(
5715 NodeFailureCode::BackendOperationFailed,
5716 "managed session has conflicting environment ownership",
5717 ));
5718 }
5719 ownership
5720 };
5721 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5722 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5723 failure(
5724 NodeFailureCode::ContextPackMaterializationFailed,
5725 "session context materializer is unavailable",
5726 )
5727 })?;
5728 let pack = materializer
5729 .revalidate_context(ownership, receipt)
5730 .map_err(|_| {
5731 self.mark_materialization_recovery_required(ownership.id());
5732 failure(
5733 NodeFailureCode::ContextPackMaterializationFailed,
5734 "managed session context pack revalidation failed",
5735 )
5736 })?;
5737 self.context_catalog
5738 .write()
5739 .unwrap_or_else(|poisoned| poisoned.into_inner())
5740 .insert(pack)
5741 .map_err(|_| failure(
5742 NodeFailureCode::ContextPackMaterializationFailed,
5743 "managed session context pack catalog restore failed",
5744 ))?;
5745 }
5746 let materialization = self.materialization_profile(
5747 provider,
5748 mode,
5749 environment_profile,
5750 bundle,
5751 context,
5752 )?;
5753 match (ownership, materialization) {
5754 (None, None) => Ok(None),
5755 (None, Some(_)) | (Some(_), None) => Err(failure(
5756 NodeFailureCode::BackendOperationFailed,
5757 "managed session materialization is unavailable",
5758 )),
5759 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5760 if ownership.environment_profile() != environment_profile
5761 || ownership.bundle() != bundle
5762 || ownership.context() != context
5763 || ownership.state() != MaterializationState::Ready
5764 {
5765 self.mark_materialization_recovery_required(ownership.id());
5766 return Err(failure(
5767 NodeFailureCode::BackendOperationFailed,
5768 "managed session environment ownership requires recovery",
5769 ));
5770 }
5771 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5772 failure(
5773 NodeFailureCode::BackendOperationFailed,
5774 "session environment materializer is unavailable",
5775 )
5776 })?;
5777 if materializer.revalidate(&ownership).is_err() {
5778 self.mark_materialization_recovery_required(ownership.id());
5779 return Err(failure(
5780 NodeFailureCode::BackendOperationFailed,
5781 "managed session environment revalidation failed",
5782 ));
5783 }
5784 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5785 let catalog = self.bundle_catalog.read()
5786 .unwrap_or_else(|poisoned| poisoned.into_inner());
5787 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5788 return Err(failure(
5789 NodeFailureCode::UnknownBundle,
5790 "managed session bundle is unavailable",
5791 ));
5792 };
5793 let layout = bundle_layout.ok_or_else(|| failure(
5794 NodeFailureCode::BundleBindingMismatch,
5795 "record bundle provider layout is unavailable",
5796 ))?;
5797 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5798 drop(catalog);
5799 self.mark_materialization_recovery_required(ownership.id());
5800 return Err(failure(
5801 NodeFailureCode::BundleMaterializationFailed,
5802 "managed session bundle revalidation failed",
5803 ));
5804 }
5805 Some(bundle_launch_arguments(
5806 layout,
5807 ownership.bundle_root(),
5808 ownership.provider_home(),
5809 ).map_err(|_| failure(
5810 NodeFailureCode::BundleBindingMismatch,
5811 "managed session bundle launch binding is invalid",
5812 ))?)
5813 } else {
5814 None
5815 };
5816 if !has_environment_materialization {
5817 let overlay = bundle_arguments.map(|extra_args| {
5818 NativeInstanceLaunchOverlay::new(
5819 provider.clone(),
5820 TransportKind::Pty,
5821 Vec::new(),
5822 extra_args,
5823 )
5824 .map(PreparedNativeLaunchOverlay::Instance)
5825 .map_err(|_| failure(
5826 NodeFailureCode::BundleBindingMismatch,
5827 "managed session bundle launch overlay is invalid",
5828 ))
5829 }).transpose()?;
5830 return Ok(Some((ownership.id().clone(), overlay)));
5831 }
5832 let environment = match materializer.resolve_environment(&ownership, &profile) {
5833 Ok(environment) => environment,
5834 Err(
5835 SessionEnvironmentMaterializeError::SecretUnavailable
5836 | SessionEnvironmentMaterializeError::SecretDenied
5837 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5838 ) => {
5839 return Err(failure(
5840 NodeFailureCode::BackendOperationFailed,
5841 "managed session environment resolution failed",
5842 ));
5843 }
5844 Err(
5845 SessionEnvironmentMaterializeError::InvalidRoot
5846 | SessionEnvironmentMaterializeError::OwnershipMismatch
5847 | SessionEnvironmentMaterializeError::Filesystem(_)
5848 | SessionEnvironmentMaterializeError::Record(_),
5849 ) => {
5850 self.mark_materialization_recovery_required(ownership.id());
5851 return Err(failure(
5852 NodeFailureCode::BackendOperationFailed,
5853 "managed session environment resolution failed",
5854 ));
5855 }
5856 };
5857 let transport = match mode {
5858 SessionMode::Pty => TransportKind::Pty,
5859 SessionMode::Inline => TransportKind::Pipe,
5860 SessionMode::Acp => TransportKind::Acp,
5861 };
5862 let overlay = match bundle_arguments {
5863 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5864 NativeInstanceLaunchOverlay::new(
5865 provider.clone(),
5866 transport,
5867 environment,
5868 extra_args,
5869 )
5870 .map_err(|_| failure(
5871 NodeFailureCode::BundleBindingMismatch,
5872 "managed session bundle launch overlay is invalid",
5873 ))?,
5874 ),
5875 None => PreparedNativeLaunchOverlay::Environment(
5876 NativeLaunchEnvironmentOverlay::new(
5877 provider.clone(),
5878 transport,
5879 environment,
5880 )
5881 .map_err(|_| {
5882 failure(
5883 NodeFailureCode::BackendOperationFailed,
5884 "managed session environment overlay is invalid",
5885 )
5886 })?,
5887 ),
5888 };
5889 Ok(Some((ownership.id().clone(), Some(overlay))))
5890 }
5891 }
5892 }
5893
5894 fn destroy_record_materialization(
5895 &self,
5896 record_id: &SessionRecordId,
5897 ) -> Result<bool, NodeFailure> {
5898 let ids = self
5899 .materializations
5900 .lock()
5901 .unwrap_or_else(|poisoned| poisoned.into_inner())
5902 .values()
5903 .filter_map(|record| {
5904 (record.owner()
5905 == &MaterializationOwner::Record {
5906 record_id: record_id.clone(),
5907 })
5908 .then(|| record.id().clone())
5909 })
5910 .collect::<Vec<_>>();
5911 if ids.len() > 1 {
5912 return Err(failure(
5913 NodeFailureCode::BackendOperationFailed,
5914 "managed session has conflicting environment ownership",
5915 ));
5916 }
5917 let Some(id) = ids.first().cloned() else {
5918 return Ok(false);
5919 };
5920 let (terminal_record, mut cleanup_ownership) = {
5921 let _transaction = self
5922 .state_transaction
5923 .lock()
5924 .unwrap_or_else(|poisoned| poisoned.into_inner());
5925 let original_record = self.record(record_id)?;
5926 let mut terminal_record = original_record.clone();
5927 terminal_record.provider_session = None;
5928 terminal_record.active_session = None;
5929 terminal_record.state = ManagedSessionState::Unavailable;
5930 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5931 terminal_record.updated_at_unix_ms = unix_time_ms();
5932 let original_ownership = self
5933 .materializations
5934 .lock()
5935 .unwrap_or_else(|poisoned| poisoned.into_inner())
5936 .get(&id)
5937 .cloned()
5938 .ok_or_else(|| {
5939 failure(
5940 NodeFailureCode::BackendOperationFailed,
5941 "managed session environment ownership disappeared during cleanup",
5942 )
5943 })?;
5944 let mut cleanup_ownership = original_ownership.clone();
5945 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5946 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5947 failure(
5948 NodeFailureCode::BackendOperationFailed,
5949 "managed session environment cleanup state is invalid",
5950 )
5951 })?;
5952 }
5953 self.session_records
5954 .lock()
5955 .unwrap_or_else(|poisoned| poisoned.into_inner())
5956 .records
5957 .insert(record_id.clone(), terminal_record.clone());
5958 self.materializations
5959 .lock()
5960 .unwrap_or_else(|poisoned| poisoned.into_inner())
5961 .insert(id.clone(), cleanup_ownership.clone());
5962 if let Err(error) = self.persist_state_locked() {
5963 self.session_records
5964 .lock()
5965 .unwrap_or_else(|poisoned| poisoned.into_inner())
5966 .records
5967 .insert(record_id.clone(), original_record);
5968 self.materializations
5969 .lock()
5970 .unwrap_or_else(|poisoned| poisoned.into_inner())
5971 .insert(id.clone(), original_ownership);
5972 return Err(persistence_failure(error));
5973 }
5974 (terminal_record, cleanup_ownership)
5975 };
5976 let cleanup = self
5977 .session_environment_materializer
5978 .as_ref()
5979 .ok_or(())
5980 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5981 if cleanup.is_err() {
5982 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5983 self.materializations
5984 .lock()
5985 .unwrap_or_else(|poisoned| poisoned.into_inner())
5986 .insert(id.clone(), cleanup_ownership);
5987 if self.persist_state().is_err() {
5988 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5989 }
5990 return Err(failure(
5991 NodeFailureCode::BackendOperationFailed,
5992 "managed session environment cleanup requires recovery",
5993 ));
5994 }
5995 let _transaction = self
5996 .state_transaction
5997 .lock()
5998 .unwrap_or_else(|poisoned| poisoned.into_inner());
5999 self.remove_record_memory(record_id);
6000 self.materializations
6001 .lock()
6002 .unwrap_or_else(|poisoned| poisoned.into_inner())
6003 .remove(&id);
6004 if let Err(error) = self.persist_state_locked() {
6005 self.session_records
6006 .lock()
6007 .unwrap_or_else(|poisoned| poisoned.into_inner())
6008 .records
6009 .insert(record_id.clone(), terminal_record);
6010 self.materializations
6011 .lock()
6012 .unwrap_or_else(|poisoned| poisoned.into_inner())
6013 .insert(id, cleanup_ownership);
6014 return Err(persistence_failure(error));
6015 }
6016 Ok(true)
6017 }
6018
6019 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6020 let changed = {
6021 let mut materializations = self
6022 .materializations
6023 .lock()
6024 .unwrap_or_else(|poisoned| poisoned.into_inner());
6025 let Some(ownership) = materializations.get_mut(id) else {
6026 return;
6027 };
6028 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6029 };
6030 if changed && self.persist_state().is_err() {
6031 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6032 }
6033 }
6034
6035 fn reconcile_materializations(&self) {
6036 let records = self
6037 .materializations
6038 .lock()
6039 .unwrap_or_else(|poisoned| poisoned.into_inner())
6040 .values()
6041 .cloned()
6042 .collect::<Vec<_>>();
6043 for mut ownership in records {
6044 let result = match (ownership.state(), ownership.owner()) {
6045 (MaterializationState::Preparing, _)
6046 | (MaterializationState::CleanupRequired, _) => {
6047 self.cleanup_materialization(ownership.id())
6048 }
6049 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6050 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6051 self.materializations
6052 .lock()
6053 .unwrap_or_else(|poisoned| poisoned.into_inner())
6054 .insert(ownership.id().clone(), ownership.clone());
6055 self.persist_state().map_err(persistence_failure)
6056 } else {
6057 Err(failure(
6058 NodeFailureCode::BackendOperationFailed,
6059 "session environment reconciliation failed",
6060 ))
6061 }
6062 }
6063 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6064 .session_environment_materializer
6065 .as_ref()
6066 .ok_or_else(|| {
6067 failure(
6068 NodeFailureCode::BackendOperationFailed,
6069 "session environment materializer is unavailable",
6070 )
6071 })
6072 .and_then(|materializer| {
6073 materializer.revalidate(&ownership).map_err(|_| failure(
6074 NodeFailureCode::BackendOperationFailed,
6075 "session materialization revalidation failed",
6076 ))?;
6077 let record = self.record(record_id)?;
6078 if record.environment_profile.as_ref() != ownership.environment_profile()
6079 || record.bundle.as_ref() != ownership.bundle()
6080 || record.context.as_ref() != ownership.context()
6081 {
6082 return Err(failure(
6083 NodeFailureCode::BackendOperationFailed,
6084 "session materialization ownership changed after restart",
6085 ));
6086 }
6087 if let Some(receipt) = ownership.bundle() {
6088 let catalog = self.bundle_catalog.read()
6089 .unwrap_or_else(|poisoned| poisoned.into_inner());
6090 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6091 NodeFailureCode::UnknownBundle,
6092 "session bundle is unavailable after restart",
6093 ))?;
6094 if bundle.receipt() != *receipt {
6095 return Err(failure(
6096 NodeFailureCode::BundleBindingMismatch,
6097 "session bundle changed after restart",
6098 ));
6099 }
6100 let layout = self.bundle_layout(
6101 &record.provider,
6102 record.mode,
6103 record.environment_profile.as_ref(),
6104 bundle,
6105 )?;
6106 materializer
6107 .revalidate_bundle(&ownership, bundle, layout)
6108 .map_err(|_| failure(
6109 NodeFailureCode::BundleMaterializationFailed,
6110 "session bundle revalidation failed",
6111 ))?;
6112 }
6113 if let Some(receipt) = ownership.context() {
6114 let pack = materializer
6115 .revalidate_context(&ownership, receipt)
6116 .map_err(|_| {
6117 failure(
6118 NodeFailureCode::ContextPackMaterializationFailed,
6119 "session context revalidation failed",
6120 )
6121 })?;
6122 self.context_catalog
6123 .write()
6124 .unwrap_or_else(|poisoned| poisoned.into_inner())
6125 .insert(pack)
6126 .map_err(|_| {
6127 failure(
6128 NodeFailureCode::ContextPackMaterializationFailed,
6129 "session context catalog restore failed",
6130 )
6131 })?;
6132 }
6133 Ok(())
6134 }),
6135 (MaterializationState::RecoveryRequired, _) => Ok(()),
6136 };
6137 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6138 let _ = ownership.mark_recovery_required(unix_time_ms());
6139 self.materializations
6140 .lock()
6141 .unwrap_or_else(|poisoned| poisoned.into_inner())
6142 .insert(ownership.id().clone(), ownership);
6143 if self.persist_state().is_err() {
6144 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6145 }
6146 }
6147 }
6148 let materialized_profiles = self
6149 .environment_materialization_profiles
6150 .read()
6151 .unwrap_or_else(|poisoned| poisoned.into_inner())
6152 .keys()
6153 .cloned()
6154 .collect::<Vec<_>>();
6155 let ownership = self
6156 .materializations
6157 .lock()
6158 .unwrap_or_else(|poisoned| poisoned.into_inner())
6159 .values()
6160 .cloned()
6161 .collect::<Vec<_>>();
6162 let changed = {
6163 let mut session_records = self
6164 .session_records
6165 .lock()
6166 .unwrap_or_else(|poisoned| poisoned.into_inner());
6167 let mut changed = false;
6168 for record in session_records.records.values_mut() {
6169 let environment_profile = record.environment_profile.as_ref();
6170 if environment_profile.is_none()
6171 && record.bundle.is_none()
6172 && record.context.is_none()
6173 {
6174 continue;
6175 }
6176 if record.bundle.is_none()
6177 && environment_profile.is_some_and(|profile| {
6178 !materialized_profiles.contains(&profile.profile_id)
6179 })
6180 {
6181 continue;
6182 }
6183 let has_exact_ownership = ownership.iter().any(|candidate| {
6184 candidate.state() == MaterializationState::Ready
6185 && candidate.environment_profile() == environment_profile
6186 && candidate.bundle() == record.bundle.as_ref()
6187 && candidate.context() == record.context.as_ref()
6188 && candidate.owner()
6189 == &MaterializationOwner::Record {
6190 record_id: record.record_id.clone(),
6191 }
6192 });
6193 if !has_exact_ownership {
6194 record.provider_session = None;
6195 record.active_session = None;
6196 record.state = ManagedSessionState::Unavailable;
6197 record.last_error = Some(if record.bundle.is_some() {
6198 "bundle-unavailable".to_owned()
6199 } else if record.context.is_some() {
6200 "context-unavailable".to_owned()
6201 } else {
6202 "environment-profile-unavailable".to_owned()
6203 });
6204 record.updated_at_unix_ms = unix_time_ms();
6205 changed = true;
6206 }
6207 }
6208 changed
6209 };
6210 if changed && self.persist_state().is_err() {
6211 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6212 }
6213 }
6214
6215 fn replay_spawn_spec(
6216 &self,
6217 spec: &SpawnSpec,
6218 now: Instant,
6219 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6220 let mut cache = self
6221 .spawn_idempotency
6222 .lock()
6223 .unwrap_or_else(|poisoned| poisoned.into_inner());
6224 cache.entries.retain(|_, entry| entry.expires_at > now);
6225 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6226 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6227 return Err(failure(
6228 NodeFailureCode::SpawnIdempotencyConflict,
6229 "spawn idempotency key was reused across spawn kinds",
6230 ));
6231 };
6232 if existing != spec {
6233 return Err(failure(
6234 NodeFailureCode::SpawnIdempotencyConflict,
6235 "spawn idempotency key was reused with a different specification",
6236 ));
6237 }
6238 return Ok(Some(result.clone()));
6239 }
6240 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6241 return Err(failure(
6242 NodeFailureCode::SpawnIdempotencyCapacity,
6243 "spawn idempotency cache reached its bounded capacity",
6244 ));
6245 }
6246 Ok(None)
6247 }
6248
6249 fn remember_spawn_spec(
6250 &self,
6251 spec: SpawnSpec,
6252 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6253 accepted_at: Instant,
6254 ) {
6255 self
6256 .spawn_idempotency
6257 .lock()
6258 .unwrap_or_else(|poisoned| poisoned.into_inner())
6259 .entries
6260 .insert(
6261 spec.idempotency_key.clone(),
6262 SpawnIdempotencyEntry {
6263 value: SpawnIdempotencyValue::Standard { spec, result },
6264 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6265 },
6266 );
6267 }
6268
6269 fn claim_harness_mcp_spawn(
6270 &self,
6271 _reservation_id: &HarnessMcpReservationId,
6272 _activation_digest: &HarnessMcpActivationDigest,
6273 spec: &SpawnSpec,
6274 now: Instant,
6275 ) -> Result<(), NodeFailure> {
6276 let mut cache = self.spawn_idempotency.lock()
6277 .unwrap_or_else(|poisoned| poisoned.into_inner());
6278 cache.entries.retain(|_, entry| entry.expires_at > now);
6279 if cache.entries.contains_key(&spec.idempotency_key) {
6280 return Err(failure(
6281 NodeFailureCode::SpawnIdempotencyConflict,
6282 "spawn idempotency key was reused across spawn kinds",
6283 ));
6284 }
6285 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6286 return Err(failure(
6287 NodeFailureCode::SpawnIdempotencyCapacity,
6288 "spawn idempotency cache reached its bounded capacity",
6289 ));
6290 }
6291 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6292 value: SpawnIdempotencyValue::HarnessMcp,
6293 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6294 });
6295 Ok(())
6296 }
6297
6298 fn replay_managed_spawn(
6299 &self,
6300 request: &ManagedWorktreeSpawnRequest,
6301 now: Instant,
6302 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6303 let mut cache = self.spawn_idempotency.lock()
6304 .unwrap_or_else(|poisoned| poisoned.into_inner());
6305 cache.entries.retain(|_, entry| entry.expires_at > now);
6306 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6307 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6308 return Err(failure(
6309 NodeFailureCode::SpawnIdempotencyConflict,
6310 "spawn idempotency key was reused across spawn kinds",
6311 ));
6312 };
6313 if existing != request {
6314 return Err(failure(
6315 NodeFailureCode::SpawnIdempotencyConflict,
6316 "spawn idempotency key was reused with a different managed specification",
6317 ));
6318 }
6319 return Ok(Some(result.clone()));
6320 }
6321 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6322 return Err(failure(
6323 NodeFailureCode::SpawnIdempotencyCapacity,
6324 "spawn idempotency cache reached its bounded capacity",
6325 ));
6326 }
6327 Ok(None)
6328 }
6329
6330 fn remember_managed_spawn(
6331 &self,
6332 request: ManagedWorktreeSpawnRequest,
6333 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6334 accepted_at: Instant,
6335 ) {
6336 self.spawn_idempotency.lock()
6337 .unwrap_or_else(|poisoned| poisoned.into_inner())
6338 .entries
6339 .insert(
6340 request.spawn_spec.idempotency_key.clone(),
6341 SpawnIdempotencyEntry {
6342 value: SpawnIdempotencyValue::Managed { request, result },
6343 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6344 },
6345 );
6346 }
6347
6348 fn remember_managed_spawn_attempt(
6349 &self,
6350 durable_v2_key: Option<&SpawnIdempotencyKey>,
6351 request: ManagedWorktreeSpawnRequest,
6352 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6353 accepted_at: Instant,
6354 ) {
6355 if durable_v2_key.is_none() {
6356 self.remember_managed_spawn(request, result, accepted_at);
6357 }
6358 }
6359
6360 fn acquire_durable_managed_spawn_v2(
6361 &self,
6362 request: &ManagedWorktreeSpawnRequestV2,
6363 request_digest: String,
6364 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6365 if self.state_path.is_none() {
6366 return Err(failure(
6367 NodeFailureCode::BackendOperationFailed,
6368 "managed worktree V2 requires durable node state",
6369 ));
6370 }
6371 let _transaction = self.state_transaction
6372 .lock()
6373 .unwrap_or_else(|poisoned| poisoned.into_inner());
6374 let key = request.spawn_spec.idempotency_key.clone();
6375 let mut previous = None;
6376 {
6377 let mut replays = self.managed_spawn_replays
6378 .lock()
6379 .unwrap_or_else(|poisoned| poisoned.into_inner());
6380 if let Some(record) = replays.get_mut(&key) {
6381 if record.request_digest != request_digest {
6382 return Err(failure(
6383 NodeFailureCode::SpawnIdempotencyConflict,
6384 "managed worktree V2 idempotency key was reused with a different request",
6385 ));
6386 }
6387 match &record.state {
6388 ManagedWorktreeSpawnReplayStateV10::Pending {
6389 owner_incarnation_id,
6390 lease_id: None,
6391 } if owner_incarnation_id != &self.incarnation_id => {
6392 previous = Some(record.clone());
6393 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6394 owner_incarnation_id: self.incarnation_id,
6395 lease_id: None,
6396 };
6397 record.updated_at_unix_ms = unix_time_ms();
6398 }
6399 ManagedWorktreeSpawnReplayStateV10::Pending {
6400 lease_id: None,
6401 ..
6402 } => {
6403 return Err(failure(
6404 NodeFailureCode::BackendBusy,
6405 "managed worktree V2 reservation is already active",
6406 ));
6407 }
6408 ManagedWorktreeSpawnReplayStateV10::Pending {
6409 lease_id: Some(_),
6410 ..
6411 } => {
6412 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6413 }
6414 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6415 return Ok(DurableManagedSpawnReplayDecision::Committed(
6416 receipt.clone(),
6417 ));
6418 }
6419 }
6420 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6421 return Err(failure(
6422 NodeFailureCode::SpawnIdempotencyCapacity,
6423 "durable managed worktree replay ledger reached its bounded capacity",
6424 ));
6425 } else {
6426 let now = unix_time_ms();
6427 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6428 idempotency_key: key.clone(),
6429 request_digest,
6430 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6431 profile_id: request.worktree_profile_id.clone(),
6432 expected_profile_revision: request.expected_profile_revision.clone(),
6433 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6434 owner_incarnation_id: self.incarnation_id,
6435 lease_id: None,
6436 },
6437 created_at_unix_ms: now,
6438 updated_at_unix_ms: now,
6439 });
6440 }
6441 }
6442 if let Err(error) = self.persist_state_locked() {
6443 let mut replays = self.managed_spawn_replays
6444 .lock()
6445 .unwrap_or_else(|poisoned| poisoned.into_inner());
6446 match previous {
6447 Some(previous) => {
6448 replays.insert(key, previous);
6449 }
6450 None => {
6451 replays.remove(&key);
6452 }
6453 }
6454 return Err(persistence_failure(error));
6455 }
6456 Ok(DurableManagedSpawnReplayDecision::Reserved)
6457 }
6458
6459 fn link_durable_managed_spawn_v2_lease_locked(
6460 &self,
6461 key: &SpawnIdempotencyKey,
6462 lease_id: &ManagedWorktreeLeaseId,
6463 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6464 let mut replays = self.managed_spawn_replays
6465 .lock()
6466 .unwrap_or_else(|poisoned| poisoned.into_inner());
6467 let record = replays.get_mut(key).ok_or_else(|| {
6468 failure(
6469 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6470 "managed worktree V2 reservation disappeared before allocation",
6471 )
6472 })?;
6473 let previous = record.clone();
6474 match &mut record.state {
6475 ManagedWorktreeSpawnReplayStateV10::Pending {
6476 lease_id: current,
6477 ..
6478 }
6479 if current.is_none() =>
6480 {
6481 *current = Some(lease_id.clone());
6482 record.updated_at_unix_ms = unix_time_ms();
6483 Ok(previous)
6484 }
6485 _ => Err(failure(
6486 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6487 "managed worktree V2 reservation is not allocation-ready",
6488 )),
6489 }
6490 }
6491
6492 fn restore_durable_managed_spawn_v2_locked(
6493 &self,
6494 key: &SpawnIdempotencyKey,
6495 previous: ManagedWorktreeSpawnReplayRecordV10,
6496 ) {
6497 self.managed_spawn_replays
6498 .lock()
6499 .unwrap_or_else(|poisoned| poisoned.into_inner())
6500 .insert(key.clone(), previous);
6501 }
6502
6503 fn commit_durable_managed_spawn_v2(
6504 &self,
6505 request: &ManagedWorktreeSpawnRequestV2,
6506 receipt: ManagedWorktreeSpawnReceipt,
6507 ) -> Result<(), NodeFailure> {
6508 let _transaction = self.state_transaction
6509 .lock()
6510 .unwrap_or_else(|poisoned| poisoned.into_inner());
6511 let key = &request.spawn_spec.idempotency_key;
6512 let previous = {
6513 let mut replays = self.managed_spawn_replays
6514 .lock()
6515 .unwrap_or_else(|poisoned| poisoned.into_inner());
6516 let record = replays.get_mut(key).ok_or_else(|| {
6517 failure(
6518 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6519 "managed worktree V2 reservation disappeared before commit",
6520 )
6521 })?;
6522 let previous = record.clone();
6523 match &record.state {
6524 ManagedWorktreeSpawnReplayStateV10::Pending {
6525 lease_id: Some(lease_id),
6526 ..
6527 } if lease_id == &receipt.lease.lease_id => {}
6528 _ => {
6529 return Err(failure(
6530 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6531 "managed worktree V2 reservation is not linked to the spawned lease",
6532 ));
6533 }
6534 }
6535 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6536 record.updated_at_unix_ms = unix_time_ms();
6537 previous
6538 };
6539 if let Err(error) = self.persist_state_locked() {
6540 self.restore_durable_managed_spawn_v2_locked(key, previous);
6541 return Err(persistence_failure(error));
6542 }
6543 Ok(())
6544 }
6545
6546 fn remove_unallocated_durable_managed_spawn_v2(
6547 &self,
6548 request: &ManagedWorktreeSpawnRequestV2,
6549 ) -> Result<(), NodeFailure> {
6550 let _transaction = self.state_transaction
6551 .lock()
6552 .unwrap_or_else(|poisoned| poisoned.into_inner());
6553 let key = &request.spawn_spec.idempotency_key;
6554 let removed = {
6555 let mut replays = self.managed_spawn_replays
6556 .lock()
6557 .unwrap_or_else(|poisoned| poisoned.into_inner());
6558 match replays.get(key).map(|record| &record.state) {
6559 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6560 lease_id: None,
6561 ..
6562 }) => {
6563 replays.remove(key)
6564 }
6565 _ => None,
6566 }
6567 };
6568 let Some(removed) = removed else {
6569 return Ok(());
6570 };
6571 if let Err(error) = self.persist_state_locked() {
6572 self.managed_spawn_replays
6573 .lock()
6574 .unwrap_or_else(|poisoned| poisoned.into_inner())
6575 .insert(key.clone(), removed);
6576 return Err(persistence_failure(error));
6577 }
6578 Ok(())
6579 }
6580
6581 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6582 if spec.target.node_id != self.node_id {
6583 return Err(failure(
6584 NodeFailureCode::SpawnTargetMismatch,
6585 "spawn target does not match this node",
6586 ));
6587 }
6588 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6589 failure(
6590 NodeFailureCode::UnknownSpawnProfile,
6591 "spawn profile is unavailable on this node",
6592 )
6593 })?;
6594 let resolved = spec.resolve(defaults).map_err(|error| match error {
6595 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6596 NodeFailureCode::SpawnProfileRevisionMismatch,
6597 "spawn profile revision does not match the loaded profile",
6598 ),
6599 _ => failure(
6600 NodeFailureCode::InvalidRequest,
6601 "spawn specification could not be resolved",
6602 ),
6603 })?;
6604 if resolved.target.worktree_id.is_some() {
6605 self.require_worktree_service(&resolved.target.workspace_id)?;
6606 }
6607 let environment_profile = self.resolve_environment_profile(&resolved)?;
6608 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6609 self.resolve_context(&resolved)?;
6610 Ok(resolved)
6611 }
6612
6613 async fn resolve_spawn_workspace(
6614 &self,
6615 resolved: &ResolvedSpawnSpec,
6616 deadline: Instant,
6617 ) -> Result<WorkspaceId, NodeFailure> {
6618 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6619 return Ok(resolved.target.workspace_id.clone());
6620 };
6621 if self.managed_worktrees.lock()
6622 .unwrap_or_else(|poisoned| poisoned.into_inner())
6623 .lease_for_workspace(worktree_id)
6624 .is_some()
6625 {
6626 return Err(failure(
6627 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6628 "managed worktree targets may only be spawned through their lease request",
6629 ));
6630 }
6631 self.require_worktree_service(&resolved.target.workspace_id)?;
6632 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6633 let selected_root = self.workspace_root(worktree_id)?;
6634 let remaining = spawn_deadline_remaining(deadline)?;
6635 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6636 .await
6637 .map_err(|_| {
6638 failure(
6639 NodeFailureCode::SpawnDeadlineExceeded,
6640 "Git worktree selection exceeded the spawn deadline",
6641 )
6642 })?
6643 .map_err(git_worktree_failure)?;
6644 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6645 Ok(worktree_id.clone())
6646 }
6647
6648 async fn spawn_from_spec(
6649 &self,
6650 spec: SpawnSpec,
6651 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6652 self.spawn_from_spec_with_proxy(spec, None).await
6653 }
6654
6655 async fn spawn_from_spec_with_proxy(
6656 &self,
6657 spec: SpawnSpec,
6658 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6659 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6660 let accepted_at = Instant::now();
6661 if harness_mcp.is_none() {
6662 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6663 return replayed;
6664 }
6665 }
6666 let resolved = self.resolve_spawn_spec(&spec)?;
6667 let environment_profile = self.resolve_environment_profile(&resolved)?;
6668 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6669 let context = self.resolve_context(&resolved)?;
6670 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6671 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6672 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6673 let spawn_workspace_id = match spawn_workspace_id {
6674 Ok(workspace_id) => workspace_id,
6675 Err(error) => {
6676 let result = Err(error);
6677 if harness_mcp.is_none() {
6678 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6679 }
6680 return result;
6681 }
6682 };
6683 if self.managed_worktrees.lock()
6684 .unwrap_or_else(|poisoned| poisoned.into_inner())
6685 .lease_for_workspace(&spawn_workspace_id)
6686 .is_some()
6687 {
6688 let result = Err(failure(
6689 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6690 "managed worktree targets may only be spawned through their lease request",
6691 ));
6692 if harness_mcp.is_none() {
6693 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6694 }
6695 return result;
6696 }
6697 let result = self
6698 .spawn_session_with_deadline(
6699 spawn_workspace_id,
6700 resolved.provider.clone(),
6701 resolved.mode,
6702 resolved.terminal_size,
6703 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6704 environment_profile.clone(),
6705 bundle.clone(),
6706 context.clone(),
6707 None,
6708 None,
6709 SpawnRecordPolicy::Always,
6710 Some(deadline),
6711 &required_capabilities,
6712 harness_mcp,
6713 resolved.approval_level,
6714 resolved.network_allowlist.clone(),
6715 resolved.browser_profile_id.clone(),
6716 )
6717 .await
6718 .map(|(session, _runtime_policy)| {
6719 resolved.receipt_with_materialization(
6720 self.incarnation_id,
6721 session,
6722 environment_profile,
6723 bundle,
6724 context,
6725 )
6726 });
6727 if harness_mcp.is_none() {
6728 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6729 }
6730 result
6731 }
6732
6733 async fn spawn_from_spec_with_harness_mcp(
6734 &self,
6735 reservation_id: HarnessMcpReservationId,
6736 activation_digest: HarnessMcpActivationDigest,
6737 spec: SpawnSpec,
6738 deadline_unix_ms: u64,
6739 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6740 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6741 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6742 })?;
6743 if let Some(receipt) = registry
6744 .replay_spawn(&reservation_id, &activation_digest, &spec)
6745 .map_err(harness_mcp_failure)?
6746 {
6747 return Ok(receipt);
6748 }
6749 let resolved = self.resolve_spawn_spec(&spec)?;
6750 let prepared = registry
6751 .prepare_spawn(
6752 &reservation_id,
6753 &activation_digest,
6754 &spec,
6755 deadline_unix_ms,
6756 resolved.provider.clone(),
6757 )
6758 .map_err(harness_mcp_failure)?;
6759 self.claim_harness_mcp_spawn(
6760 &reservation_id,
6761 &activation_digest,
6762 &spec,
6763 Instant::now(),
6764 )?;
6765 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6766 let mut receipt = match spawn {
6767 Ok(receipt) => receipt,
6768 Err(error) => {
6769 let _ = registry.abort(&reservation_id, &activation_digest);
6770 return Err(error);
6771 }
6772 };
6773 let record_id = self
6774 .session_bindings
6775 .lock()
6776 .unwrap_or_else(|poisoned| poisoned.into_inner())
6777 .get(&receipt.session.session.instance_id)
6778 .and_then(|binding| binding.record_id.clone());
6779 let record_id = match record_id {
6780 Some(record_id) => record_id,
6781 None => {
6782 let rollback = self.remove_session(&receipt.session).await;
6783 let _ = registry.abort(&reservation_id, &activation_digest);
6784 if let Err(rollback_error) = rollback {
6785 return Err(rollback_error);
6786 }
6787 return Err(failure(
6788 NodeFailureCode::BindingMismatch,
6789 "harness MCP spawn did not retain its exact managed record",
6790 ));
6791 }
6792 };
6793 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6794 reservation_id: reservation_id.clone(),
6795 activation_digest: activation_digest.clone(),
6796 });
6797 if let Err(error) = registry.mark_spawned(
6798 &prepared,
6799 receipt.session.clone(),
6800 record_id,
6801 receipt.clone(),
6802 ) {
6803 let rollback = self.remove_session(&receipt.session).await;
6804 let _ = registry.abort(&reservation_id, &activation_digest);
6805 if let Err(rollback_error) = rollback {
6806 return Err(rollback_error);
6807 }
6808 return Err(harness_mcp_failure(error));
6809 }
6810 Ok(receipt)
6811 }
6812
6813 async fn spawn_managed_worktree(
6814 &self,
6815 request: ManagedWorktreeSpawnRequest,
6816 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6817 self.spawn_managed_worktree_inner(request, None).await
6818 }
6819
6820 async fn spawn_managed_worktree_inner(
6821 &self,
6822 request: ManagedWorktreeSpawnRequest,
6823 durable_v2_key: Option<&SpawnIdempotencyKey>,
6824 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6825 let accepted_at = Instant::now();
6826 if durable_v2_key.is_none() {
6827 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6828 return replayed;
6829 }
6830 }
6831 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6832 if request.spawn_spec.target.worktree_id.is_some() {
6833 let result = Err(failure(
6834 NodeFailureCode::InvalidRequest,
6835 "managed worktree spawn must not select a caller-provided worktree",
6836 ));
6837 self.remember_managed_spawn_attempt(
6838 durable_v2_key,
6839 request,
6840 result.clone(),
6841 accepted_at,
6842 );
6843 return result;
6844 }
6845 let environment_profile = self.resolve_environment_profile(&resolved)?;
6846 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6847 let context = self.resolve_context(&resolved)?;
6848 let source_workspace_id = resolved.target.workspace_id.clone();
6849 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6850 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6851 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6852 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6853 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6854 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6855 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6856 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6857 };
6858 let runtime_policy = timeout(
6859 spawn_deadline_remaining(deadline)?,
6860 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6861 ).await.map_err(|_| failure(
6862 NodeFailureCode::SpawnDeadlineExceeded,
6863 "provider admission exceeded the managed spawn deadline",
6864 ))??;
6865 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6866 return Err(failure(
6867 NodeFailureCode::UnsupportedSpawnCapability,
6868 "provider runtime does not admit a required spawn capability",
6869 ));
6870 }
6871 self.ensure_binding_capacity()?;
6872 let source_root = self.workspace_root(&source_workspace_id)?;
6873 profile.validate_for_workspace(&source_root)
6874 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6875 let base_timeout_ms = u64::try_from(
6876 spawn_deadline_remaining(deadline)?.as_millis(),
6877 ).unwrap_or(u64::MAX).max(1);
6878 let base_commit = resolve_base_commit_with_timeout(
6879 &source_root,
6880 profile.base(),
6881 base_timeout_ms,
6882 ).await.map_err(|error| {
6883 if Instant::now() >= deadline || error.message.contains("timed out") {
6884 failure(
6885 NodeFailureCode::SpawnDeadlineExceeded,
6886 "managed worktree base resolution exceeded the spawn deadline",
6887 )
6888 } else {
6889 managed_git_worktree_failure(error)
6890 }
6891 })?;
6892
6893 let lease = {
6894 let _transaction = self.state_transaction.lock()
6895 .unwrap_or_else(|poisoned| poisoned.into_inner());
6896 let previous = self.managed_worktrees.lock()
6897 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6898 let lease = self.managed_worktrees.lock()
6899 .unwrap_or_else(|poisoned| poisoned.into_inner())
6900 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6901 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6902 let previous_replay = match durable_v2_key
6903 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6904 .transpose()
6905 {
6906 Ok(previous) => previous,
6907 Err(error) => {
6908 *self.managed_worktrees.lock()
6909 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6910 return Err(error);
6911 }
6912 };
6913 if let Err(error) = self.persist_state_locked() {
6914 *self.managed_worktrees.lock()
6915 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6916 if let (Some(key), Some(previous_replay)) =
6917 (durable_v2_key, previous_replay)
6918 {
6919 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6920 }
6921 return Err(persistence_failure(error));
6922 }
6923 lease
6924 };
6925 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6926 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6927 self.managed_worktrees.lock()
6928 .unwrap_or_else(|poisoned| poisoned.into_inner())
6929 .remove_unmutated(&lease.lease_id);
6930 if self.persist_state().is_err() {
6931 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6932 }
6933 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6934 self.remember_managed_spawn_attempt(
6935 durable_v2_key,
6936 request,
6937 result.clone(),
6938 accepted_at,
6939 );
6940 return result;
6941 }
6942 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6943 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6944 Err(error) => {
6945 self.reconcile_failed_allocation(&lease, &source_root).await;
6946 let result = Err(error);
6947 self.remember_managed_spawn_attempt(
6948 durable_v2_key,
6949 request,
6950 result.clone(),
6951 accepted_at,
6952 );
6953 return result;
6954 }
6955 };
6956 let created = match create_git_worktree_with_timeout(
6957 &source_root,
6958 &lease.target_root,
6959 &lease.branch,
6960 Some(&base_commit),
6961 mutation_timeout_ms,
6962 ).await {
6963 Ok(created) => created,
6964 Err(error) => {
6965 self.reconcile_failed_allocation(&lease, &source_root).await;
6966 let result = Err(if Instant::now() >= deadline
6967 || error.message.contains("timed out")
6968 || error.message.contains("deadline")
6969 {
6970 failure(
6971 NodeFailureCode::SpawnDeadlineExceeded,
6972 "managed worktree creation exceeded the spawn deadline",
6973 )
6974 } else {
6975 managed_git_worktree_failure(error)
6976 });
6977 self.remember_managed_spawn_attempt(
6978 durable_v2_key,
6979 request,
6980 result.clone(),
6981 accepted_at,
6982 );
6983 return result;
6984 }
6985 };
6986 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6987 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6988 let result = Err(failure(
6989 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6990 "created worktree identity did not match its durable lease",
6991 ));
6992 self.remember_managed_spawn_attempt(
6993 durable_v2_key,
6994 request,
6995 result.clone(),
6996 accepted_at,
6997 );
6998 return result;
6999 }
7000 {
7001 let mut registry = self.managed_worktrees.lock()
7002 .unwrap_or_else(|poisoned| poisoned.into_inner());
7003 let current = registry.get_mut(&lease.lease_id)
7004 .expect("allocated managed worktree lease remains present");
7005 current.expected_head = Some(created.head.clone());
7006 current.state = ManagedWorktreeLeaseState::Ready;
7007 current.updated_at_unix_ms = unix_time_ms();
7008 }
7009 if let Err(error) = self.persist_state() {
7010 self.mark_managed_recovery_required(
7011 &lease.lease_id,
7012 ManagedWorktreeCleanupFailure::Backend,
7013 );
7014 let result = Err(persistence_failure(error));
7015 self.remember_managed_spawn_attempt(
7016 durable_v2_key,
7017 request,
7018 result.clone(),
7019 accepted_at,
7020 );
7021 return result;
7022 }
7023 let ready = self.managed_worktrees.lock()
7024 .unwrap_or_else(|poisoned| poisoned.into_inner())
7025 .get(&lease.lease_id)
7026 .expect("ready managed worktree lease remains present")
7027 .snapshot();
7028 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7029 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7030 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7031 let result = Err(error);
7032 self.remember_managed_spawn_attempt(
7033 durable_v2_key,
7034 request,
7035 result.clone(),
7036 accepted_at,
7037 );
7038 return result;
7039 }
7040 let spawn = self.spawn_session_with_deadline(
7041 lease.workspace_id.clone(),
7042 resolved.provider.clone(),
7043 resolved.mode,
7044 resolved.terminal_size,
7045 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7046 environment_profile.clone(),
7047 bundle.clone(),
7048 context.clone(),
7049 Some(lease.lease_id.clone()),
7050 Some(runtime_policy),
7051 SpawnRecordPolicy::Always,
7052 Some(deadline),
7053 &required_capabilities,
7054 None,
7055 resolved.approval_level,
7056 resolved.network_allowlist.clone(),
7057 resolved.browser_profile_id.clone(),
7058 ).await;
7059 let (session, effective_runtime_policy) = match spawn {
7060 Ok(spawned) => spawned,
7061 Err(error) => {
7062 if self
7063 .cleanup_managed_worktree(&lease.lease_id, true)
7064 .await
7065 .is_err()
7066 {
7067 self.mark_managed_recovery_required(
7068 &lease.lease_id,
7069 ManagedWorktreeCleanupFailure::Backend,
7070 );
7071 }
7072 let result = Err(error);
7073 self.remember_managed_spawn_attempt(
7074 durable_v2_key,
7075 request,
7076 result.clone(),
7077 accepted_at,
7078 );
7079 return result;
7080 }
7081 };
7082 let (record_id, raw_inventory_record_id) = {
7083 let mut bindings = self.session_bindings.lock()
7084 .unwrap_or_else(|poisoned| poisoned.into_inner());
7085 let binding = bindings.get_mut(&session.session.instance_id)
7086 .expect("successful spawn retains its session binding");
7087 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7088 managed_spawn_record_ownership(binding, effective_runtime_policy)
7089 };
7090 let bound = self.managed_worktrees.lock()
7091 .unwrap_or_else(|poisoned| poisoned.into_inner())
7092 .bind_session(
7093 &lease.lease_id,
7094 ManagedWorktreeSessionHolder {
7095 incarnation_id: self.incarnation_id,
7096 instance_id: session.session.instance_id,
7097 generation: session.session.generation,
7098 },
7099 record_id,
7100 unix_time_ms(),
7101 )
7102 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7103 let snapshot = match bound {
7104 Ok(snapshot) => snapshot,
7105 Err(error) => {
7106 if raw_inventory_record_id
7107 .as_ref()
7108 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7109 {
7110 self.mark_managed_recovery_required(
7111 &lease.lease_id,
7112 ManagedWorktreeCleanupFailure::Backend,
7113 );
7114 }
7115 let _ = self.remove_session(&session).await;
7116 self.mark_managed_recovery_required(
7117 &lease.lease_id,
7118 ManagedWorktreeCleanupFailure::Backend,
7119 );
7120 let result = Err(error);
7121 self.remember_managed_spawn_attempt(
7122 durable_v2_key,
7123 request,
7124 result.clone(),
7125 accepted_at,
7126 );
7127 return result;
7128 }
7129 };
7130 if let Err(error) = self.persist_state() {
7131 if raw_inventory_record_id
7132 .as_ref()
7133 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7134 {
7135 self.mark_managed_recovery_required(
7136 &lease.lease_id,
7137 ManagedWorktreeCleanupFailure::Backend,
7138 );
7139 }
7140 let _ = self.remove_session(&session).await;
7141 if self
7142 .cleanup_managed_worktree(&lease.lease_id, true)
7143 .await
7144 .is_err()
7145 {
7146 self.mark_managed_recovery_required(
7147 &lease.lease_id,
7148 ManagedWorktreeCleanupFailure::Backend,
7149 );
7150 }
7151 let result = Err(persistence_failure(error));
7152 self.remember_managed_spawn_attempt(
7153 durable_v2_key,
7154 request,
7155 result.clone(),
7156 accepted_at,
7157 );
7158 return result;
7159 }
7160 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7161 let receipt = managed_spawn_receipt(
7162 &resolved,
7163 self.incarnation_id,
7164 session,
7165 snapshot,
7166 environment_profile,
7167 bundle,
7168 context,
7169 );
7170 let result = Ok(receipt);
7171 self.remember_managed_spawn_attempt(
7172 durable_v2_key,
7173 request,
7174 result.clone(),
7175 accepted_at,
7176 );
7177 result
7178 }
7179
7180 async fn spawn_managed_worktree_v2(
7181 &self,
7182 request: ManagedWorktreeSpawnRequestV2,
7183 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7184 if request.spawn_spec.target.node_id != self.node_id {
7185 return Err(failure(
7186 NodeFailureCode::SpawnTargetMismatch,
7187 "spawn target does not match this node",
7188 ));
7189 }
7190 if request.spawn_spec.target.worktree_id.is_some() {
7191 return Err(failure(
7192 NodeFailureCode::InvalidRequest,
7193 "managed worktree spawn must not select a caller-provided worktree",
7194 ));
7195 }
7196 let request_digest = managed_spawn_request_digest_v2(&request)?;
7197 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7198 match replay {
7199 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7200 DurableManagedSpawnReplayDecision::PendingLinked => {
7201 return Err(failure(
7202 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7203 "managed worktree V2 reservation has an incomplete durable allocation",
7204 ));
7205 }
7206 DurableManagedSpawnReplayDecision::Reserved => {}
7207 }
7208 let validated = (|| {
7209 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7210 let profile = self.managed_profile(
7211 &resolved.target.workspace_id,
7212 &request.worktree_profile_id,
7213 )?;
7214 if profile.revision() != &request.expected_profile_revision {
7215 return Err(failure(
7216 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7217 "managed worktree profile revision does not match the loaded profile",
7218 ));
7219 }
7220 Ok(())
7221 })();
7222 match validated {
7223 Ok(()) => {}
7224 Err(error) => {
7225 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7226 return Err(error);
7227 }
7228 }
7229 let legacy = ManagedWorktreeSpawnRequest {
7230 spawn_spec: request.spawn_spec.clone(),
7231 worktree_profile_id: request.worktree_profile_id.clone(),
7232 };
7233 match self
7234 .spawn_managed_worktree_inner(
7235 legacy,
7236 Some(&request.spawn_spec.idempotency_key),
7237 )
7238 .await
7239 {
7240 Ok(receipt) => {
7241 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7242 Ok(receipt)
7243 }
7244 Err(error) => {
7245 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7246 Err(error)
7247 }
7248 }
7249 }
7250
7251 fn managed_profile(
7252 &self,
7253 workspace_id: &WorkspaceId,
7254 profile_id: &WorktreeProfileId,
7255 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7256 if self.worktree_service_modes.get(workspace_id).copied()
7257 != Some(WorktreeServiceMode::Managed)
7258 {
7259 return Err(failure(
7260 NodeFailureCode::UnsupportedSpawnCapability,
7261 "workspace does not enable managed worktrees",
7262 ));
7263 }
7264 self.managed_worktree_profiles.get(workspace_id)
7265 .and_then(|profiles| profiles.get(profile_id))
7266 .cloned()
7267 .ok_or_else(|| failure(
7268 NodeFailureCode::InvalidRequest,
7269 "managed worktree profile is unavailable for this workspace",
7270 ))
7271 }
7272
7273 async fn reconcile_failed_allocation(
7274 &self,
7275 lease: &ManagedWorktreeLeaseRecord,
7276 source_root: &str,
7277 ) {
7278 match list_git_worktrees(source_root).await {
7279 Ok(worktrees) => {
7280 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7281 None => {
7282 match std::fs::symlink_metadata(&lease.target_root) {
7283 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7284 self.managed_worktrees.lock()
7285 .unwrap_or_else(|poisoned| poisoned.into_inner())
7286 .remove_unmutated(&lease.lease_id);
7287 let _ = self.persist_state();
7288 }
7289 _ => self.mark_managed_recovery_required(
7290 &lease.lease_id,
7291 ManagedWorktreeCleanupFailure::OwnershipConflict,
7292 ),
7293 }
7294 }
7295 Some(item) if exact_owned_worktree(
7296 lease,
7297 &item.path,
7298 item.branch.as_deref(),
7299 &item.head,
7300 ) => {
7301 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7302 }
7303 Some(_) => self.mark_managed_recovery_required(
7304 &lease.lease_id,
7305 ManagedWorktreeCleanupFailure::OwnershipConflict,
7306 ),
7307 }
7308 }
7309 Err(_) => self.mark_managed_recovery_required(
7310 &lease.lease_id,
7311 ManagedWorktreeCleanupFailure::Backend,
7312 ),
7313 }
7314 }
7315
7316 async fn reconcile_managed_worktrees(&self) {
7317 let leases = self.managed_worktrees.lock()
7318 .unwrap_or_else(|poisoned| poisoned.into_inner())
7319 .active_records()
7320 .cloned()
7321 .collect::<Vec<_>>();
7322 for lease in leases {
7323 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7324 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7325 _ => {
7326 self.mark_managed_recovery_required(
7327 &lease.lease_id,
7328 ManagedWorktreeCleanupFailure::OwnershipConflict,
7329 );
7330 continue;
7331 }
7332 };
7333 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7334 self.mark_managed_recovery_required(
7335 &lease.lease_id,
7336 ManagedWorktreeCleanupFailure::OwnershipConflict,
7337 );
7338 continue;
7339 }
7340 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7341 Ok(root) => root,
7342 Err(_) => {
7343 self.mark_managed_recovery_required(
7344 &lease.lease_id,
7345 ManagedWorktreeCleanupFailure::OwnershipConflict,
7346 );
7347 continue;
7348 }
7349 };
7350 let worktrees = match list_git_worktrees(&source_root).await {
7351 Ok(worktrees) => worktrees,
7352 Err(_) => {
7353 self.mark_managed_recovery_required(
7354 &lease.lease_id,
7355 ManagedWorktreeCleanupFailure::Backend,
7356 );
7357 continue;
7358 }
7359 };
7360 let listed = worktrees.iter()
7361 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7362 let Some(listed) = listed else {
7363 match std::fs::symlink_metadata(&lease.target_root) {
7364 Err(error) if error.kind() == io::ErrorKind::NotFound
7365 && lease.state == ManagedWorktreeLeaseState::Allocating
7366 && !lease.has_holders() => {
7367 let removed = self.managed_worktrees.lock()
7368 .unwrap_or_else(|poisoned| poisoned.into_inner())
7369 .tombstone(&lease.lease_id, unix_time_ms());
7370 if self.persist_state().is_ok() && removed.is_some() {
7371 self.publish(NodeEvent::ManagedWorktreeRemoved {
7372 lease_id: lease.lease_id.clone(),
7373 });
7374 }
7375 }
7376 _ => self.mark_managed_recovery_required(
7377 &lease.lease_id,
7378 ManagedWorktreeCleanupFailure::OwnershipConflict,
7379 ),
7380 }
7381 continue;
7382 };
7383 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7384 &lease,
7385 &listed.path,
7386 listed.branch.as_deref(),
7387 &listed.head,
7388 ) {
7389 self.mark_managed_recovery_required(
7390 &lease.lease_id,
7391 ManagedWorktreeCleanupFailure::OwnershipConflict,
7392 );
7393 continue;
7394 }
7395 if !self.workspaces.read()
7396 .unwrap_or_else(|poisoned| poisoned.into_inner())
7397 .contains_key(&lease.workspace_id)
7398 {
7399 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7400 self.mark_managed_recovery_required(
7401 &lease.lease_id,
7402 ManagedWorktreeCleanupFailure::Backend,
7403 );
7404 continue;
7405 }
7406 }
7407 let snapshot = {
7408 let mut registry = self.managed_worktrees.lock()
7409 .unwrap_or_else(|poisoned| poisoned.into_inner());
7410 let current = registry.get_mut(&lease.lease_id)
7411 .expect("reconciled managed lease remains present");
7412 current.expected_head = Some(listed.head.clone());
7413 current.cleanup_failure = None;
7414 current.state = if current.has_holders() {
7415 ManagedWorktreeLeaseState::InUse
7416 } else if current.retention == ManagedWorktreeRetention::Retain {
7417 ManagedWorktreeLeaseState::Retained
7418 } else {
7419 ManagedWorktreeLeaseState::Ready
7420 };
7421 current.updated_at_unix_ms = unix_time_ms();
7422 current.snapshot()
7423 };
7424 if self.persist_state().is_err() {
7425 self.mark_managed_recovery_required(
7426 &lease.lease_id,
7427 ManagedWorktreeCleanupFailure::Backend,
7428 );
7429 continue;
7430 }
7431 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7432 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7433 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7434 }
7435 }
7436 }
7437
7438 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7458 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7459 .session_records
7460 .lock()
7461 .unwrap_or_else(|poisoned| poisoned.into_inner())
7462 .records
7463 .values()
7464 .filter(|record| {
7465 record.state == ManagedSessionState::Live
7466 && record.exported_context.is_none()
7467 && record.provider_session.is_some()
7468 })
7469 .filter_map(|record| {
7470 record
7471 .active_session
7472 .clone()
7473 .map(|session| (record.record_id.clone(), session))
7474 })
7475 .collect();
7476 for (record_id, session) in candidates {
7477 let shared = Arc::clone(self);
7478 tokio::spawn(async move {
7479 shared
7480 .reconcile_context_pack_export_for_record(&record_id, session)
7481 .await;
7482 });
7483 }
7484 }
7485
7486 async fn reconcile_context_pack_export_for_record(
7505 &self,
7506 record_id: &SessionRecordId,
7507 session: SessionAddress,
7508 ) {
7509 let Ok(record) = self.record(record_id) else {
7510 return;
7511 };
7512 if !matches!(
7513 record.state,
7514 ManagedSessionState::Live | ManagedSessionState::Dormant
7515 ) || record.exported_context.is_some()
7516 || record.provider_session.is_none()
7517 {
7518 return;
7519 }
7520 let Ok(receipt) = self
7527 .export_context_pack_for_session_record(&record.record_id, &session, true)
7528 .await
7529 else {
7530 return;
7531 };
7532 self.set_exported_context(&record.record_id, receipt);
7533 }
7534
7535 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7540 let transaction = self
7541 .state_transaction
7542 .lock()
7543 .unwrap_or_else(|poisoned| poisoned.into_inner());
7544 let (previous, updated) = {
7545 let mut records = self
7546 .session_records
7547 .lock()
7548 .unwrap_or_else(|poisoned| poisoned.into_inner());
7549 let Some(record) = records.records.get_mut(record_id) else {
7550 return;
7551 };
7552 if record.exported_context.is_some() {
7553 return;
7554 }
7555 let previous = record.clone();
7556 record.exported_context = Some(receipt);
7557 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7558 (previous, record.clone())
7559 };
7560 if self.persist_state_locked().is_err() {
7561 self.session_records
7562 .lock()
7563 .unwrap_or_else(|poisoned| poisoned.into_inner())
7564 .records
7565 .insert(record_id.clone(), previous);
7566 return;
7567 }
7568 drop(transaction);
7569 self.publish_record(updated);
7570 }
7571
7572 fn managed_record_export_target_for_instance(
7582 &self,
7583 instance_id: AgentInstanceId,
7584 generation: SessionGeneration,
7585 ) -> Option<(SessionRecordId, SessionAddress)> {
7586 let bindings = self
7587 .session_bindings
7588 .lock()
7589 .unwrap_or_else(|poisoned| poisoned.into_inner());
7590 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7591 let record_id = binding.record_id.clone()?;
7592 Some((
7593 record_id,
7594 SessionAddress {
7595 workspace_id: binding.workspace_id.clone(),
7596 session: SessionKey { instance_id, generation },
7597 },
7598 ))
7599 }
7600
7601 fn mark_managed_recovery_required(
7602 &self,
7603 lease_id: &ManagedWorktreeLeaseId,
7604 failure_kind: ManagedWorktreeCleanupFailure,
7605 ) {
7606 let snapshot = {
7607 let mut registry = self.managed_worktrees.lock()
7608 .unwrap_or_else(|poisoned| poisoned.into_inner());
7609 let Some(lease) = registry.get_mut(lease_id) else { return };
7610 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7611 lease.cleanup_failure = Some(failure_kind);
7612 lease.updated_at_unix_ms = unix_time_ms();
7613 lease.snapshot()
7614 };
7615 let _ = self.persist_state();
7616 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7617 }
7618
7619 fn set_persistence_error(&self, error: Option<String>) {
7620 *self
7621 .persistence_error
7622 .write()
7623 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7624 .map(|message| session_registry::sanitized_persistence_summary(&message));
7625 }
7626
7627 fn persist_state(&self) -> io::Result<()> {
7628 let _transaction = self
7629 .state_transaction
7630 .lock()
7631 .unwrap_or_else(|poisoned| poisoned.into_inner());
7632 self.persist_state_locked()
7633 }
7634
7635 fn persist_state_locked(&self) -> io::Result<()> {
7636 let workspaces = self
7637 .workspaces
7638 .read()
7639 .unwrap_or_else(|poisoned| poisoned.into_inner())
7640 .clone();
7641 let records = self
7642 .session_records
7643 .lock()
7644 .unwrap_or_else(|poisoned| poisoned.into_inner())
7645 .records
7646 .values()
7647 .cloned()
7648 .collect::<Vec<_>>();
7649 let mut managed = self.managed_worktrees
7650 .lock()
7651 .unwrap_or_else(|poisoned| poisoned.into_inner());
7652 managed.reattach_record_holders(
7653 &managed_worktree_record_holders(&records),
7654 unix_time_ms(),
7655 );
7656 let materializations = self
7657 .materializations
7658 .lock()
7659 .unwrap_or_else(|poisoned| poisoned.into_inner())
7660 .values()
7661 .cloned()
7662 .collect::<Vec<_>>();
7663 let managed_spawn_replays = self.managed_spawn_replays
7664 .lock()
7665 .unwrap_or_else(|poisoned| poisoned.into_inner())
7666 .values()
7667 .cloned()
7668 .collect::<Vec<_>>();
7669 let result = session_registry::save_v10(
7670 self.state_path.as_deref(),
7671 &self.node_id,
7672 &workspaces,
7673 &records,
7674 &managed.records(),
7675 &managed.tombstones(),
7676 &materializations,
7677 &managed_spawn_replays,
7678 );
7679 match &result {
7680 Ok(warning) => self.set_persistence_error(warning.clone()),
7681 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7682 }
7683 result.map(|_| ())
7684 }
7685
7686 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7687 let _mutation_guard = self.mutation_gate.lock().await;
7688 self.begin_shutdown_locked().await
7689 }
7690
7691 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7692 if self.shutdown.swap(true, Ordering::AcqRel) {
7693 return Ok(());
7694 }
7695 SessionLifecycleHook::on_shutdown(self);
7696 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7697 let mut first_error = None;
7698 for session in &self.handle.snapshot().sessions {
7699 let remaining = deadline.saturating_duration_since(Instant::now());
7700 if remaining.is_zero() {
7701 first_error.get_or_insert_with(|| {
7702 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7703 });
7704 break;
7705 }
7706 if let Err(error) = self
7707 .dispatch_bounded(
7708 ControlCommand::Stop {
7709 instance_id: session.instance_id,
7710 force: true,
7711 },
7712 remaining,
7713 )
7714 .await
7715 {
7716 first_error.get_or_insert(error);
7717 }
7718 }
7719 self.shutdown_notify.notify_waiters();
7720 match first_error {
7721 Some(error) => Err(error),
7722 None => Ok(()),
7723 }
7724 }
7725
7726 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7727 let bindings = self
7728 .session_bindings
7729 .lock()
7730 .unwrap_or_else(|poisoned| poisoned.into_inner());
7731 if bindings.len() >= CONTROL_SESSIONS_MAX {
7732 Err(failure(
7733 NodeFailureCode::BackendBusy,
7734 "node session-binding capacity is full; remove an existing session first",
7735 ))
7736 } else {
7737 Ok(())
7738 }
7739 }
7740
7741 fn ensure_workspace_environment_profile_compatible(
7748 &self,
7749 workspace_id: &WorkspaceId,
7750 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7751 ) -> Result<(), NodeFailure> {
7752 let bindings = self
7753 .session_bindings
7754 .lock()
7755 .unwrap_or_else(|poisoned| poisoned.into_inner());
7756 for binding in bindings.values() {
7757 if &binding.workspace_id != workspace_id {
7758 continue;
7759 }
7760 if binding.environment_profile.as_ref() != environment_profile {
7761 return Err(failure(
7762 NodeFailureCode::WorkspaceBusy,
7763 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7764 ));
7765 }
7766 }
7767 Ok(())
7768 }
7769
7770 #[cfg(test)]
7771 fn bind_session(
7772 &self,
7773 instance_id: AgentInstanceId,
7774 workspace_id: WorkspaceId,
7775 generation: SessionGeneration,
7776 ) {
7777 self.bind_session_with_policy(
7778 &SessionAddress {
7779 workspace_id,
7780 session: SessionKey { instance_id, generation },
7781 },
7782 ProviderRuntimePolicy::raw_pty(),
7783 None,
7784 );
7785 }
7786
7787 #[cfg(test)]
7788 fn bind_session_with_policy(
7789 &self,
7790 address: &SessionAddress,
7791 runtime_policy: ProviderRuntimePolicy,
7792 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7793 ) {
7794 self.bind_session_with_materialization(
7795 address,
7796 runtime_policy,
7797 environment_profile,
7798 None,
7799 None,
7800 None,
7801 );
7802 }
7803
7804 fn bind_session_with_materialization(
7805 &self,
7806 address: &SessionAddress,
7807 runtime_policy: ProviderRuntimePolicy,
7808 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7809 bundle: Option<ResolvedBundleReceipt>,
7810 context: Option<ResolvedContextPackReceipt>,
7811 materialization_id: Option<MaterializationId>,
7812 ) {
7813 let mut bindings = self
7814 .session_bindings
7815 .lock()
7816 .unwrap_or_else(|poisoned| poisoned.into_inner());
7817 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7818 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7819 bindings.insert(address.session.instance_id, SessionBinding {
7820 workspace_id: address.workspace_id.clone(),
7821 generation: address.session.generation,
7822 runtime_policy,
7823 pending_resume: None,
7824 record_id: None,
7825 managed_worktree_lease_id: None,
7826 environment_profile,
7827 bundle,
7828 context,
7829 materialization_id,
7830 });
7831 }
7832
7833 #[cfg(test)]
7834 fn bind_managed_session(
7835 &self,
7836 address: &SessionAddress,
7837 record_id: SessionRecordId,
7838 runtime_policy: ProviderRuntimePolicy,
7839 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7840 ) {
7841 self.bind_managed_session_with_materialization(
7842 address,
7843 record_id,
7844 runtime_policy,
7845 environment_profile,
7846 None,
7847 None,
7848 None,
7849 );
7850 }
7851
7852 fn bind_managed_session_with_materialization(
7853 &self,
7854 address: &SessionAddress,
7855 record_id: SessionRecordId,
7856 runtime_policy: ProviderRuntimePolicy,
7857 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7858 bundle: Option<ResolvedBundleReceipt>,
7859 context: Option<ResolvedContextPackReceipt>,
7860 materialization_id: Option<MaterializationId>,
7861 ) {
7862 let mut bindings = self
7863 .session_bindings
7864 .lock()
7865 .unwrap_or_else(|poisoned| poisoned.into_inner());
7866 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7867 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7868 bindings.insert(
7869 address.session.instance_id,
7870 SessionBinding {
7871 workspace_id: address.workspace_id.clone(),
7872 generation: address.session.generation,
7873 runtime_policy,
7874 pending_resume: None,
7875 record_id: Some(record_id),
7876 managed_worktree_lease_id: None,
7877 environment_profile,
7878 bundle,
7879 context,
7880 materialization_id,
7881 },
7882 );
7883 }
7884
7885 #[cfg(test)]
7886 fn bind_spawn_session(
7887 &self,
7888 address: &SessionAddress,
7889 provider: AgentId,
7890 mode: SessionMode,
7891 runtime_policy: ProviderRuntimePolicy,
7892 record_policy: SpawnRecordPolicy,
7893 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7894 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7895 self.bind_spawn_session_with_materialization(
7896 address,
7897 provider,
7898 mode,
7899 runtime_policy,
7900 record_policy,
7901 environment_profile,
7902 None,
7903 None,
7904 None,
7905 )
7906 }
7907
7908 fn bind_spawn_session_with_materialization(
7909 &self,
7910 address: &SessionAddress,
7911 provider: AgentId,
7912 mode: SessionMode,
7913 runtime_policy: ProviderRuntimePolicy,
7914 record_policy: SpawnRecordPolicy,
7915 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7916 bundle: Option<ResolvedBundleReceipt>,
7917 context: Option<ResolvedContextPackReceipt>,
7918 materialization_id: Option<MaterializationId>,
7919 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7920 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7921 && !runtime_policy.provider_session_identity
7922 {
7923 self.bind_session_with_materialization(
7924 address,
7925 runtime_policy,
7926 environment_profile,
7927 bundle,
7928 context,
7929 materialization_id,
7930 );
7931 return Ok(None);
7932 }
7933 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7934 let record = self.new_record(
7935 address,
7936 provider,
7937 mode,
7938 active_record_state(runtime_policy.provider_session_identity, false),
7939 None,
7940 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7941 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7942 record_owns_lifecycle.then(|| context.clone()).flatten(),
7943 )?;
7944 let record_id = record.record_id.clone();
7945 let transaction = self
7946 .state_transaction
7947 .lock()
7948 .unwrap_or_else(|poisoned| poisoned.into_inner());
7949 self.insert_record(record.clone())?;
7950 let previous_materialization = if record_owns_lifecycle {
7951 if let Some(materialization_id) = materialization_id.as_ref() {
7952 let mut materializations = self
7953 .materializations
7954 .lock()
7955 .unwrap_or_else(|poisoned| poisoned.into_inner());
7956 let Some(ownership) = materializations.get_mut(materialization_id) else {
7957 drop(materializations);
7958 self.remove_record_memory(&record_id);
7959 return Err(failure(
7960 NodeFailureCode::BackendOperationFailed,
7961 "session environment ownership disappeared before binding",
7962 ));
7963 };
7964 let previous = ownership.clone();
7965 if ownership
7966 .transfer_to_record(record_id.clone(), unix_time_ms())
7967 .is_err()
7968 {
7969 drop(materializations);
7970 self.remove_record_memory(&record_id);
7971 return Err(failure(
7972 NodeFailureCode::BackendOperationFailed,
7973 "session environment ownership transfer failed",
7974 ));
7975 }
7976 Some(previous)
7977 } else {
7978 None
7979 }
7980 } else {
7981 None
7982 };
7983 self.bind_managed_session_with_materialization(
7984 address,
7985 record_id.clone(),
7986 runtime_policy,
7987 environment_profile,
7988 bundle,
7989 context,
7990 materialization_id.clone(),
7991 );
7992 if let Err(error) = self.persist_state_locked() {
7993 self.remove_binding(address);
7994 self.remove_record_memory(&record_id);
7995 if let (Some(materialization_id), Some(previous)) =
7996 (materialization_id.as_ref(), previous_materialization)
7997 {
7998 self.materializations
7999 .lock()
8000 .unwrap_or_else(|poisoned| poisoned.into_inner())
8001 .insert(materialization_id.clone(), previous);
8002 }
8003 return Err(persistence_failure(error));
8004 }
8005 drop(transaction);
8006 self.publish_record(record);
8007 Ok(Some(record_id))
8008 }
8009
8010 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8011 for _ in 0..8 {
8012 let bytes = random_nonce().map_err(|error| {
8013 failure(
8014 NodeFailureCode::BackendOperationFailed,
8015 &format!("session record identity generation failed: {error}"),
8016 )
8017 })?;
8018 let mut value = String::with_capacity(27);
8019 value.push_str("sr-");
8020 for byte in &bytes[..12] {
8021 use std::fmt::Write as _;
8022 let _ = write!(&mut value, "{byte:02x}");
8023 }
8024 let record_id = SessionRecordId::new(value).map_err(|error| {
8025 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8026 })?;
8027 let records = self
8028 .session_records
8029 .lock()
8030 .unwrap_or_else(|poisoned| poisoned.into_inner());
8031 if !records.records.contains_key(&record_id) {
8032 return Ok(record_id);
8033 }
8034 }
8035 Err(failure(
8036 NodeFailureCode::BackendBusy,
8037 "could not allocate a unique session record ID",
8038 ))
8039 }
8040
8041 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8042 for _ in 0..8 {
8043 let bytes = random_nonce().map_err(|error| {
8044 failure(
8045 NodeFailureCode::BackendOperationFailed,
8046 &format!("task identity generation failed: {error}"),
8047 )
8048 })?;
8049 let mut nonce = [0_u8; 12];
8050 nonce.copy_from_slice(&bytes[..12]);
8051 let task_id = TaskId::from_nonce(nonce);
8052 let records = self
8053 .session_records
8054 .lock()
8055 .unwrap_or_else(|poisoned| poisoned.into_inner());
8056 if !records.records.values().any(|record| {
8057 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8058 == Some(&task_id)
8059 }) {
8060 return Ok(task_id);
8061 }
8062 }
8063 Err(failure(
8064 NodeFailureCode::BackendBusy,
8065 "could not allocate a unique task ID",
8066 ))
8067 }
8068
8069 fn default_record_name(&self, provider: &AgentId) -> String {
8070 let records = self
8071 .session_records
8072 .lock()
8073 .unwrap_or_else(|poisoned| poisoned.into_inner());
8074 let ordinal = records
8075 .records
8076 .values()
8077 .filter(|record| &record.provider == provider)
8078 .count()
8079 .saturating_add(1);
8080 format!("{} #{ordinal}", provider.as_str())
8081 }
8082
8083 fn new_record(
8084 &self,
8085 address: &SessionAddress,
8086 provider: AgentId,
8087 mode: SessionMode,
8088 state: ManagedSessionState,
8089 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8090 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8091 bundle: Option<ResolvedBundleReceipt>,
8092 context: Option<ResolvedContextPackReceipt>,
8093 ) -> Result<ManagedSessionRecord, NodeFailure> {
8094 let canonical_root = self.workspace_root(&address.workspace_id)?;
8095 let now = unix_time_ms();
8096 Ok(ManagedSessionRecord {
8097 record_id: self.allocate_record_id()?,
8098 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8099 provider,
8100 mode,
8101 state,
8102 workspace_id: address.workspace_id.clone(),
8103 canonical_root: opaque_windows_path(canonical_root),
8104 provider_session,
8105 active_session: Some(address.clone()),
8106 environment_profile,
8107 bundle,
8108 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8109 context,
8110 exported_context: None,
8111 task_binding: None,
8112 created_at_unix_ms: now,
8113 updated_at_unix_ms: now,
8114 last_error: None,
8115 })
8116 }
8117
8118 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8119 let mut records = self
8120 .session_records
8121 .lock()
8122 .unwrap_or_else(|poisoned| poisoned.into_inner());
8123 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8124 return Err(failure(
8125 NodeFailureCode::BackendBusy,
8126 "managed session record capacity is full; forget an inactive record first",
8127 ));
8128 }
8129 if let Some(identity) = record.provider_session.as_ref() {
8130 if records.records.values().any(|candidate| {
8131 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8132 session_registry::same_provider_session(
8133 &candidate.provider,
8134 candidate_identity,
8135 &record.provider,
8136 identity,
8137 )
8138 })
8139 }) {
8140 return Err(failure(
8141 NodeFailureCode::SessionRecordConflict,
8142 "provider session is already represented by another managed record",
8143 ));
8144 }
8145 }
8146 match records.records.entry(record.record_id.clone()) {
8147 std::collections::btree_map::Entry::Vacant(entry) => {
8148 entry.insert(record);
8149 }
8150 std::collections::btree_map::Entry::Occupied(_) => {
8151 return Err(failure(
8152 NodeFailureCode::SessionRecordConflict,
8153 "session record ID collision",
8154 ));
8155 }
8156 }
8157 Ok(())
8158 }
8159
8160 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8161 self.session_records
8162 .lock()
8163 .unwrap_or_else(|poisoned| poisoned.into_inner())
8164 .records
8165 .get(record_id)
8166 .cloned()
8167 .ok_or_else(|| {
8168 failure(
8169 NodeFailureCode::UnknownSessionRecord,
8170 "managed session record does not exist",
8171 )
8172 })
8173 }
8174
8175 fn revalidate_session_record_preview(
8176 &self,
8177 expected: &ManagedSessionRecord,
8178 identity: &ProviderSessionIdentity,
8179 ) -> Result<(), NodeFailure> {
8180 let current = self.record(&expected.record_id)?;
8181 let current_root = self.workspace_root(¤t.workspace_id)?;
8182 if current.provider != expected.provider
8183 || current.workspace_id != expected.workspace_id
8184 || current.canonical_root != expected.canonical_root
8185 || current.provider_session.as_ref() != Some(identity)
8186 || !platform::roots_equal(
8187 ¤t_root,
8188 windows_path_text(¤t.canonical_root),
8189 )
8190 {
8191 return Err(failure(
8192 NodeFailureCode::SessionRecordConflict,
8193 "managed session changed while its preview was loading",
8194 ));
8195 }
8196 Ok(())
8197 }
8198
8199 fn revalidate_session_record_context_export(
8200 &self,
8201 expected: &ManagedSessionRecord,
8202 identity: Option<&ProviderSessionIdentity>,
8203 session: &SessionAddress,
8204 allow_clean_detachment: bool,
8205 ) -> Result<(), NodeFailure> {
8206 let current = self.record(&expected.record_id)?;
8207 let current_root = self.workspace_root(¤t.workspace_id)?;
8208 let snapshot = self.internal_session_snapshot(session)?;
8209 let binding = self
8210 .session_bindings
8211 .lock()
8212 .unwrap_or_else(|poisoned| poisoned.into_inner())
8213 .get(&session.session.instance_id)
8214 .cloned()
8215 .ok_or_else(|| {
8216 failure(
8217 NodeFailureCode::SessionRecordConflict,
8218 "managed session runtime binding disappeared during context export",
8219 )
8220 })?;
8221 if !session_record_context_export_binding_is_exact(
8222 expected,
8223 ¤t,
8224 identity,
8225 session,
8226 &binding,
8227 &snapshot.agent_id,
8228 ¤t_root,
8229 allow_clean_detachment,
8230 ) || !session_record_context_export_source_is_usable(
8231 ¤t,
8232 &snapshot.status,
8233 allow_clean_detachment,
8234 ) {
8235 return Err(failure(
8236 NodeFailureCode::SessionRecordConflict,
8237 "managed session binding changed during context export",
8238 ));
8239 }
8240 Ok(())
8241 }
8242
8243 fn revalidate_loaded_history(
8244 &self,
8245 session: &SessionAddress,
8246 candidate_id: &str,
8247 loaded: &HistorySessionRecord,
8248 ) -> Result<(), NodeFailure> {
8249 let snapshot = self.internal_session_snapshot(session)?;
8250 if snapshot.history.pending.is_some()
8251 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8252 || snapshot.history.loaded.as_ref() != Some(loaded)
8253 {
8254 return Err(failure(
8255 NodeFailureCode::SessionRecordConflict,
8256 "managed session history changed during context export",
8257 ));
8258 }
8259 Ok(())
8260 }
8261
8262 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8263 self.session_records
8264 .lock()
8265 .unwrap_or_else(|poisoned| poisoned.into_inner())
8266 .records
8267 .remove(record_id)
8268 }
8269
8270 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8271 if self.destroy_record_materialization(record_id)? {
8272 self.publish(NodeEvent::SessionRecordRemoved {
8273 record_id: record_id.clone(),
8274 });
8275 return Ok(());
8276 }
8277 let transaction = self
8278 .state_transaction
8279 .lock()
8280 .unwrap_or_else(|poisoned| poisoned.into_inner());
8281 let Some(removed) = self.remove_record_memory(record_id) else {
8282 return Ok(());
8283 };
8284 if let Err(error) = self.persist_state_locked() {
8285 let _ = self.insert_record(removed);
8286 return Err(persistence_failure(error));
8287 }
8288 drop(transaction);
8289 self.publish(NodeEvent::SessionRecordRemoved {
8290 record_id: record_id.clone(),
8291 });
8292 Ok(())
8293 }
8294
8295 fn publish_record(&self, record: ManagedSessionRecord) {
8296 self.publish(NodeEvent::SessionRecordUpserted { record });
8297 }
8298
8299 fn mark_record_error(
8300 &self,
8301 record_id: &SessionRecordId,
8302 message: &str,
8303 ) -> Result<(), NodeFailure> {
8304 let transaction = self
8305 .state_transaction
8306 .lock()
8307 .unwrap_or_else(|poisoned| poisoned.into_inner());
8308 let previous = {
8309 let mut records = self
8310 .session_records
8311 .lock()
8312 .unwrap_or_else(|poisoned| poisoned.into_inner());
8313 let Some(record) = records.records.get_mut(record_id) else {
8314 return Ok(());
8315 };
8316 let previous = record.clone();
8317 record.state = ManagedSessionState::Unavailable;
8318 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8319 record.updated_at_unix_ms = unix_time_ms();
8320 previous
8321 };
8322 if let Err(error) = self.persist_state_locked() {
8323 self.session_records
8324 .lock()
8325 .unwrap_or_else(|poisoned| poisoned.into_inner())
8326 .records
8327 .insert(record_id.clone(), previous);
8328 return Err(persistence_failure(error));
8329 }
8330 let record = self.record(record_id)?;
8331 drop(transaction);
8332 self.publish_record(record);
8333 Ok(())
8334 }
8335
8336 fn rename_session_record(
8337 &self,
8338 record_id: &SessionRecordId,
8339 display_name: String,
8340 ) -> Result<ManagedSessionRecord, NodeFailure> {
8341 validate_display_name(&display_name)
8342 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8343 let _transaction = self
8344 .state_transaction
8345 .lock()
8346 .unwrap_or_else(|poisoned| poisoned.into_inner());
8347 let previous = {
8348 let mut records = self
8349 .session_records
8350 .lock()
8351 .unwrap_or_else(|poisoned| poisoned.into_inner());
8352 let record = records.records.get_mut(record_id).ok_or_else(|| {
8353 failure(
8354 NodeFailureCode::UnknownSessionRecord,
8355 "managed session record does not exist",
8356 )
8357 })?;
8358 let previous = record.clone();
8359 record.display_name = display_name;
8360 record.updated_at_unix_ms = unix_time_ms();
8361 previous
8362 };
8363 if let Err(error) = self.persist_state_locked() {
8364 self.session_records
8365 .lock()
8366 .unwrap_or_else(|poisoned| poisoned.into_inner())
8367 .records
8368 .insert(record_id.clone(), previous);
8369 return Err(persistence_failure(error));
8370 }
8371 let updated = self.record(record_id)?;
8372 self.publish_record(updated.clone());
8373 Ok(updated)
8374 }
8375
8376 fn set_session_task(
8377 &self,
8378 record_id: &SessionRecordId,
8379 expected_revision: u64,
8380 target: SessionTaskTargetV1,
8381 ) -> Result<ManagedSessionRecord, NodeFailure> {
8382 let transaction = self
8383 .state_transaction
8384 .lock()
8385 .unwrap_or_else(|poisoned| poisoned.into_inner());
8386 let current = self.record(record_id)?;
8387 let current_task_id = current
8388 .task_binding
8389 .as_ref()
8390 .and_then(|binding| binding.task_id.as_ref());
8391 let current_revision = current
8392 .task_binding
8393 .as_ref()
8394 .map_or(0, |binding| binding.revision);
8395 if expected_revision != current_revision {
8396 return Err(failure(
8397 NodeFailureCode::SessionRecordConflict,
8398 "managed session task binding revision changed",
8399 ));
8400 }
8401 let exact = match &target {
8402 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8403 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8404 SessionTaskTargetV1::New => false,
8405 };
8406 if exact {
8407 return Ok(current);
8408 }
8409 let revision = current_revision.checked_add(1).ok_or_else(|| {
8410 failure(
8411 NodeFailureCode::SessionRecordConflict,
8412 "managed session task binding revision is exhausted",
8413 )
8414 })?;
8415 let task_id = match target {
8416 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8417 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8418 SessionTaskTargetV1::Clear => None,
8419 };
8420 let changed_at_unix_ms = unix_time_ms()
8421 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8422 failure(
8423 NodeFailureCode::SessionRecordConflict,
8424 "managed session record timestamp is exhausted",
8425 )
8426 })?);
8427 let updated = {
8428 let mut records = self
8429 .session_records
8430 .lock()
8431 .unwrap_or_else(|poisoned| poisoned.into_inner());
8432 let record = records.records.get_mut(record_id).ok_or_else(|| {
8433 failure(
8434 NodeFailureCode::UnknownSessionRecord,
8435 "managed session record does not exist",
8436 )
8437 })?;
8438 record.task_binding = Some(SessionTaskBindingV1 {
8439 revision,
8440 task_id,
8441 changed_at_unix_ms,
8442 });
8443 record.updated_at_unix_ms = changed_at_unix_ms;
8444 record.clone()
8445 };
8446 if let Err(error) = self.persist_state_locked() {
8447 self.session_records
8448 .lock()
8449 .unwrap_or_else(|poisoned| poisoned.into_inner())
8450 .records
8451 .insert(record_id.clone(), current);
8452 return Err(persistence_failure(error));
8453 }
8454 drop(transaction);
8455 self.publish_record(updated.clone());
8456 Ok(updated)
8457 }
8458
8459 fn index_provider_session(
8460 &self,
8461 workspace_id: WorkspaceId,
8462 provider: AgentId,
8463 identity: ProviderSessionIdentity,
8464 display_name: String,
8465 ) -> Result<ManagedSessionRecord, NodeFailure> {
8466 self.index_provider_session_with_policy(
8467 workspace_id,
8468 provider,
8469 identity,
8470 display_name,
8471 false,
8472 )
8473 }
8474
8475 fn index_provider_session_with_policy(
8476 &self,
8477 workspace_id: WorkspaceId,
8478 provider: AgentId,
8479 identity: ProviderSessionIdentity,
8480 display_name: String,
8481 allow_validated_transcript_path: bool,
8482 ) -> Result<ManagedSessionRecord, NodeFailure> {
8483 validate_display_name(&display_name)
8484 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8485 identity
8486 .validate()
8487 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8488 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8489 return Err(failure(
8490 NodeFailureCode::InvalidRequest,
8491 "provider session reference index v1 does not accept transcript paths",
8492 ));
8493 }
8494 let canonical_root = self.workspace_root(&workspace_id)?;
8495 if !self.enabled_providers.contains(&provider) {
8496 return Err(failure(
8497 NodeFailureCode::InvalidRequest,
8498 "provider is not enabled on this node",
8499 ));
8500 }
8501
8502 let transaction = self
8503 .state_transaction
8504 .lock()
8505 .unwrap_or_else(|poisoned| poisoned.into_inner());
8506 {
8507 let records = self
8508 .session_records
8509 .lock()
8510 .unwrap_or_else(|poisoned| poisoned.into_inner());
8511 if let Some(existing) = records.records.values().find(|candidate| {
8512 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8513 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8514 })
8515 }) {
8516 if existing.provider == provider && existing.workspace_id == workspace_id {
8517 return Ok(existing.clone());
8518 }
8519 return Err(failure(
8520 NodeFailureCode::SessionRecordConflict,
8521 "provider session reference conflicts with another provider or workspace",
8522 ));
8523 }
8524 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8525 return Err(failure(
8526 NodeFailureCode::BackendBusy,
8527 "managed session record capacity is full; forget an inactive record first",
8528 ));
8529 }
8530 }
8531
8532 let now = unix_time_ms();
8533 let record = ManagedSessionRecord {
8534 record_id: self.allocate_record_id()?,
8535 display_name,
8536 provider,
8537 mode: SessionMode::Pty,
8538 state: ManagedSessionState::Dormant,
8539 workspace_id,
8540 canonical_root: opaque_windows_path(canonical_root),
8541 provider_session: Some(identity),
8542 active_session: None,
8543 environment_profile: None,
8544 bundle: None,
8545 context_id: None,
8546 context: None,
8547 exported_context: None,
8548 task_binding: None,
8549 created_at_unix_ms: now,
8550 updated_at_unix_ms: now,
8551 last_error: None,
8552 };
8553 self.insert_record(record.clone())?;
8554 if let Err(error) = self.persist_state_locked() {
8555 self.remove_record_memory(&record.record_id);
8556 return Err(persistence_failure(error));
8557 }
8558 drop(transaction);
8559 self.publish_record(record.clone());
8560 Ok(record)
8561 }
8562
8563 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8564 self.session_bindings
8565 .lock()
8566 .unwrap_or_else(|poisoned| poisoned.into_inner())
8567 .iter()
8568 .find_map(|(instance_id, binding)| {
8569 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8570 workspace_id: binding.workspace_id.clone(),
8571 session: SessionKey {
8572 instance_id: *instance_id,
8573 generation: binding.generation,
8574 },
8575 })
8576 })
8577 }
8578
8579 async fn forget_session_record(
8580 &self,
8581 record_id: &SessionRecordId,
8582 ) -> Result<(), NodeFailure> {
8583 let record = self.record(record_id)?;
8584 if record.active_session.is_some()
8585 || matches!(
8586 record.state,
8587 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8588 )
8589 {
8590 return Err(failure(
8591 NodeFailureCode::SessionRecordBusy,
8592 "stop the managed session before forgetting it",
8593 ));
8594 }
8595 if let Some(address) = self.bound_address_for_record(record_id) {
8596 self.remove_session(&address).await?;
8597 }
8598 self.remove_managed_session_record(record_id, &record.workspace_id).await
8599 }
8600
8601 async fn remove_managed_session_record(
8616 &self,
8617 record_id: &SessionRecordId,
8618 workspace_id: &WorkspaceId,
8619 ) -> Result<(), NodeFailure> {
8620 let managed_lease_id = self.managed_worktrees.lock()
8621 .unwrap_or_else(|poisoned| poisoned.into_inner())
8622 .lease_for_workspace(workspace_id);
8623 if !self.destroy_record_materialization(record_id)? {
8624 let transaction = self
8625 .state_transaction
8626 .lock()
8627 .unwrap_or_else(|poisoned| poisoned.into_inner());
8628 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8629 failure(
8630 NodeFailureCode::UnknownSessionRecord,
8631 "managed session record does not exist",
8632 )
8633 })?;
8634 if let Err(error) = self.persist_state_locked() {
8635 let _ = self.insert_record(removed);
8636 return Err(persistence_failure(error));
8637 }
8638 drop(transaction);
8639 }
8640 self.publish(NodeEvent::SessionRecordRemoved {
8641 record_id: record_id.clone(),
8642 });
8643 if let Some(lease_id) = managed_lease_id {
8644 if let Some(snapshot) = self.managed_worktrees.lock()
8645 .unwrap_or_else(|poisoned| poisoned.into_inner())
8646 .get(&lease_id)
8647 .map(ManagedWorktreeLeaseRecord::snapshot)
8648 {
8649 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8650 }
8651 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8652 }
8653 Ok(())
8654 }
8655
8656 async fn retire_unavailable_session_records(&self) {
8669 let policy = self.session_record_retention;
8670 let age_enabled = policy.age_ms > 0;
8671 let keep_enabled = policy.keep_per_workspace > 0;
8672 if !age_enabled && !keep_enabled {
8673 return;
8674 }
8675 let now = unix_time_ms();
8676 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8677 self.session_records
8678 .lock()
8679 .unwrap_or_else(|poisoned| poisoned.into_inner())
8680 .records
8681 .values()
8682 .filter(|record| record.state == ManagedSessionState::Unavailable)
8683 .map(|record| {
8684 (
8685 record.record_id.clone(),
8686 record.workspace_id.clone(),
8687 record.provider.clone(),
8688 record.updated_at_unix_ms,
8689 )
8690 })
8691 .collect()
8692 };
8693 if candidates.is_empty() {
8694 return;
8695 }
8696 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8697 BTreeMap::new();
8698 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8699 by_workspace
8700 .entry(workspace_id)
8701 .or_default()
8702 .push((record_id, provider, updated_at_unix_ms));
8703 }
8704 let reason = if age_enabled && keep_enabled {
8705 "age+keep-n"
8706 } else if age_enabled {
8707 "age"
8708 } else {
8709 "keep-n"
8710 };
8711 let mut retired: u64 = 0;
8712 for (workspace_id, mut records) in by_workspace {
8713 records.sort_by(|left, right| right.2.cmp(&left.2));
8716 for (index, (record_id, provider, updated_at_unix_ms)) in
8717 records.into_iter().enumerate()
8718 {
8719 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8720 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8721 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8722 let should_retire = if age_enabled && keep_enabled {
8723 age_hit && keep_hit
8724 } else {
8725 age_hit || keep_hit
8726 };
8727 if !should_retire {
8728 continue;
8729 }
8730 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8731 Ok(()) => {
8732 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8733 retired += 1;
8734 tracing::info!(
8735 node_id = %self.node_id,
8736 record_id = %record_id,
8737 workspace_id = %workspace_id,
8738 provider = %provider,
8739 age_ms = record_age_ms,
8740 reason,
8741 "managed session record retired"
8742 );
8743 }
8744 Err(error) => tracing::warn!(
8745 node_id = %self.node_id,
8746 record_id = %record_id,
8747 workspace_id = %workspace_id,
8748 code = ?error.code,
8749 cause = %error.message,
8750 "session record retention sweep failed to retire a record"
8751 ),
8752 }
8753 }
8754 }
8755 if retired > 0 {
8756 let remaining_unavailable = self
8757 .session_records
8758 .lock()
8759 .unwrap_or_else(|poisoned| poisoned.into_inner())
8760 .records
8761 .values()
8762 .filter(|record| record.state == ManagedSessionState::Unavailable)
8763 .count();
8764 tracing::info!(
8765 node_id = %self.node_id,
8766 retired,
8767 remaining_unavailable,
8768 "session record retention sweep completed"
8769 );
8770 }
8771 }
8772
8773 async fn resume_session_record(
8774 &self,
8775 record_id: &SessionRecordId,
8776 terminal_size: gate4agent_types::TerminalSize,
8777 initial_prompt: Option<String>,
8778 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8779 let mut record = self.record(record_id)?;
8780 if record.mode == SessionMode::Acp
8783 && record.provider.as_str() == "claude"
8784 && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8785 {
8786 record.mode = SessionMode::Pty;
8787 if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8788 r.mode = SessionMode::Pty;
8789 }
8790 tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8791 }
8792 if record.active_session.is_some()
8793 || matches!(
8794 record.state,
8795 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8796 )
8797 {
8798 return Err(failure(
8799 NodeFailureCode::SessionRecordBusy,
8800 "managed session already has a live runtime binding",
8801 ));
8802 }
8803 let identity = record.provider_session.clone().ok_or_else(|| {
8804 failure(
8805 NodeFailureCode::SessionRecordNotResumable,
8806 "managed session has no verified provider session identity",
8807 )
8808 })?;
8809 let working_directory = self.workspace_root(&record.workspace_id)?;
8810 if !platform::roots_equal(
8811 &working_directory,
8812 windows_path_text(&record.canonical_root),
8813 ) {
8814 return Err(failure(
8815 NodeFailureCode::SessionRecordConflict,
8816 "managed session workspace root changed; refusing to resume in another directory",
8817 ));
8818 }
8819 {
8820 let records = self
8821 .session_records
8822 .lock()
8823 .unwrap_or_else(|poisoned| poisoned.into_inner());
8824 if records.records.values().any(|candidate| {
8825 candidate.record_id != *record_id
8826 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8827 session_registry::same_provider_session(
8828 &candidate.provider,
8829 candidate_identity,
8830 &record.provider,
8831 &identity,
8832 )
8833 })
8834 && (candidate.active_session.is_some()
8835 || matches!(
8836 candidate.state,
8837 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8838 ))
8839 }) {
8840 return Err(failure(
8841 NodeFailureCode::SessionRecordConflict,
8842 "provider session is already active through another managed record",
8843 ));
8844 }
8845 }
8846 let runtime_requirement = if initial_prompt.is_some() {
8847 ProviderRuntimeRequirement::ResumeWithPrompt
8848 } else {
8849 ProviderRuntimeRequirement::Resume
8850 };
8851 let runtime_policy = self.admit_provider_runtime(
8852 &record.provider,
8853 runtime_requirement,
8854 )
8855 .await?;
8856 let working_directory = match self.m4a_accounts.as_ref() {
8858 Some(_) => {
8859 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8860 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8861 }
8862 None => working_directory,
8863 };
8864 let request = ResumeLaunchRequest {
8865 working_directory,
8866 terminal_size,
8867 initial_prompt,
8868 };
8869 request
8870 .validate()
8871 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8872 self.ensure_binding_capacity()?;
8873 let resolved_materialization = match self.resolve_record_materialization(
8874 record_id,
8875 &record.provider,
8876 record.mode,
8877 record.environment_profile.as_ref(),
8878 record.bundle.as_ref(),
8879 record.context.as_ref(),
8880 ) {
8881 Ok(materialization) => materialization,
8882 Err(error) => {
8883 let label = match error.code {
8884 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8885 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8886 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8887 _ => "environment-profile-unavailable",
8888 };
8889 self.mark_record_error(record_id, label)?;
8890 return Err(error);
8891 }
8892 };
8893 let (materialization_id, environment_overlay) = match resolved_materialization {
8894 Some((id, overlay)) => (Some(id), overlay),
8895 None => (None, None),
8896 };
8897 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8898 let environment_overlay = {
8900 let mut args: Vec<OsString> = Vec::new();
8901 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8902 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8903 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8904 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8905 }
8906 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8907 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8908 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8909 if let Some(sel) = options.as_ref() {
8910 args.push("--model".into()); args.push(sel.model.clone().into());
8911 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8912 args.push("--effort".into()); args.push(effort.to_owned().into());
8913 }
8914 }
8915 }
8916 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8917 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8918 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8919 instance_id: instance_id.0,
8920 provider: record.provider.as_str().to_owned(),
8921 workspace_id: record.workspace_id.as_str().to_owned(),
8922 provider_session_id: identity.id.clone(),
8923 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8924 cwd: String::new(),
8925 acp: record.mode == SessionMode::Acp,
8926 options,
8927 cols: 0,
8928 rows: 0,
8929 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8930 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8931 });
8932 }
8933 }
8934 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8935 };
8936 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8938 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8939 if let Some(old) = self.bound_address_for_record(record_id) {
8940 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8941 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8942 }
8943 }
8944 let session_key = self.m4a_session_key(instance_id);
8945 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8946 }
8947 _ => environment_overlay,
8948 };
8949 let mut environment_selection = match SpawnPreparer::select_environment(self,
8950 instance_id,
8951 &record.provider,
8952 record.mode,
8953 record.environment_profile.as_ref(),
8954 ) {
8955 Ok(selection) => selection,
8956 Err(error) => {
8957 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8958 return Err(error);
8959 }
8960 };
8961 let mut instance_overlay = environment_overlay
8962 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8963 .transpose()?
8964 .flatten();
8965 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8966 self.remove_session(&stale_address).await?;
8967 }
8968 let address = SessionAddress {
8969 workspace_id: record.workspace_id.clone(),
8970 session: SessionKey {
8971 instance_id,
8972 generation: SessionGeneration(1),
8973 },
8974 };
8975 let transaction = self
8976 .state_transaction
8977 .lock()
8978 .unwrap_or_else(|poisoned| poisoned.into_inner());
8979 let current = self.record(record_id)?;
8980 if current.active_session.is_some()
8981 || matches!(
8982 current.state,
8983 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8984 )
8985 {
8986 return Err(failure(
8987 NodeFailureCode::SessionRecordBusy,
8988 "managed session became active while resume was being prepared",
8989 ));
8990 }
8991 self.bind_managed_session_with_materialization(
8992 &address,
8993 record_id.clone(),
8994 runtime_policy,
8995 record.environment_profile.clone(),
8996 record.bundle.clone(),
8997 record.context.clone(),
8998 materialization_id,
8999 );
9000 if let Some(selection) = environment_selection.take() {
9001 selection.retain();
9002 }
9003 if let Some(overlay) = instance_overlay.take() {
9004 overlay.retain();
9005 }
9006 drop(transaction);
9007
9008 let transport = match record.mode {
9009 SessionMode::Pty => TransportKind::Pty,
9010 SessionMode::Inline => TransportKind::Pipe,
9011 SessionMode::Acp => TransportKind::Acp,
9012 };
9013 let agent_id = record.provider.clone();
9014 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9015 if let Err(error) = self
9016 .dispatch_bounded(
9017 ControlCommand::Register {
9018 instance_id,
9019 agent_id,
9020 transport,
9021 },
9022 dispatch_timeout,
9023 )
9024 .await
9025 {
9026 if let Some(binding) = self.remove_binding(&address) {
9027 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9028 }
9029 return Err(error);
9030 }
9031 let command = self.prepare_command(ControlCommand::Resume {
9032 instance_id,
9033 target: ResumeTarget::ProviderSession { identity },
9034 runtime_policy,
9035 request,
9036 });
9037 let started_after = self.current_sequence();
9038 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9039 self.rollback_spawn(&address, dispatch_timeout).await?;
9040 return Err(error);
9041 }
9042 if let Err(error) = self.dispatch_envelope(command) {
9043 self.clear_armed_resume(&address);
9044 self.rollback_spawn(&address, dispatch_timeout).await?;
9045 return Err(error);
9046 }
9047 match self
9048 .wait_for_record_resume(&address, started_after)
9049 .await
9050 {
9051 Ok(settled_address) => {
9052 let managed_lease_id = {
9053 self.managed_worktrees.lock()
9054 .unwrap_or_else(|poisoned| poisoned.into_inner())
9055 .lease_for_workspace(&record.workspace_id)
9056 };
9057 if let Some(lease_id) = managed_lease_id {
9058 {
9059 let mut bindings = self.session_bindings.lock()
9060 .unwrap_or_else(|poisoned| poisoned.into_inner());
9061 let binding = bindings.get_mut(&settled_address.session.instance_id)
9062 .ok_or_else(|| failure(
9063 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9064 "resumed managed session lost its exact binding",
9065 ))?;
9066 if binding.generation != settled_address.session.generation {
9067 return Err(failure(
9068 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9069 "resumed managed session generation diverged",
9070 ));
9071 }
9072 binding.managed_worktree_lease_id = Some(lease_id.clone());
9073 }
9074 let snapshot = self.managed_worktrees.lock()
9075 .unwrap_or_else(|poisoned| poisoned.into_inner())
9076 .bind_session(
9077 &lease_id,
9078 ManagedWorktreeSessionHolder {
9079 incarnation_id: self.incarnation_id,
9080 instance_id: settled_address.session.instance_id,
9081 generation: settled_address.session.generation,
9082 },
9083 Some(record_id.clone()),
9084 unix_time_ms(),
9085 ).map_err(|message| failure(
9086 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9087 &message,
9088 ))?;
9089 if let Err(error) = self.persist_state() {
9090 let _ = self.remove_session(&settled_address).await;
9091 return Err(persistence_failure(error));
9092 }
9093 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9094 }
9095 Ok((self.record(record_id)?, settled_address))
9096 }
9097 Err(error) => {
9098 if matches!(
9099 error.code,
9100 NodeFailureCode::SessionRecordNotResumable
9101 | NodeFailureCode::BackendOperationFailed
9102 ) {
9103 if let Some(bound) = self.bound_address_for_record(record_id) {
9104 let _ = self.remove_session(&bound).await;
9105 }
9106 }
9107 Err(error)
9108 }
9109 }
9110 }
9111
9112 async fn wait_for_record_resume(
9113 &self,
9114 initial_address: &SessionAddress,
9115 after_sequence: u64,
9116 ) -> Result<SessionAddress, NodeFailure> {
9117 let deadline = Instant::now()
9118 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9119 let mut scan_after = after_sequence;
9120 loop {
9121 let events = self
9122 .history
9123 .lock()
9124 .unwrap_or_else(|poisoned| poisoned.into_inner())
9125 .events
9126 .iter()
9127 .filter(|event| event.sequence > scan_after)
9128 .cloned()
9129 .collect::<Vec<_>>();
9130 for envelope in events {
9131 scan_after = scan_after.max(envelope.sequence);
9132 let NodeEvent::Control { address, event } = envelope.event else {
9133 continue;
9134 };
9135 if address.session.instance_id != initial_address.session.instance_id {
9136 continue;
9137 }
9138 match event.event {
9139 ControlEventKind::Resumed { .. } => return Ok(address),
9140 ControlEventKind::ResumeDenied { reason } => {
9141 return Err(failure(
9142 NodeFailureCode::SessionRecordNotResumable,
9143 &reason,
9144 ));
9145 }
9146 ControlEventKind::ResumeFailed { message }
9147 | ControlEventKind::CommandRejected { message } => {
9148 return Err(failure(
9149 NodeFailureCode::BackendOperationFailed,
9150 &message,
9151 ));
9152 }
9153 _ => {}
9154 }
9155 }
9156 if Instant::now() >= deadline {
9157 return Err(failure(
9158 NodeFailureCode::BackendBusy,
9159 "managed resume did not settle before the bounded deadline",
9160 ));
9161 }
9162 sleep(Duration::from_millis(5)).await;
9163 }
9164 }
9165
9166 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9167 self.workspaces
9168 .read()
9169 .unwrap_or_else(|poisoned| poisoned.into_inner())
9170 .get(workspace_id)
9171 .cloned()
9172 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9173 }
9174
9175 fn workspace_roots(&self) -> Vec<PathBuf> {
9176 self.workspaces
9177 .read()
9178 .unwrap_or_else(|poisoned| poisoned.into_inner())
9179 .values()
9180 .map(PathBuf::from)
9181 .collect()
9182 }
9183
9184 async fn inspect_workspace(
9185 &self,
9186 workspace_id: WorkspaceId,
9187 ) -> Result<WorkspaceInspection, NodeFailure> {
9188 let _permit = self
9189 .inspection_slots
9190 .clone()
9191 .try_acquire_owned()
9192 .map_err(|_| {
9193 tracing::warn!(
9194 workspace_id = %workspace_id,
9195 "workspace inspection rejected: inspection capacity is busy",
9196 );
9197 failure(
9198 NodeFailureCode::BackendBusy,
9199 "workspace inspection capacity is busy",
9200 )
9201 })?;
9202 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9203 tracing::warn!(
9204 workspace_id = %workspace_id,
9205 code = ?error.code,
9206 "workspace inspection rejected: workspace is not registered",
9207 );
9208 error
9209 })?;
9210 let tree_root = canonical_root.clone();
9211 let started_at = Instant::now();
9212 let time_budget_ms = workspace_inspection_time_budget_ms();
9213 let entry_cap = workspace_inspection_entry_cap();
9214 let deadline = started_at + Duration::from_millis(time_budget_ms);
9215 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9216 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9217 })
9218 .await
9219 .map_err(|error| {
9220 tracing::warn!(
9221 workspace_id = %workspace_id,
9222 error = %error,
9223 "workspace inspection rejected: walk task failed",
9224 );
9225 failure(
9226 NodeFailureCode::BackendOperationFailed,
9227 &format!("workspace inspection task failed: {error}"),
9228 )
9229 })?;
9230 let (mut git, git_time_budget_exceeded) =
9231 inspect_git_workspace(&canonical_root, deadline).await;
9232 if !git.worktrees.is_empty() {
9233 let registered = self
9234 .workspaces
9235 .read()
9236 .unwrap_or_else(|poisoned| poisoned.into_inner())
9237 .iter()
9238 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9239 .collect::<Vec<_>>();
9240 for worktree in &mut git.worktrees {
9241 worktree.workspace_id = registered
9242 .iter()
9243 .find(|(_, root)| {
9244 worktree_paths_equal(root, windows_path_text(&worktree.path))
9245 })
9246 .map(|(workspace_id, _)| workspace_id.clone());
9247 }
9248 }
9249 if git.is_repository {
9250 git.managed_worktree = self
9251 .managed_worktrees
9252 .lock()
9253 .unwrap_or_else(|poisoned| poisoned.into_inner())
9254 .git_scope(&workspace_id, git.branch.as_deref());
9255 }
9256 let truncation = workspace_inspection_truncation(
9257 &walk_budget,
9258 git_time_budget_exceeded,
9259 started_at.elapsed(),
9260 );
9261 if let Some(truncation) = &truncation {
9262 tracing::info!(
9263 workspace_id = %workspace_id,
9264 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9265 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9266 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9267 entries_visited = truncation.entries_visited,
9268 elapsed_ms = truncation.elapsed_ms,
9269 "workspace inspection truncated by its inner budget",
9270 );
9271 }
9272 Ok(WorkspaceInspection {
9273 workspace_id,
9274 entries,
9275 tree_truncated,
9276 git,
9277 truncation,
9278 })
9279 }
9280
9281 async fn browse_host_directories(
9282 &self,
9283 directory: Option<OpaqueHostPath>,
9284 after: Option<OpaqueHostPath>,
9285 ) -> Result<HostDirectoryListing, NodeFailure> {
9286 let permit = self
9287 .inspection_slots
9288 .clone()
9289 .try_acquire_owned()
9290 .map_err(|_| {
9291 failure(
9292 NodeFailureCode::BackendBusy,
9293 "host directory browse capacity is busy",
9294 )
9295 })?;
9296 let task = tokio::task::spawn_blocking(move || {
9297 let _permit = permit;
9298 browse_host_directories(directory, after)
9299 });
9300 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9301 .await
9302 .map_err(|_| {
9303 failure(
9304 NodeFailureCode::HostDirectoryReadTimedOut,
9305 "host directory browse exceeded its bounded deadline",
9306 )
9307 })?
9308 .map_err(|_| {
9309 failure(
9310 NodeFailureCode::HostDirectoryReadFailed,
9311 "host directory browse task failed",
9312 )
9313 })?
9314 .map_err(host_directory_failure)
9315 }
9316
9317 async fn read_workspace_file(
9318 &self,
9319 workspace_id: WorkspaceId,
9320 path: RepositoryPath,
9321 ) -> Result<WorkspaceFileRead, NodeFailure> {
9322 let canonical_root = self.workspace_root(&workspace_id)?;
9323 let permit = self
9324 .inspection_slots
9325 .clone()
9326 .try_acquire_owned()
9327 .map_err(|_| {
9328 failure(
9329 NodeFailureCode::BackendBusy,
9330 "workspace file read capacity is busy",
9331 )
9332 })?;
9333 let path_to_read = path.clone();
9334 let task = tokio::task::spawn_blocking(move || {
9335 let _permit = permit;
9336 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9337 });
9338 let content = timeout(
9339 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9340 task,
9341 )
9342 .await
9343 .map_err(|_| {
9344 failure(
9345 NodeFailureCode::RepositoryFileReadTimedOut,
9346 "repository file read exceeded its bounded deadline",
9347 )
9348 })?
9349 .map_err(|_| {
9350 failure(
9351 NodeFailureCode::RepositoryFileReadFailed,
9352 "repository file read task failed",
9353 )
9354 })?
9355 .map_err(workspace_file_failure)?;
9356 let content = match content {
9357 WorkspaceFileBytes::Utf8(text) => {
9358 let revision = Some(workspace_file_revision(text.as_bytes()));
9359 return Ok(WorkspaceFileRead {
9360 workspace_id,
9361 path,
9362 content: WorkspaceFileContent::Utf8 {
9363 byte_len: u32::try_from(text.len())
9364 .expect("bounded workspace text length must fit u32"),
9365 text,
9366 },
9367 revision,
9368 });
9369 }
9370 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9371 WorkspaceFileContent::NonUtf8 {
9372 byte_len: u32::try_from(byte_length)
9373 .expect("bounded workspace file length must fit u32"),
9374 }
9375 }
9376 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9377 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9378 },
9379 };
9380 Ok(WorkspaceFileRead {
9381 workspace_id,
9382 path,
9383 content,
9384 revision: None,
9385 })
9386 }
9387
9388 async fn write_workspace_file(
9389 &self,
9390 workspace_id: WorkspaceId,
9391 path: RepositoryPath,
9392 expected_revision: WorkspaceFileRevision,
9393 text: String,
9394 ) -> Result<WorkspaceFileRead, NodeFailure> {
9395 let canonical_root = self.workspace_root(&workspace_id)?;
9396 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9397 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9398 })?;
9399 let path_to_write = path.clone();
9400 let expected = expected_revision.as_str().to_owned();
9401 let text_to_write = text.clone();
9402 let task = tokio::task::spawn_blocking(move || {
9403 let _permit = permit;
9404 write_workspace_file_to_disk(
9405 Path::new(&canonical_root),
9406 &path_to_write,
9407 &expected,
9408 &text_to_write,
9409 )
9410 });
9411 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9412 .await
9413 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9414 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9415 .map_err(workspace_file_write_failure)?;
9416 Ok(WorkspaceFileRead {
9417 workspace_id,
9418 path,
9419 content: WorkspaceFileContent::Utf8 {
9420 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9421 text,
9422 },
9423 revision: Some(WorkspaceFileRevision::new(revision)
9424 .expect("workspace writer returns a valid SHA-256 revision")),
9425 })
9426 }
9427
9428 async fn create_workspace_file(
9429 &self,
9430 workspace_id: WorkspaceId,
9431 path: RepositoryPath,
9432 ) -> Result<WorkspaceFileRead, NodeFailure> {
9433 let canonical_root = self.workspace_root(&workspace_id)?;
9434 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9435 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9436 })?;
9437 let path_to_create = path.clone();
9438 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9439 let task_commit_state = Arc::clone(&commit_state);
9440 let task = tokio::task::spawn_blocking(move || {
9441 let _permit = permit;
9442 create_workspace_file_on_disk(
9443 Path::new(&canonical_root),
9444 &path_to_create,
9445 &task_commit_state,
9446 )
9447 });
9448 let revision = settle_workspace_entry_create(
9449 task,
9450 commit_state,
9451 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9452 "repository file create exceeded its bounded deadline",
9453 "repository file create task failed",
9454 )
9455 .await?;
9456 Ok(WorkspaceFileRead {
9457 workspace_id,
9458 path,
9459 content: WorkspaceFileContent::Utf8 {
9460 text: String::new(),
9461 byte_len: 0,
9462 },
9463 revision: Some(WorkspaceFileRevision::new(revision)
9464 .expect("workspace creator returns a valid SHA-256 revision")),
9465 })
9466 }
9467
9468 async fn create_workspace_directory(
9469 &self,
9470 workspace_id: WorkspaceId,
9471 path: RepositoryPath,
9472 ) -> Result<WorkspaceEntry, NodeFailure> {
9473 let canonical_root = self.workspace_root(&workspace_id)?;
9474 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9475 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9476 })?;
9477 let path_to_create = path.clone();
9478 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9479 let task_commit_state = Arc::clone(&commit_state);
9480 let task = tokio::task::spawn_blocking(move || {
9481 let _permit = permit;
9482 create_workspace_directory_on_disk(
9483 Path::new(&canonical_root),
9484 &path_to_create,
9485 &task_commit_state,
9486 )
9487 });
9488 settle_workspace_entry_create(
9489 task,
9490 commit_state,
9491 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9492 "repository directory create exceeded its bounded deadline",
9493 "repository directory create task failed",
9494 )
9495 .await?;
9496 Ok(WorkspaceEntry {
9497 relative_path: path,
9498 kind: WorkspaceEntryKind::Directory,
9499 })
9500 }
9501
9502 async fn read_git_history(
9503 &self,
9504 workspace_id: WorkspaceId,
9505 path: Option<RepositoryPath>,
9506 before: Option<GitObjectId>,
9507 limit: u16,
9508 ) -> Result<GitHistoryPage, NodeFailure> {
9509 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9510 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9511 })?;
9512 let root = self.workspace_root(&workspace_id)?;
9513 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9514 }
9515
9516 async fn read_git_diff(
9517 &self,
9518 workspace_id: WorkspaceId,
9519 request: GitDiffRequest,
9520 ) -> Result<GitDiff, NodeFailure> {
9521 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9522 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9523 })?;
9524 let root = self.workspace_root(&workspace_id)?;
9525 read_git_diff_bounded(&root, request).await
9526 }
9527
9528 fn native_session_route_root(
9529 &self,
9530 route: &NativeSessionCatalogRoute,
9531 ) -> Result<Option<String>, NodeFailure> {
9532 route
9533 .validate()
9534 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9535 match route.scope {
9536 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9537 .workspace_id
9538 .as_ref()
9539 .map(|workspace_id| self.workspace_root(workspace_id))
9540 .transpose(),
9541 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9542 }
9543 }
9544
9545 fn project_native_session_entries(
9546 &self,
9547 route: &NativeSessionCatalogRoute,
9548 entries: Vec<ScopedNativeSessionCatalogEntry>,
9549 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9550 let current_root = route
9551 .workspace_id
9552 .as_ref()
9553 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9554 let records = self
9555 .session_records
9556 .lock()
9557 .unwrap_or_else(|poisoned| poisoned.into_inner());
9558 let mut seen_records = std::collections::HashSet::new();
9559 entries
9560 .into_iter()
9561 .map(|entry| {
9562 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9563 let matches = records
9564 .records
9565 .values()
9566 .filter(|record| {
9567 record.provider == route.provider
9568 && record.workspace_id == *workspace_id
9569 && current_root.as_deref().is_some_and(|root| {
9570 platform::roots_equal(
9571 root,
9572 windows_path_text(&record.canonical_root),
9573 )
9574 })
9575 && record.provider_session.as_ref().is_some_and(|identity| {
9576 session_registry::same_provider_session(
9577 &record.provider,
9578 identity,
9579 &route.provider,
9580 &entry.provider_session,
9581 )
9582 })
9583 })
9584 .map(|record| record.record_id.clone())
9585 .collect::<Vec<_>>();
9586 (matches.len() == 1
9587 && seen_records.insert(matches[0].clone()))
9588 .then(|| matches[0].clone())
9589 });
9590 let metadata = entry.metadata;
9591 crate::protocol::NativeSessionCatalogEntry {
9592 selection_id: metadata.selection_id,
9593 title: metadata.title,
9594 modified_at_unix_ms: metadata.modified_at_unix_ms,
9595 model: metadata.model,
9596 message_count: metadata.message_count,
9597 completed_turn_count: metadata.completed_turn_count,
9598 external_group: entry.external_group,
9599 record_id,
9600 }
9601 })
9602 .collect()
9603 }
9604
9605 async fn catalog_native_sessions(
9606 &self,
9607 route: NativeSessionCatalogRoute,
9608 limit: u16,
9609 ) -> Result<(
9610 Vec<crate::protocol::NativeSessionCatalogEntry>,
9611 crate::protocol::NativeSessionCatalogSummary,
9612 ), NodeFailure> {
9613 let canonical_root = self.native_session_route_root(&route)?;
9614 let registered_workspace_roots = self.workspace_roots();
9615 let provider = route.provider.clone();
9616 let scope = route.scope;
9617 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9618 failure(
9619 NodeFailureCode::InvalidRequest,
9620 "native session catalog is not configured",
9621 )
9622 })?;
9623 let _permit = Arc::clone(&self.inspection_slots)
9624 .try_acquire_owned()
9625 .map_err(|_| {
9626 failure(
9627 NodeFailureCode::BackendBusy,
9628 "native session catalog capacity is busy",
9629 )
9630 })?;
9631 let page = run_native_session_catalog_operation(
9632 catalog,
9633 "native session catalog",
9634 move |catalog| {
9635 catalog.catalog_initial_for_scope(
9636 &provider,
9637 scope,
9638 canonical_root.as_deref().map(Path::new),
9639 ®istered_workspace_roots,
9640 limit,
9641 )
9642 },
9643 )
9644 .await?;
9645 let summary = crate::protocol::NativeSessionCatalogSummary {
9646 catalog_revision: page.revision,
9647 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9648 recent_total_count: page.recent_total_count,
9649 older_total_count: page.older_total_count,
9650 recent_next_after_selection_id: page.next_after_selection_id,
9651 recent_has_more: page.remaining_count > 0,
9652 };
9653 Ok((self.project_native_session_entries(&route, page.entries), summary))
9654 }
9655
9656 async fn page_native_sessions(
9657 &self,
9658 route: NativeSessionCatalogRoute,
9659 window: crate::protocol::NativeSessionCatalogWindow,
9660 catalog_revision: u64,
9661 recent_cutoff_unix_ms: u64,
9662 after_selection_id: Option<String>,
9663 limit: u16,
9664 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9665 let canonical_root = self.native_session_route_root(&route)?;
9666 let registered_workspace_roots = self.workspace_roots();
9667 let provider = route.provider.clone();
9668 let scope = route.scope;
9669 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9670 failure(
9671 NodeFailureCode::InvalidRequest,
9672 "native session catalog is not configured",
9673 )
9674 })?;
9675 let _permit = Arc::clone(&self.inspection_slots)
9676 .try_acquire_owned()
9677 .map_err(|_| {
9678 failure(
9679 NodeFailureCode::BackendBusy,
9680 "native session catalog capacity is busy",
9681 )
9682 })?;
9683 let page = run_native_session_catalog_operation(
9684 catalog,
9685 "native session catalog page",
9686 move |catalog| {
9687 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9688 &provider,
9689 scope,
9690 canonical_root.as_deref().map(Path::new),
9691 ®istered_workspace_roots,
9692 window,
9693 catalog_revision,
9694 recent_cutoff_unix_ms,
9695 after_selection_id.as_deref(),
9696 limit,
9697 ))
9698 },
9699 )
9700 .await?;
9701 let page = page.map_err(|error| match error {
9702 NativeSessionCatalogError::StaleCatalog => failure(
9703 NodeFailureCode::StaleNativeSessionCatalog,
9704 "native session catalog revision or cursor is stale",
9705 ),
9706 _ => failure(
9707 NodeFailureCode::BackendOperationFailed,
9708 "native session catalog page failed",
9709 ),
9710 })?;
9711 let entries = self.project_native_session_entries(&route, page.entries);
9712 Ok(crate::protocol::NativeSessionCatalogPage {
9713 window: page.window,
9714 revision: page.revision,
9715 entries,
9716 next_after_selection_id: page.next_after_selection_id,
9717 remaining_count: page.remaining_count,
9718 has_more: page.remaining_count > 0,
9719 })
9720 }
9721
9722 async fn preview_native_session(
9723 &self,
9724 selection: NativeSessionSelection,
9725 message_limit: u16,
9726 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9727 selection
9728 .validate()
9729 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9730 let canonical_root = self.native_session_route_root(&selection.route)?;
9731 let registered_workspace_roots = self.workspace_roots();
9732 let provider = selection.route.provider.clone();
9733 let scope = selection.route.scope;
9734 let catalog_revision = selection.catalog_revision;
9735 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9736 let selection_id = selection.selection_id.clone();
9737 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9738 failure(
9739 NodeFailureCode::InvalidRequest,
9740 "native session preview is not configured",
9741 )
9742 })?;
9743 let _permit = Arc::clone(&self.inspection_slots)
9744 .try_acquire_owned()
9745 .map_err(|_| {
9746 failure(
9747 NodeFailureCode::BackendBusy,
9748 "native session preview capacity is busy",
9749 )
9750 })?;
9751 let preview = run_native_session_catalog_operation(
9752 catalog,
9753 "native session preview",
9754 move |catalog| {
9755 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9756 &provider,
9757 scope,
9758 canonical_root.as_deref().map(Path::new),
9759 ®istered_workspace_roots,
9760 catalog_revision,
9761 recent_cutoff_unix_ms,
9762 &selection_id,
9763 message_limit,
9764 ))
9765 },
9766 )
9767 .await?;
9768 preview
9769 .map(Into::into)
9770 .map_err(native_session_preview_failure)
9771 }
9772
9773 async fn index_native_session(
9774 &self,
9775 selection: NativeSessionSelection,
9776 display_name: String,
9777 ) -> Result<ManagedSessionRecord, NodeFailure> {
9778 selection
9779 .validate()
9780 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9781 if selection.route.scope
9782 != gate4agent_types::NativeSessionCatalogScope::Workspace
9783 {
9784 return Err(failure(
9785 NodeFailureCode::WorkspaceRegistrationRequired,
9786 "register the external project as a workspace before indexing or resuming it",
9787 ));
9788 }
9789 let workspace_id = selection
9790 .route
9791 .workspace_id
9792 .clone()
9793 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9794 let canonical_root = self
9795 .native_session_route_root(&selection.route)?
9796 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9797 let registered_workspace_roots = self.workspace_roots();
9798 let provider = selection.route.provider.clone();
9799 let catalog_revision = selection.catalog_revision;
9800 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9801 let selection_id = selection.selection_id.clone();
9802 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9803 failure(
9804 NodeFailureCode::InvalidRequest,
9805 "native session index is not configured",
9806 )
9807 })?;
9808 let _permit = Arc::clone(&self.inspection_slots)
9809 .try_acquire_owned()
9810 .map_err(|_| {
9811 failure(
9812 NodeFailureCode::BackendBusy,
9813 "native session index capacity is busy",
9814 )
9815 })?;
9816 let resolved = run_native_session_catalog_operation(
9817 catalog,
9818 "native session index resolution",
9819 move |catalog| {
9820 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9821 &provider,
9822 gate4agent_types::NativeSessionCatalogScope::Workspace,
9823 Some(Path::new(&canonical_root)),
9824 ®istered_workspace_roots,
9825 catalog_revision,
9826 recent_cutoff_unix_ms,
9827 &selection_id,
9828 ))
9829 },
9830 )
9831 .await?
9832 .map_err(native_session_preview_failure)?;
9833 self.index_provider_session_with_policy(
9834 workspace_id,
9835 selection.route.provider,
9836 resolved.identity,
9837 display_name,
9838 true,
9839 )
9840 }
9841
9842 async fn preview_native_session_by_session_id(
9843 &self,
9844 workspace_id: WorkspaceId,
9845 provider: AgentId,
9846 session_id: String,
9847 message_limit: u16,
9848 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9849 let canonical_root = self.workspace_root(&workspace_id)?;
9850 let registered_workspace_roots = self.workspace_roots();
9851 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9852 failure(
9853 NodeFailureCode::InvalidRequest,
9854 "native session preview is not configured",
9855 )
9856 })?;
9857 let _permit = Arc::clone(&self.inspection_slots)
9858 .try_acquire_owned()
9859 .map_err(|_| {
9860 failure(
9861 NodeFailureCode::BackendBusy,
9862 "native session preview capacity is busy",
9863 )
9864 })?;
9865 run_native_session_catalog_operation(
9866 catalog,
9867 "native session preview",
9868 move |catalog| {
9869 catalog.preview_session_id_for_workspace(
9870 &provider,
9871 Path::new(&canonical_root),
9872 ®istered_workspace_roots,
9873 &session_id,
9874 message_limit,
9875 )
9876 },
9877 )
9878 .await
9879 }
9880
9881 async fn register_workspace(
9882 &self,
9883 workspace_id: WorkspaceId,
9884 root: String,
9885 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9886 validate_workspace_request_root(&root)?;
9887 let canonicalize_workspace_id = workspace_id.clone();
9888 let workspace = tokio::task::spawn_blocking(move || {
9889 WorkspaceConfig::new(canonicalize_workspace_id, root)
9890 })
9891 .await
9892 .map_err(|error| {
9893 failure(
9894 NodeFailureCode::BackendOperationFailed,
9895 &format!("workspace canonicalization task failed: {error}"),
9896 )
9897 })?
9898 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9899 let transaction = self
9900 .state_transaction
9901 .lock()
9902 .unwrap_or_else(|poisoned| poisoned.into_inner());
9903 let mut workspaces = self
9904 .workspaces
9905 .write()
9906 .unwrap_or_else(|poisoned| poisoned.into_inner());
9907 if workspaces.contains_key(&workspace_id) {
9908 return Err(failure(
9909 NodeFailureCode::DuplicateWorkspaceId,
9910 "workspace ID is already registered",
9911 ));
9912 }
9913 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9914 platform::roots_equal(existing_root, workspace.canonical_root())
9915 }) {
9916 return Err(failure(
9917 NodeFailureCode::DuplicateWorkspaceRoot,
9918 &format!("workspace root is already registered as '{existing_id}'"),
9919 ));
9920 }
9921 {
9922 let records = self
9923 .session_records
9924 .lock()
9925 .unwrap_or_else(|poisoned| poisoned.into_inner());
9926 if let Some(record) = records.records.values().find(|record| {
9927 record.workspace_id == workspace_id
9928 && !record
9929 .canonical_root
9930 .as_utf8()
9931 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9932 }) {
9933 return Err(failure(
9934 NodeFailureCode::SessionRecordConflict,
9935 &format!(
9936 "workspace ID is retained by session record '{}' at another root",
9937 record.record_id,
9938 ),
9939 ));
9940 }
9941 }
9942 let snapshot = WorkspaceSnapshot {
9943 workspace_id: workspace_id.clone(),
9944 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9945 sessions: Vec::new(),
9946 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9947 managed_worktree_profiles: Some(WorktreeProfileInventory {
9948 profiles: Vec::new(),
9949 }),
9950 };
9951 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9952 drop(workspaces);
9953 let previous_records = {
9954 let mut records = self
9955 .session_records
9956 .lock()
9957 .unwrap_or_else(|poisoned| poisoned.into_inner());
9958 let previous = records.records.clone();
9959 for record in records.records.values_mut().filter(|record| {
9960 record.workspace_id == workspace_id
9961 && record
9962 .canonical_root
9963 .as_utf8()
9964 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9965 }) {
9966 if record.active_session.is_none() {
9967 record.state = detached_record_state(record);
9968 }
9969 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9970 record.last_error = None;
9971 }
9972 record.updated_at_unix_ms = unix_time_ms();
9973 }
9974 previous
9975 };
9976 if let Err(error) = self.persist_state_locked() {
9977 self.workspaces
9978 .write()
9979 .unwrap_or_else(|poisoned| poisoned.into_inner())
9980 .remove(&workspace_id);
9981 self.session_records
9982 .lock()
9983 .unwrap_or_else(|poisoned| poisoned.into_inner())
9984 .records = previous_records;
9985 return Err(persistence_failure(error));
9986 }
9987 let restored = self
9988 .session_records
9989 .lock()
9990 .unwrap_or_else(|poisoned| poisoned.into_inner())
9991 .records
9992 .values()
9993 .filter(|record| record.workspace_id == workspace_id)
9994 .cloned()
9995 .collect::<Vec<_>>();
9996 drop(transaction);
9997 self.publish(NodeEvent::WorkspaceAdded {
9998 workspace: snapshot.clone(),
9999 });
10000 for record in restored {
10001 self.publish_record(record);
10002 }
10003 Ok(snapshot)
10004 }
10005
10006 async fn create_standalone_workspace(
10007 &self,
10008 workspace_id: WorkspaceId,
10009 root: String,
10010 initial_branch: Option<String>,
10011 ) -> Result<WorkspaceSnapshot, NodeFailure> {
10012 validate_workspace_request_root(&root)?;
10013 validate_git_revision("initial branch", initial_branch.as_deref())?;
10014 let preflight_workspace_id = workspace_id.clone();
10015 let preflight_root = root.clone();
10016 let canonical_candidate = tokio::task::spawn_blocking(move || {
10017 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10018 })
10019 .await
10020 .map_err(|_| failure(
10021 NodeFailureCode::BackendOperationFailed,
10022 "standalone workspace root preflight task failed",
10023 ))?
10024 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10025 {
10026 let workspaces = self
10027 .workspaces
10028 .read()
10029 .unwrap_or_else(|poisoned| poisoned.into_inner());
10030 if workspaces.contains_key(&workspace_id) {
10031 return Err(failure(
10032 NodeFailureCode::DuplicateWorkspaceId,
10033 "workspace ID is already registered",
10034 ));
10035 }
10036 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10037 platform::roots_equal(existing_root, &canonical_candidate)
10038 }) {
10039 return Err(failure(
10040 NodeFailureCode::DuplicateWorkspaceRoot,
10041 &format!("workspace root is already registered as '{existing_id}'"),
10042 ));
10043 }
10044 }
10045 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10046 .await
10047 .map_err(standalone_workspace_failure)?;
10048 prepared
10049 .verify_for_registration()
10050 .await
10051 .map_err(standalone_workspace_failure)?;
10052 match self
10053 .register_workspace(workspace_id, prepared.root().to_owned())
10054 .await
10055 {
10056 Ok(workspace) => Ok(workspace),
10057 Err(registration_error) => {
10058 if prepared.compensate_registration_failure().await {
10059 Err(registration_error)
10060 } else {
10061 Err(failure(
10062 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10063 "standalone workspace initialization succeeded but registration failed; recovery is required",
10064 ))
10065 }
10066 }
10067 }
10068 }
10069
10070 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10071 let bindings = self
10072 .session_bindings
10073 .lock()
10074 .unwrap_or_else(|poisoned| poisoned.into_inner());
10075 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10076 return Err(failure(
10077 NodeFailureCode::WorkspaceBusy,
10078 "workspace retains a bound session; remove every session before unregistering it",
10079 ));
10080 }
10081 drop(bindings);
10082 let transaction = self
10083 .state_transaction
10084 .lock()
10085 .unwrap_or_else(|poisoned| poisoned.into_inner());
10086 let mut workspaces = self
10087 .workspaces
10088 .write()
10089 .unwrap_or_else(|poisoned| poisoned.into_inner());
10090 if !workspaces.contains_key(workspace_id) {
10091 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10092 }
10093 if workspaces.len() == 1 {
10094 return Err(failure(
10095 NodeFailureCode::LastWorkspace,
10096 "node must retain at least one workspace",
10097 ));
10098 }
10099 let removed_root = workspaces
10100 .remove(workspace_id)
10101 .expect("workspace presence was validated");
10102 drop(workspaces);
10103 let previous_records = {
10104 let mut records = self
10105 .session_records
10106 .lock()
10107 .unwrap_or_else(|poisoned| poisoned.into_inner());
10108 let previous = records.records.clone();
10109 for record in records
10110 .records
10111 .values_mut()
10112 .filter(|record| &record.workspace_id == workspace_id)
10113 {
10114 record.active_session = None;
10115 record.state = ManagedSessionState::Unavailable;
10116 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10117 record.updated_at_unix_ms = unix_time_ms();
10118 }
10119 previous
10120 };
10121 if let Err(error) = self.persist_state_locked() {
10122 self.workspaces
10123 .write()
10124 .unwrap_or_else(|poisoned| poisoned.into_inner())
10125 .insert(workspace_id.clone(), removed_root);
10126 self.session_records
10127 .lock()
10128 .unwrap_or_else(|poisoned| poisoned.into_inner())
10129 .records = previous_records;
10130 return Err(persistence_failure(error));
10131 }
10132 let unavailable = self
10133 .session_records
10134 .lock()
10135 .unwrap_or_else(|poisoned| poisoned.into_inner())
10136 .records
10137 .values()
10138 .filter(|record| &record.workspace_id == workspace_id)
10139 .cloned()
10140 .collect::<Vec<_>>();
10141 drop(transaction);
10142 self.publish(NodeEvent::WorkspaceRemoved {
10143 workspace_id: workspace_id.clone(),
10144 });
10145 for record in unavailable {
10146 self.publish_record(record);
10147 }
10148 Ok(())
10149 }
10150
10151 async fn create_worktree(
10152 &self,
10153 source_workspace_id: WorkspaceId,
10154 workspace_id: WorkspaceId,
10155 target_root: String,
10156 branch: String,
10157 base: Option<String>,
10158 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10159 self.require_worktree_service(&source_workspace_id)?;
10160 validate_workspace_request_root(&target_root)?;
10161 validate_git_revision("worktree base", base.as_deref())?;
10162 let source_root = self.workspace_root(&source_workspace_id)?;
10163 {
10164 let workspaces = self
10165 .workspaces
10166 .read()
10167 .unwrap_or_else(|poisoned| poisoned.into_inner());
10168 if workspaces.contains_key(&workspace_id) {
10169 return Err(failure(
10170 NodeFailureCode::DuplicateWorkspaceId,
10171 "workspace ID is already registered",
10172 ));
10173 }
10174 if let Some((existing_id, _)) = workspaces
10175 .iter()
10176 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10177 {
10178 return Err(failure(
10179 NodeFailureCode::DuplicateWorkspaceRoot,
10180 &format!("workspace root is already registered as '{existing_id}'"),
10181 ));
10182 }
10183 }
10184 let mut worktree = create_git_worktree(
10185 &source_root,
10186 &target_root,
10187 &branch,
10188 base.as_deref(),
10189 )
10190 .await
10191 .map_err(git_worktree_failure)?;
10192 let workspace = match self
10193 .register_workspace(workspace_id, worktree.path.clone())
10194 .await
10195 {
10196 Ok(workspace) => workspace,
10197 Err(registration_error) => {
10198 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10199 return Err(failure(
10200 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10201 "worktree registration failed and compensation requires recovery",
10202 ));
10203 }
10204 return Err(failure(
10205 registration_error.code,
10206 ®istration_error.message,
10207 ));
10208 }
10209 };
10210 worktree.workspace_id = Some(workspace.workspace_id.clone());
10211 Ok((protocol_worktree(worktree), workspace))
10212 }
10213
10214 async fn remove_worktree(
10215 &self,
10216 source_workspace_id: WorkspaceId,
10217 target_root: String,
10218 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10219 self.require_worktree_service(&source_workspace_id)?;
10220 validate_workspace_request_root(&target_root)?;
10221 let source_root = self.workspace_root(&source_workspace_id)?;
10222 let target_root = normalize_worktree_removal_target(&target_root)
10223 .map_err(git_worktree_failure)?;
10224 let target = list_git_worktrees(&source_root)
10225 .await
10226 .map_err(git_worktree_failure)?
10227 .into_iter()
10228 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10229 .ok_or_else(|| {
10230 failure(
10231 NodeFailureCode::WorktreeProtected,
10232 "refusing to remove a path that is not in Git's worktree listing",
10233 )
10234 })?;
10235 let registered_workspace_id = {
10236 let workspaces = self
10237 .workspaces
10238 .read()
10239 .unwrap_or_else(|poisoned| poisoned.into_inner());
10240 let matched = workspaces
10241 .iter()
10242 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10243 .map(|(workspace_id, _)| workspace_id.clone());
10244 if matched.is_some() && workspaces.len() == 1 {
10245 return Err(failure(
10246 NodeFailureCode::LastWorkspace,
10247 "node must retain at least one workspace",
10248 ));
10249 }
10250 matched
10251 };
10252 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10253 let bindings = self
10254 .session_bindings
10255 .lock()
10256 .unwrap_or_else(|poisoned| poisoned.into_inner());
10257 if bindings
10258 .values()
10259 .any(|binding| &binding.workspace_id == workspace_id)
10260 {
10261 return Err(failure(
10262 NodeFailureCode::WorkspaceBusy,
10263 "worktree workspace retains a bound session; remove every session first",
10264 ));
10265 }
10266 }
10267 remove_git_worktree(&source_root, &target.path)
10268 .await
10269 .map_err(git_worktree_failure)?;
10270 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10271 self.unregister_workspace(workspace_id)
10272 .map_err(|error| failure(error.code, &error.message))?;
10273 }
10274 Ok(registered_workspace_id)
10275 }
10276
10277 async fn cleanup_managed_worktree(
10278 &self,
10279 lease_id: &ManagedWorktreeLeaseId,
10280 explicit: bool,
10281 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10282 let lease = self.managed_worktrees.lock()
10283 .unwrap_or_else(|poisoned| poisoned.into_inner())
10284 .get(lease_id)
10285 .cloned()
10286 .ok_or_else(|| failure(
10287 NodeFailureCode::UnknownManagedWorktreeLease,
10288 "managed worktree lease does not exist",
10289 ))?;
10290 if lease.state == ManagedWorktreeLeaseState::Removed {
10291 return Ok(lease.snapshot());
10292 }
10293 if lease.has_holders() {
10294 return Err(failure(
10295 NodeFailureCode::ManagedWorktreeBusy,
10296 "managed worktree lease still has a session or durable-record holder",
10297 ));
10298 }
10299 if self
10300 .materializations
10301 .lock()
10302 .unwrap_or_else(|poisoned| poisoned.into_inner())
10303 .values()
10304 .any(|record| record.managed_lease_id() == Some(lease_id))
10305 {
10306 return Err(failure(
10307 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10308 "managed worktree cleanup is blocked by session environment ownership",
10309 ));
10310 }
10311 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10312 let snapshot = {
10313 let mut registry = self.managed_worktrees.lock()
10314 .unwrap_or_else(|poisoned| poisoned.into_inner());
10315 let current = registry.get_mut(lease_id)
10316 .expect("managed worktree lease remains present");
10317 current.state = ManagedWorktreeLeaseState::Retained;
10318 current.cleanup_failure = None;
10319 current.updated_at_unix_ms = unix_time_ms();
10320 current.snapshot()
10321 };
10322 if self.persist_state().is_err() {
10323 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10324 }
10325 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10326 return Ok(snapshot);
10327 }
10328 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10329 if profile.revision() != &lease.profile_revision {
10330 self.mark_managed_recovery_required(
10331 lease_id,
10332 ManagedWorktreeCleanupFailure::OwnershipConflict,
10333 );
10334 return Err(failure(
10335 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10336 "managed worktree profile revision changed",
10337 ));
10338 }
10339 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10340 self.mark_managed_recovery_required(
10341 lease_id,
10342 ManagedWorktreeCleanupFailure::OwnershipConflict,
10343 );
10344 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10345 })?;
10346 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10347 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10348 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10349 managed_git_worktree_failure(error)
10350 })?;
10351 let target = listed.iter()
10352 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10353 if let Some(target) = target {
10354 if target.is_main || target.is_bare || !exact_owned_worktree(
10355 &lease,
10356 &target.path,
10357 target.branch.as_deref(),
10358 &target.head,
10359 ) {
10360 self.mark_managed_recovery_required(
10361 lease_id,
10362 ManagedWorktreeCleanupFailure::OwnershipConflict,
10363 );
10364 return Err(failure(
10365 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10366 "Git worktree identity no longer matches the managed lease",
10367 ));
10368 }
10369 if target.prunable {
10370 self.mark_managed_cleanup_blocked(
10371 lease_id,
10372 ManagedWorktreeCleanupFailure::Prunable,
10373 );
10374 return Err(failure(
10375 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10376 "managed worktree is prunable and requires recovery",
10377 ));
10378 }
10379 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10380 let failure_kind = match error.kind {
10381 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10382 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10383 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10384 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10385 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10386 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10387 };
10388 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10389 self.mark_managed_recovery_required(lease_id, failure_kind);
10390 } else {
10391 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10392 }
10393 return Err(managed_git_worktree_failure(error));
10394 }
10395 } else {
10396 match std::fs::symlink_metadata(&lease.target_root) {
10397 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10398 Ok(_) => {
10399 self.mark_managed_recovery_required(
10400 lease_id,
10401 ManagedWorktreeCleanupFailure::OwnershipConflict,
10402 );
10403 return Err(failure(
10404 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10405 "managed target exists but is not owned by Git",
10406 ));
10407 }
10408 Err(_) => {
10409 self.mark_managed_cleanup_blocked(
10410 lease_id,
10411 ManagedWorktreeCleanupFailure::Backend,
10412 );
10413 return Err(failure(
10414 NodeFailureCode::BackendOperationFailed,
10415 "managed target identity could not be inspected",
10416 ));
10417 }
10418 }
10419 }
10420 let registered = self.workspaces.read()
10421 .unwrap_or_else(|poisoned| poisoned.into_inner())
10422 .contains_key(&lease.workspace_id);
10423 if registered {
10424 self.unregister_workspace(&lease.workspace_id)?;
10425 }
10426 let removed = {
10427 self.managed_worktrees.lock()
10428 .unwrap_or_else(|poisoned| poisoned.into_inner())
10429 .tombstone(lease_id, unix_time_ms())
10430 .expect("managed worktree lease remains present during cleanup")
10431 };
10432 self.persist_state().map_err(persistence_failure)?;
10433 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10434 Ok(removed.snapshot())
10435 }
10436
10437 fn mark_managed_cleanup_blocked(
10438 &self,
10439 lease_id: &ManagedWorktreeLeaseId,
10440 failure_kind: ManagedWorktreeCleanupFailure,
10441 ) {
10442 let snapshot = {
10443 let mut registry = self.managed_worktrees.lock()
10444 .unwrap_or_else(|poisoned| poisoned.into_inner());
10445 let Some(lease) = registry.get_mut(lease_id) else { return };
10446 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10447 lease.cleanup_failure = Some(failure_kind);
10448 lease.updated_at_unix_ms = unix_time_ms();
10449 lease.snapshot()
10450 };
10451 let _ = self.persist_state();
10452 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10453 }
10454
10455 fn require_worktree_service(
10456 &self,
10457 source_workspace_id: &WorkspaceId,
10458 ) -> Result<(), NodeFailure> {
10459 if self.managed_worktrees.lock()
10460 .unwrap_or_else(|poisoned| poisoned.into_inner())
10461 .lease_for_workspace(source_workspace_id)
10462 .is_some()
10463 {
10464 return Err(failure(
10465 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10466 "managed worktree targets are internal-only workspaces",
10467 ));
10468 }
10469 match self
10470 .worktree_service_modes
10471 .get(source_workspace_id)
10472 .copied()
10473 .unwrap_or(WorktreeServiceMode::Manual)
10474 {
10475 WorktreeServiceMode::Manual => Ok(()),
10476 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10477 NodeFailureCode::UnsupportedSpawnCapability,
10478 "Git worktree service mode is unavailable in this node build",
10479 )),
10480 }
10481 }
10482
10483 fn managed_reservation_conflict(
10484 &self,
10485 workspace_id: Option<&WorkspaceId>,
10486 root: Option<&str>,
10487 ) -> bool {
10488 self.managed_worktrees.lock()
10489 .unwrap_or_else(|poisoned| poisoned.into_inner())
10490 .active_records()
10491 .any(|lease| {
10492 workspace_id.is_some_and(|workspace_id| {
10493 workspace_id == &lease.source_workspace_id
10494 || workspace_id == &lease.workspace_id
10495 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10496 })
10497 }
10498
10499 fn reject_managed_reservation(
10500 &self,
10501 workspace_id: Option<&WorkspaceId>,
10502 root: Option<&str>,
10503 ) -> Result<(), NodeFailure> {
10504 if self.managed_reservation_conflict(workspace_id, root) {
10505 Err(failure(
10506 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10507 "managed worktree ownership may only be mutated through its lease cleanup",
10508 ))
10509 } else {
10510 Ok(())
10511 }
10512 }
10513
10514 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10515 self.validate_address(address)?;
10516 self.workspace_root(&address.workspace_id)
10517 }
10518
10519 fn require_session_runtime_policy(
10553 &self,
10554 address: &SessionAddress,
10555 requirement: ProviderRuntimeRequirement,
10556 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10557 let runtime_policy = {
10558 let bindings = self
10559 .session_bindings
10560 .lock()
10561 .unwrap_or_else(|poisoned| poisoned.into_inner());
10562 let binding = bindings
10563 .get(&address.session.instance_id)
10564 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10565 if binding.workspace_id != address.workspace_id {
10566 return Err(failure(
10567 NodeFailureCode::SessionWorkspaceMismatch,
10568 "session belongs to another workspace",
10569 ));
10570 }
10571 if binding.generation != address.session.generation {
10572 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10573 }
10574 binding.runtime_policy
10575 };
10576 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10577 let snapshot = self.internal_session_snapshot(address)?;
10578 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10579 && snapshot.provider.lead_activity != ProviderActivity::Idle
10580 {
10581 return Err(failure(
10582 NodeFailureCode::TurnInFlight,
10583 "session already has a turn in flight; wait for it to complete before sending another prompt",
10584 ));
10585 }
10586 }
10587 require_policy(runtime_policy, false, false, requirement)
10588 .map(|()| runtime_policy)
10589 .map_err(|_| failure(
10590 NodeFailureCode::UnsupportedCapability,
10591 "session runtime policy does not admit this semantic operation",
10592 ))
10593 }
10594
10595 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10596 let mut bindings = self
10597 .session_bindings
10598 .lock()
10599 .unwrap_or_else(|poisoned| poisoned.into_inner());
10600 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10601 binding.workspace_id == address.workspace_id
10602 && binding.generation == address.session.generation
10603 }) {
10604 let removed = bindings.remove(&address.session.instance_id);
10605 self.clear_terminal_frame_watermark(address);
10606 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10607 return removed;
10608 }
10609 None
10610 }
10611
10612 fn cleanup_session_owned_materialization(
10613 &self,
10614 address: &SessionAddress,
10615 binding: &SessionBinding,
10616 ) -> Result<(), NodeFailure> {
10617 let Some(id) = binding.materialization_id.as_ref() else {
10618 return Ok(());
10619 };
10620 let owner = self
10621 .materializations
10622 .lock()
10623 .unwrap_or_else(|poisoned| poisoned.into_inner())
10624 .get(id)
10625 .map(|record| record.owner().clone());
10626 match owner {
10627 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10628 Some(MaterializationOwner::Session {
10629 incarnation_id,
10630 instance_id,
10631 generation,
10632 }) if incarnation_id == self.incarnation_id
10633 && instance_id == address.session.instance_id
10634 && generation == address.session.generation =>
10635 {
10636 self.cleanup_materialization(id)
10637 }
10638 Some(MaterializationOwner::Session { .. }) => Err(failure(
10639 NodeFailureCode::BackendOperationFailed,
10640 "session environment ownership requires recovery",
10641 )),
10642 }
10643 }
10644
10645 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10646 let mut watermarks = self
10647 .terminal_frame_watermarks
10648 .lock()
10649 .unwrap_or_else(|poisoned| poisoned.into_inner());
10650 if watermarks
10651 .get(&address.session.instance_id)
10652 .is_some_and(|(current, _)| current == address)
10653 {
10654 watermarks.remove(&address.session.instance_id);
10655 }
10656 }
10657
10658 #[cfg(test)]
10659 fn address_for(
10660 &self,
10661 instance_id: AgentInstanceId,
10662 generation: SessionGeneration,
10663 ) -> Option<SessionAddress> {
10664 let binding = self.session_bindings
10665 .lock()
10666 .unwrap_or_else(|poisoned| poisoned.into_inner())
10667 .get(&instance_id)
10668 .cloned()?;
10669 (binding.generation == generation).then_some(SessionAddress {
10670 workspace_id: binding.workspace_id,
10671 session: SessionKey { instance_id, generation },
10672 })
10673 }
10674
10675 fn arm_resume(
10676 &self,
10677 address: &SessionAddress,
10678 command_id: CommandId,
10679 runtime_policy: ProviderRuntimePolicy,
10680 ) -> Result<(), NodeFailure> {
10681 let mut bindings = self
10682 .session_bindings
10683 .lock()
10684 .unwrap_or_else(|poisoned| poisoned.into_inner());
10685 let binding = bindings
10686 .get_mut(&address.session.instance_id)
10687 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10688 if binding.workspace_id != address.workspace_id {
10689 return Err(failure(
10690 NodeFailureCode::SessionWorkspaceMismatch,
10691 "session belongs to another workspace",
10692 ));
10693 }
10694 if binding.generation != address.session.generation {
10695 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10696 }
10697 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10698 Ok(())
10699 }
10700
10701 fn clear_armed_resume(&self, address: &SessionAddress) {
10702 let mut bindings = self
10703 .session_bindings
10704 .lock()
10705 .unwrap_or_else(|poisoned| poisoned.into_inner());
10706 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10707 if binding.workspace_id == address.workspace_id
10708 && binding.generation == address.session.generation
10709 {
10710 binding.pending_resume = None;
10711 }
10712 }
10713 }
10714
10715 fn publish_control(&self, event: ControlEvent) {
10716 let revokes_harness_mcp = matches!(
10717 &event.event,
10718 ControlEventKind::Exited { .. }
10719 | ControlEventKind::Failed { .. }
10720 | ControlEventKind::Removed
10721 );
10722 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10723 let mut bindings = self
10724 .session_bindings
10725 .lock()
10726 .unwrap_or_else(|poisoned| poisoned.into_inner());
10727 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10728 return;
10729 };
10730 let previous_address = SessionAddress {
10731 workspace_id: binding.workspace_id.clone(),
10732 session: SessionKey {
10733 instance_id: event.instance_id,
10734 generation: binding.generation,
10735 },
10736 };
10737 let mut resume_command_rejected = false;
10738 if binding.generation == event.generation {
10739 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10740 let rejected_resume = matches!(
10741 &event.event,
10742 ControlEventKind::CommandRejected { .. }
10743 ) && event.command_id == Some(pending_command_id);
10744 let resume_failed = matches!(
10745 &event.event,
10746 ControlEventKind::ResumeDenied { .. }
10747 | ControlEventKind::ResumeFailed { .. }
10748 );
10749 if pending_generation == binding.generation
10750 && (rejected_resume || resume_failed)
10751 {
10752 resume_command_rejected = rejected_resume;
10753 binding.pending_resume = None;
10754 }
10755 }
10756 } else {
10757 let expected = binding.generation.0.checked_add(1);
10758 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10759 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10760 generation == binding.generation
10761 })
10762 || expected != Some(event.generation.0)
10763 || !authorized
10764 {
10765 return;
10766 }
10767 let (_, _, runtime_policy) = binding
10768 .pending_resume
10769 .expect("authorized resume retains its pending runtime policy");
10770 binding.generation = event.generation;
10771 binding.runtime_policy = runtime_policy;
10772 binding.pending_resume = None;
10773 }
10774 (
10775 SessionAddress {
10776 workspace_id: binding.workspace_id.clone(),
10777 session: SessionKey {
10778 instance_id: event.instance_id,
10779 generation: event.generation,
10780 },
10781 },
10782 (previous_address.session.generation != event.generation)
10783 .then_some(previous_address),
10784 binding.record_id.clone(),
10785 binding.runtime_policy.provider_session_identity,
10786 resume_command_rejected,
10787 )
10788 };
10789 if let Some(previous_address) = previous_address.as_ref() {
10790 self.clear_terminal_frame_watermark(previous_address);
10791 }
10792 if let Some(record_id) = record_id {
10793 self.reconcile_managed_record(
10794 &record_id,
10795 &address,
10796 &event,
10797 identity_admitted,
10798 resume_command_rejected,
10799 );
10800 }
10801 match &event.event {
10809 ControlEventKind::Running { process_id } => {
10810 #[cfg(unix)]
10811 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10812 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10813 }
10814 tracing::info!(
10815 node_id = %self.node_id,
10816 workspace_id = %address.workspace_id,
10817 session = ?address.session,
10818 pid = ?process_id,
10819 "session process is running",
10820 );
10821 }
10822 ControlEventKind::Exited { exit_code, forced } => {
10823 #[cfg(unix)]
10824 if let Some(proxy) = self.signer_proxy.get().cloned() {
10825 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10826 if proxy.has_session(&key) {
10827 std::thread::spawn(move || proxy.release(&key));
10829 }
10830 }
10831 tracing::info!(
10832 node_id = %self.node_id,
10833 workspace_id = %address.workspace_id,
10834 session = ?address.session,
10835 exit_code = ?exit_code,
10836 forced = forced,
10837 "session process exited",
10838 );
10839 }
10840 ControlEventKind::Failed { message } => {
10841 tracing::warn!(
10842 node_id = %self.node_id,
10843 workspace_id = %address.workspace_id,
10844 session = ?address.session,
10845 cause = %message,
10846 "session failed",
10847 );
10848 }
10849 ControlEventKind::CommandRejected { message } => {
10850 tracing::warn!(
10851 node_id = %self.node_id,
10852 workspace_id = %address.workspace_id,
10853 session = ?address.session,
10854 cause = %message,
10855 "session command rejected",
10856 );
10857 }
10858 ControlEventKind::Removed => {
10859 tracing::info!(
10860 node_id = %self.node_id,
10861 workspace_id = %address.workspace_id,
10862 session = ?address.session,
10863 "session removed",
10864 );
10865 }
10866 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10867 tracing::info!(
10868 node_id = %self.node_id,
10869 workspace_id = %address.workspace_id,
10870 session = ?address.session,
10871 provider_session = ?provider_session,
10872 "session resume authorized",
10873 );
10874 }
10875 ControlEventKind::Resumed { session: provider_session, process_id } => {
10876 tracing::info!(
10877 node_id = %self.node_id,
10878 workspace_id = %address.workspace_id,
10879 session = ?address.session,
10880 provider_session = ?provider_session,
10881 pid = ?process_id,
10882 "session resumed",
10883 );
10884 }
10885 ControlEventKind::ResumeDenied { reason } => {
10886 tracing::warn!(
10887 node_id = %self.node_id,
10888 workspace_id = %address.workspace_id,
10889 session = ?address.session,
10890 cause = %reason,
10891 "session resume denied",
10892 );
10893 }
10894 ControlEventKind::ResumeFailed { message } => {
10895 tracing::warn!(
10896 node_id = %self.node_id,
10897 workspace_id = %address.workspace_id,
10898 session = ?address.session,
10899 cause = %message,
10900 "session resume failed",
10901 );
10902 }
10903 _ => {}
10904 }
10905 self.apply_pending_acp_options(&address, &event);
10906 self.apply_acp_auto_permit(&address, &event);
10907 let stream_chunk = agent_stream_chunk(&event);
10908 self.publish(NodeEvent::Control {
10909 address: address.clone(),
10910 event,
10911 });
10912 if let Some(chunk) = stream_chunk {
10913 self.publish_agent_stream_chunk(address.clone(), chunk);
10914 }
10915 if revokes_harness_mcp {
10916 SessionLifecycleHook::on_session_ended(self, &address);
10917 }
10918 }
10919
10920 fn reconcile_managed_record(
10921 &self,
10922 record_id: &SessionRecordId,
10923 address: &SessionAddress,
10924 event: &ControlEvent,
10925 identity_admitted: bool,
10926 resume_command_rejected: bool,
10927 ) {
10928 let _transaction = self
10929 .state_transaction
10930 .lock()
10931 .unwrap_or_else(|poisoned| poisoned.into_inner());
10932 let observed_identity = match &event.event {
10933 ControlEventKind::ProviderEvent {
10934 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10935 ..
10936 } if identity_admitted => Some(identity.clone()),
10937 _ => None,
10938 };
10939 let identity_was_observed = observed_identity.is_some();
10940 let replacement_id = observed_identity
10941 .as_ref()
10942 .and_then(|_| self.allocate_record_id().ok());
10943 let mut upserts = Vec::new();
10944 let mut removals = Vec::new();
10945 let mut rebind = None;
10946 let original_records;
10947 let original_materializations = self
10948 .materializations
10949 .lock()
10950 .unwrap_or_else(|poisoned| poisoned.into_inner())
10951 .clone();
10952 {
10953 let mut records = self
10954 .session_records
10955 .lock()
10956 .unwrap_or_else(|poisoned| poisoned.into_inner());
10957 original_records = records.records.clone();
10958 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10959 return;
10960 };
10961 if let Some(identity) = observed_identity {
10962 let existing_id = records
10963 .records
10964 .values()
10965 .find(|record| {
10966 record.record_id != *record_id
10967 && record.provider_session.as_ref().is_some_and(|record_identity| {
10968 session_registry::same_provider_session(
10969 &record.provider,
10970 record_identity,
10971 ¤t_snapshot.provider,
10972 &identity,
10973 )
10974 })
10975 })
10976 .map(|record| record.record_id.clone());
10977 if let Some(existing_id) = existing_id {
10978 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10979 record.mode == current_snapshot.mode
10980 && record.workspace_id == current_snapshot.workspace_id
10981 && record.environment_profile == current_snapshot.environment_profile
10982 && record.bundle == current_snapshot.bundle
10983 && record.context_id == current_snapshot.context_id
10984 && record.context == current_snapshot.context
10985 && record
10986 .canonical_root
10987 .as_utf8()
10988 .zip(current_snapshot.canonical_root.as_utf8())
10989 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10990 });
10991 if !scope_matches {
10992 if let Some(current) = records.records.get_mut(record_id) {
10993 current.state = ManagedSessionState::Unavailable;
10994 current.last_error =
10995 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10996 current.updated_at_unix_ms = unix_time_ms();
10997 upserts.push(current.clone());
10998 }
10999 } else {
11000 let existing_is_active = records
11001 .records
11002 .get(&existing_id)
11003 .is_some_and(|record| record.active_session.is_some());
11004 if existing_is_active {
11005 if let Some(current) = records.records.get_mut(record_id) {
11006 current.state = ManagedSessionState::Unavailable;
11007 current.last_error =
11008 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11009 current.updated_at_unix_ms = unix_time_ms();
11010 upserts.push(current.clone());
11011 }
11012 } else {
11013 if current_snapshot.provider_session.is_some() {
11014 if let Some(current) = records.records.get_mut(record_id) {
11015 current.active_session = None;
11016 current.state = ManagedSessionState::Dormant;
11017 current.updated_at_unix_ms = unix_time_ms();
11018 upserts.push(current.clone());
11019 }
11020 } else {
11021 records.records.remove(record_id);
11022 removals.push(record_id.clone());
11023 }
11024 if let Some(existing) = records.records.get_mut(&existing_id) {
11025 existing.provider_session = Some(identity.clone());
11026 existing.active_session = Some(address.clone());
11027 existing.state = ManagedSessionState::Live;
11028 existing.updated_at_unix_ms = unix_time_ms();
11029 existing.last_error = None;
11030 upserts.push(existing.clone());
11031 }
11032 rebind = Some(existing_id);
11033 }
11034 }
11035 } else if current_snapshot
11036 .provider_session
11037 .as_ref()
11038 .is_some_and(|current| {
11039 !session_registry::same_provider_session(
11040 ¤t_snapshot.provider,
11041 current,
11042 ¤t_snapshot.provider,
11043 &identity,
11044 )
11045 })
11046 {
11047 if let Some(current) = records.records.get_mut(record_id) {
11048 current.active_session = None;
11049 if current.bundle.is_some() || current.context.is_some() {
11050 let had_bundle = current.bundle.is_some();
11051 current.provider_session = None;
11052 current.bundle = None;
11053 current.context_id = None;
11054 current.context = None;
11055 current.state = ManagedSessionState::Unavailable;
11056 current.last_error = Some(if had_bundle {
11057 "bundle-unavailable".to_owned()
11058 } else {
11059 "context-unavailable".to_owned()
11060 });
11061 } else {
11062 current.state = ManagedSessionState::Dormant;
11063 }
11064 current.updated_at_unix_ms = unix_time_ms();
11065 upserts.push(current.clone());
11066 }
11067 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11068 if let Some(current) = records.records.get_mut(record_id) {
11069 current.state = ManagedSessionState::Unavailable;
11070 current.last_error =
11071 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11072 current.updated_at_unix_ms = unix_time_ms();
11073 upserts.push(current.clone());
11074 }
11075 } else if let Some(new_record_id) = replacement_id {
11076 let now = unix_time_ms();
11077 let replacement = ManagedSessionRecord {
11078 record_id: new_record_id.clone(),
11079 display_name: format!(
11080 "{} #{}",
11081 current_snapshot.provider.as_str(),
11082 records
11083 .records
11084 .values()
11085 .filter(|record| {
11086 record.provider == current_snapshot.provider
11087 })
11088 .count()
11089 .saturating_add(1),
11090 ),
11091 provider: current_snapshot.provider.clone(),
11092 mode: current_snapshot.mode,
11093 state: ManagedSessionState::Live,
11094 workspace_id: current_snapshot.workspace_id,
11095 canonical_root: current_snapshot.canonical_root,
11096 provider_session: Some(identity),
11097 active_session: Some(address.clone()),
11098 environment_profile: current_snapshot.environment_profile,
11099 bundle: current_snapshot.bundle,
11100 context_id: current_snapshot.context_id,
11101 context: current_snapshot.context,
11102 exported_context: None,
11108 task_binding: current_snapshot.task_binding,
11109 created_at_unix_ms: now,
11110 updated_at_unix_ms: now,
11111 last_error: None,
11112 };
11113 records
11114 .records
11115 .insert(new_record_id.clone(), replacement.clone());
11116 upserts.push(replacement);
11117 rebind = Some(new_record_id);
11118 } else if let Some(current) = records.records.get_mut(record_id) {
11119 current.state = ManagedSessionState::Unavailable;
11120 current.last_error =
11121 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11122 upserts.push(current.clone());
11123 }
11124 } else if let Some(current) = records.records.get_mut(record_id) {
11125 current.provider_session = Some(identity);
11126 current.active_session = Some(address.clone());
11127 current.state = ManagedSessionState::Live;
11128 current.updated_at_unix_ms = unix_time_ms();
11129 current.last_error = None;
11130 upserts.push(current.clone());
11131 }
11132 } else if let Some(current) = records.records.get_mut(record_id) {
11133 if resume_command_rejected {
11134 current.active_session = None;
11135 current.state = detached_record_state(current);
11136 current.updated_at_unix_ms = unix_time_ms();
11137 current.last_error =
11138 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11139 upserts.push(current.clone());
11140 } else {
11141 match &event.event {
11142 ControlEventKind::Running { .. }
11143 | ControlEventKind::ResumeAuthorized { .. }
11144 | ControlEventKind::Resumed { .. } => {
11145 current.active_session = Some(address.clone());
11146 current.state = active_record_state(
11147 identity_admitted,
11148 current.provider_session.is_some(),
11149 );
11150 current.updated_at_unix_ms = unix_time_ms();
11151 current.last_error = None;
11152 upserts.push(current.clone());
11153 }
11154 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11155 current.active_session = None;
11156 current.state = detached_record_state(current);
11157 current.updated_at_unix_ms = unix_time_ms();
11158 upserts.push(current.clone());
11159 }
11160 ControlEventKind::Failed { message }
11161 | ControlEventKind::ResumeFailed { message } => {
11162 current.active_session = None;
11163 current.state = detached_record_state(current);
11164 current.updated_at_unix_ms = unix_time_ms();
11165 current.last_error = Some(
11166 session_registry::sanitized_record_error_summary(message),
11167 );
11168 upserts.push(current.clone());
11169 }
11170 ControlEventKind::ResumeDenied { reason } => {
11171 current.active_session = None;
11172 current.state = detached_record_state(current);
11173 current.updated_at_unix_ms = unix_time_ms();
11174 current.last_error = Some(
11175 session_registry::sanitized_record_error_summary(reason),
11176 );
11177 upserts.push(current.clone());
11178 }
11179 _ => {}
11180 }
11181 }
11182 }
11183 }
11184 let materialization_owner_record = identity_was_observed.then(|| {
11185 rebind.clone().unwrap_or_else(|| record_id.clone())
11186 }).and_then(|candidate| {
11187 self.session_records
11188 .lock()
11189 .unwrap_or_else(|poisoned| poisoned.into_inner())
11190 .records
11191 .get(&candidate)
11192 .filter(|record| {
11193 record.provider_session.is_some()
11194 && record.active_session.as_ref() == Some(address)
11195 })
11196 .map(|_| candidate)
11197 });
11198 if let Some(owner_record_id) = materialization_owner_record {
11199 let materialization_id = self
11200 .session_bindings
11201 .lock()
11202 .unwrap_or_else(|poisoned| poisoned.into_inner())
11203 .get(&address.session.instance_id)
11204 .and_then(|binding| binding.materialization_id.clone());
11205 if let Some(materialization_id) = materialization_id {
11206 let mut transfer_failed = false;
11207 {
11208 let mut materializations = self
11209 .materializations
11210 .lock()
11211 .unwrap_or_else(|poisoned| poisoned.into_inner());
11212 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11213 match ownership.owner() {
11214 MaterializationOwner::Session {
11215 incarnation_id,
11216 instance_id,
11217 generation,
11218 } if *incarnation_id == self.incarnation_id
11219 && *instance_id == address.session.instance_id
11220 && *generation == address.session.generation =>
11221 {
11222 transfer_failed = ownership
11223 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11224 .is_err();
11225 }
11226 MaterializationOwner::Record { record_id }
11227 if record_id == &owner_record_id => {}
11228 MaterializationOwner::Record { record_id } => {
11229 let expected = record_id.clone();
11230 transfer_failed = ownership
11231 .transfer_record_owner(
11232 &expected,
11233 owner_record_id.clone(),
11234 unix_time_ms(),
11235 )
11236 .is_err();
11237 }
11238 _ => transfer_failed = true,
11239 }
11240 if transfer_failed {
11241 let _ = ownership.mark_recovery_required(unix_time_ms());
11242 }
11243 } else {
11244 transfer_failed = true;
11245 }
11246 }
11247 if transfer_failed {
11248 if let Some(record) = self
11249 .session_records
11250 .lock()
11251 .unwrap_or_else(|poisoned| poisoned.into_inner())
11252 .records
11253 .get_mut(&owner_record_id)
11254 {
11255 record.state = ManagedSessionState::Unavailable;
11256 record.last_error = Some("environment-profile-unavailable".to_owned());
11257 record.updated_at_unix_ms = unix_time_ms();
11258 upserts.push(record.clone());
11259 }
11260 }
11261 }
11262 }
11263 if let Some(new_record_id) = rebind.clone() {
11264 if let Some(binding) = self
11265 .session_bindings
11266 .lock()
11267 .unwrap_or_else(|poisoned| poisoned.into_inner())
11268 .get_mut(&address.session.instance_id)
11269 {
11270 binding.record_id = Some(new_record_id);
11271 }
11272 }
11273 if upserts.is_empty() && removals.is_empty() {
11274 return;
11275 }
11276 if self.persist_state_locked().is_err() {
11277 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11278 let mut records = self
11279 .session_records
11280 .lock()
11281 .unwrap_or_else(|poisoned| poisoned.into_inner());
11282 records.records = original_records;
11283 *self
11284 .materializations
11285 .lock()
11286 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11287 if let Some(current) = records.records.get_mut(record_id) {
11288 current.state = ManagedSessionState::Unavailable;
11289 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11290 current.updated_at_unix_ms = unix_time_ms();
11291 upserts = vec![current.clone()];
11292 } else {
11293 upserts.clear();
11294 }
11295 removals.clear();
11296 drop(records);
11297 if let Some(binding) = self
11298 .session_bindings
11299 .lock()
11300 .unwrap_or_else(|poisoned| poisoned.into_inner())
11301 .get_mut(&address.session.instance_id)
11302 {
11303 binding.record_id = Some(record_id.clone());
11304 }
11305 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11306 }
11307 for record in upserts {
11308 self.publish_record(record);
11309 }
11310 for record_id in removals {
11311 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11312 }
11313 }
11314
11315 fn snapshot(&self) -> NodeSnapshot {
11316 let control = self.handle.snapshot();
11317 let bindings = self
11318 .session_bindings
11319 .lock()
11320 .unwrap_or_else(|poisoned| poisoned.into_inner())
11321 .clone();
11322 let workspace_roots = self
11323 .workspaces
11324 .read()
11325 .unwrap_or_else(|poisoned| poisoned.into_inner())
11326 .clone();
11327 let environment_profiles = self
11328 .environment_profiles
11329 .read()
11330 .unwrap_or_else(|poisoned| poisoned.into_inner());
11331 let mut workspaces = workspace_roots
11332 .iter()
11333 .map(|(workspace_id, canonical_root)| {
11334 (
11335 workspace_id.clone(),
11336 WorkspaceSnapshot {
11337 workspace_id: workspace_id.clone(),
11338 canonical_root: opaque_windows_path(canonical_root.clone()),
11339 sessions: Vec::new(),
11340 worktree_service_mode: Some(
11341 self.worktree_service_modes
11342 .get(workspace_id)
11343 .copied()
11344 .unwrap_or(WorktreeServiceMode::Manual),
11345 ),
11346 managed_worktree_profiles: Some(WorktreeProfileInventory {
11347 profiles: self
11348 .managed_worktree_profiles
11349 .get(workspace_id)
11350 .into_iter()
11351 .flat_map(BTreeMap::values)
11352 .map(|profile| ManagedWorktreeProfileSummary {
11353 id: profile.profile_id().clone(),
11354 revision: profile.revision().clone(),
11355 retention: profile.retention(),
11356 })
11357 .collect(),
11358 }),
11359 },
11360 )
11361 })
11362 .collect::<BTreeMap<_, _>>();
11363 let mut agent_progress = Vec::new();
11364 for session in &control.sessions {
11365 let Some(binding) = bindings.get(&session.instance_id) else {
11366 continue;
11367 };
11368 if binding.generation != session.generation {
11369 continue;
11370 }
11371 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11372 let address = SessionAddress {
11373 workspace_id: binding.workspace_id.clone(),
11374 session: SessionKey {
11375 instance_id: session.instance_id,
11376 generation: session.generation,
11377 },
11378 };
11379 if let Some(progress) =
11380 agent_progress_from_provider_snapshot(address, &session.provider)
11381 {
11382 agent_progress.push(progress);
11383 }
11384 workspace.sessions.push(session.clone());
11385 }
11386 }
11387 NodeSnapshot {
11388 node_id: self.node_id.clone(),
11389 enabled_providers: self.enabled_providers.clone(),
11390 provider_runtime_statuses: {
11391 let updates = self
11392 .provider_runtime_status_updates
11393 .read()
11394 .unwrap_or_else(|poisoned| poisoned.into_inner());
11395 ProviderRuntimeStatuses::new(
11396 self.provider_runtime_statuses
11397 .iter()
11398 .filter(|status| !updates.contains_key(status.provider()))
11399 .cloned()
11400 .chain(updates.values().cloned()),
11401 )
11402 .expect("refreshed provider runtime inventory remains bounded and unique")
11403 },
11404 workspaces: workspaces.into_values().collect(),
11405 session_records: self
11406 .session_records
11407 .lock()
11408 .unwrap_or_else(|poisoned| poisoned.into_inner())
11409 .records
11410 .values()
11411 .cloned()
11412 .collect(),
11413 managed_worktrees: self.managed_worktrees
11414 .lock()
11415 .unwrap_or_else(|poisoned| poisoned.into_inner())
11416 .snapshots(),
11417 launch_inventory: Some(LaunchInventory {
11418 spawn_profiles: Some(
11419 self.spawn_profiles
11420 .iter()
11421 .filter_map(|profile| {
11422 resolved_spawn_profile_summary(profile, &environment_profiles)
11423 })
11424 .collect(),
11425 ),
11426 bundles: Some(
11427 self.bundle_catalog
11428 .read()
11429 .unwrap_or_else(|poisoned| poisoned.into_inner())
11430 .iter()
11431 .map(NodeBundle::receipt)
11432 .collect(),
11433 ),
11434 network_allowlists: {
11437 let catalog = self
11438 .network_allowlist_catalog
11439 .read()
11440 .unwrap_or_else(|poisoned| poisoned.into_inner());
11441 if catalog.is_empty() {
11442 None
11443 } else {
11444 Some(catalog.ids().cloned().collect())
11445 }
11446 },
11447 }),
11448 agent_progress,
11449 }
11450 }
11451
11452 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11453 assert!(
11454 !matches!(
11455 &event,
11456 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11457 ),
11458 "terminal frame and agent stream events must use their own dedicated live channel",
11459 );
11460 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11461 let sequence = history
11462 .last_sequence
11463 .checked_add(1)
11464 .expect("node event sequence exhausted");
11465 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11466 if let Some(evicted) = history.events.pop_front() {
11467 history.replay_floor_sequence = evicted
11468 .sequence
11469 .checked_add(1)
11470 .expect("node replay floor sequence exhausted");
11471 history.removed_record_providers.remove(&evicted.sequence);
11472 }
11473 }
11474 match &event {
11475 NodeEvent::HarnessMcpReadCall { .. } => {
11476 panic!("transient harness MCP calls must not enter durable event history")
11477 }
11478 NodeEvent::SessionRecordUpserted { record } => {
11479 history
11480 .record_providers
11481 .insert(record.record_id.clone(), record.provider.clone());
11482 }
11483 NodeEvent::SessionRecordRemoved { record_id } => {
11484 if let Some(provider) = history.record_providers.remove(record_id) {
11485 history.removed_record_providers.insert(sequence, provider);
11486 }
11487 }
11488 NodeEvent::ManagedWorktreeUpserted { .. }
11489 | NodeEvent::ManagedWorktreeRemoved { .. }
11490 | NodeEvent::Control { .. }
11491 | NodeEvent::ControllerChanged { .. }
11492 | NodeEvent::SessionRecordHistorySummarized { .. }
11493 | NodeEvent::WorkspaceAdded { .. }
11494 | NodeEvent::WorkspaceRemoved { .. }
11495 | NodeEvent::ResyncRequired { .. }
11496 | NodeEvent::AgentStream { .. }
11497 | NodeEvent::TerminalFrame { .. } => {}
11498 }
11499 let envelope = NodeEventEnvelope { sequence, event };
11500 history.events.push_back(envelope.clone());
11501 history.last_sequence = sequence;
11502 let _ = self.event_tx.send(envelope.clone());
11503 drop(history);
11504 envelope
11505 }
11506
11507 fn publish_transient(&self, event: NodeEvent) -> bool {
11518 assert!(event.requires_harness_mcp_proxy_capability());
11519 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11520 let envelope = NodeEventEnvelope { sequence: 0, event };
11521 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11522 Ok(_) => true,
11523 Err(broadcast::error::SendError(undelivered)) => {
11524 if let Some(NodeEventEnvelope {
11525 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11526 ..
11527 }) = undelivered.first() {
11528 tracing::warn!(
11534 call_id = call_id.as_str(),
11535 content_type = ?request.content_type,
11536 cause = "no-subscribers",
11537 "harness MCP proxy event could not be published",
11538 );
11539 }
11540 false
11541 }
11542 }
11543 }
11544
11545 fn publish_terminal_frames(&self) {
11546 let snapshot = self.handle.snapshot();
11547 let bindings = self
11548 .session_bindings
11549 .lock()
11550 .unwrap_or_else(|poisoned| poisoned.into_inner());
11551 let candidates = snapshot
11552 .sessions
11553 .iter()
11554 .filter_map(|session| {
11555 let binding = bindings.get(&session.instance_id)?;
11556 if binding.generation != session.generation {
11557 return None;
11558 }
11559 let frame = session.terminal_frame.clone()?;
11560 crate::session_signals::feed(
11561 binding.workspace_id.as_str(),
11562 session.instance_id.0,
11563 session.agent_id.as_str(),
11564 &frame.contents,
11565 );
11566 Some((
11567 SessionAddress {
11568 workspace_id: binding.workspace_id.clone(),
11569 session: SessionKey {
11570 instance_id: session.instance_id,
11571 generation: session.generation,
11572 },
11573 },
11574 frame,
11575 ))
11576 })
11577 .collect();
11578 drop(bindings);
11579 self.publish_terminal_frame_candidates(candidates);
11580 }
11581
11582 fn publish_terminal_frame_candidates(
11583 &self,
11584 candidates: Vec<(SessionAddress, TerminalFrame)>,
11585 ) {
11586 let mut watermarks = self
11587 .terminal_frame_watermarks
11588 .lock()
11589 .unwrap_or_else(|poisoned| poisoned.into_inner());
11590 let mut events = Vec::with_capacity(candidates.len());
11591 for (address, frame) in candidates {
11592 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11593 let advanced = watermarks
11594 .get(&address.session.instance_id)
11595 .map_or(true, |(current_address, sequence)| {
11596 current_address != &address || frame.sequence > *sequence
11597 });
11598 if !advanced {
11599 continue;
11600 }
11601 if is_first_frame {
11602 if self.controller_state().is_none() {
11616 tracing::warn!(
11617 node_id = %self.node_id,
11618 workspace_id = %address.workspace_id,
11619 session = ?address.session,
11620 terminal_size = ?frame.size,
11621 "session producing output with no controller attached",
11622 );
11623 } else {
11624 tracing::info!(
11625 node_id = %self.node_id,
11626 workspace_id = %address.workspace_id,
11627 session = ?address.session,
11628 terminal_size = ?frame.size,
11629 "session produced its first output",
11630 );
11631 }
11632 }
11633 watermarks.insert(
11634 address.session.instance_id,
11635 (address.clone(), frame.sequence),
11636 );
11637 if let Some((at, reported)) = self
11638 .pty_input_marks
11639 .lock()
11640 .unwrap_or_else(|poisoned| poisoned.into_inner())
11641 .get_mut(&address.session.instance_id)
11642 {
11643 if !*reported && at.elapsed() < Duration::from_secs(5) {
11645 *reported = true;
11646 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");
11647 }
11648 }
11649 events.push(NodeEvent::TerminalFrame { address, frame });
11650 }
11651 drop(watermarks);
11652 if events.is_empty() {
11653 return;
11654 }
11655 let mut history = self
11656 .history
11657 .lock()
11658 .unwrap_or_else(|poisoned| poisoned.into_inner());
11659 let mut envelopes = Vec::with_capacity(events.len());
11660 for event in events {
11661 let sequence = history
11662 .last_sequence
11663 .checked_add(1)
11664 .expect("node event sequence exhausted");
11665 history.last_sequence = sequence;
11666 envelopes.push(NodeEventEnvelope { sequence, event });
11667 }
11668 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11669 drop(history);
11670 }
11671
11672 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11681 let mut history = self
11682 .history
11683 .lock()
11684 .unwrap_or_else(|poisoned| poisoned.into_inner());
11685 let sequence = history
11686 .last_sequence
11687 .checked_add(1)
11688 .expect("node event sequence exhausted");
11689 history.last_sequence = sequence;
11690 drop(history);
11691 tracing::debug!(
11699 source_sequence = chunk.source_sequence,
11700 kind = agent_stream_chunk_kind_label(&chunk.kind),
11701 envelope_sequence = sequence,
11702 "publishing agent stream chunk",
11703 );
11704 let envelope = NodeEventEnvelope {
11705 sequence,
11706 event: NodeEvent::AgentStream { address, chunk },
11707 };
11708 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11709 }
11710
11711 fn current_sequence(&self) -> u64 {
11712 self.history
11713 .lock()
11714 .unwrap_or_else(|poisoned| poisoned.into_inner())
11715 .last_sequence
11716 }
11717
11718 fn controller_state(&self) -> Option<ControllerState> {
11719 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11720 let lease = controller.as_ref().copied()?;
11721 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11722 if remaining.is_zero() {
11723 *controller = None;
11724 return None;
11725 }
11726 Some(ControllerState {
11727 connection_id: lease.connection_id,
11728 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11729 })
11730 }
11731
11732 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11733 if role != ClientRole::Operator {
11734 tracing::warn!(
11735 node_id = %self.node_id,
11736 connection_id,
11737 "controller lease rejected: observer connections cannot acquire control",
11738 );
11739 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11740 }
11741 let lease_ms = if lease_ms == 0 {
11742 DEFAULT_CONTROLLER_LEASE_MS
11743 } else {
11744 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11745 };
11746 let now = Instant::now();
11747 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11748 if let Some(active) = controller.as_ref() {
11749 if active.connection_id != connection_id && active.expires_at > now {
11750 tracing::warn!(
11751 node_id = %self.node_id,
11752 connection_id,
11753 holder_connection_id = active.connection_id,
11754 "controller lease rejected: another operator holds it",
11755 );
11756 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11757 }
11758 }
11759 let is_new_attach = match controller.as_ref() {
11766 Some(active) => active.connection_id != connection_id,
11767 None => true,
11768 };
11769 *controller = Some(ControllerLease {
11770 connection_id,
11771 expires_at: now + Duration::from_millis(lease_ms),
11772 });
11773 drop(controller);
11774 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11775 if is_new_attach {
11776 tracing::info!(
11777 node_id = %self.node_id,
11778 connection_id,
11779 lease_ms,
11780 "controller lease acquired",
11781 );
11782 } else {
11783 tracing::debug!(
11784 node_id = %self.node_id,
11785 connection_id,
11786 lease_ms,
11787 "controller lease renewed",
11788 );
11789 }
11790 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11791 Ok(state)
11792 }
11793
11794 fn release_controller(&self, connection_id: u64) -> bool {
11795 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11796 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11797 if released {
11798 *controller = None;
11799 }
11800 drop(controller);
11801 if released {
11802 tracing::info!(
11803 node_id = %self.node_id,
11804 connection_id,
11805 "controller lease released",
11806 );
11807 self.publish(NodeEvent::ControllerChanged { controller: None });
11808 }
11809 released
11810 }
11811
11812 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11821 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11822 return;
11823 };
11824 let id = address.session.instance_id;
11825 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11826 return;
11827 };
11828 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11829 .iter()
11830 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11831 .collect();
11832 let (writes, set_model, effort_gap) =
11833 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11834 if set_model {
11835 if let Some(model_id) = model.clone() {
11836 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11837 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11838 }
11839 }
11840 }
11841 for (option_id, value_json) in writes {
11842 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11843 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11844 }
11845 }
11846 if effort_gap {
11847 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11848 }
11849 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11850 }
11851
11852 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11855 let id = address.session.instance_id;
11856 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11857 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11858 crate::auto_permit::forget_acp(id.0);
11859 return;
11860 }
11861 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11862 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11863 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11864 }
11865 match provider_event {
11866 ProviderEvent::ModeChanged { mode_id, available } => {
11867 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11868 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11869 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11870 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11871 }
11872 }
11873 }
11874 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11875 match self.dispatch(ControlCommand::ResolveInteraction {
11876 instance_id: id,
11877 generation: address.session.generation,
11878 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11879 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11880 }) {
11881 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11882 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11883 }
11884 }
11885 _ => {}
11888 }
11889 }
11890
11891 fn mark_spawn_provisional(&self, address: SessionAddress) {
11892 let id = address.session.instance_id;
11893 self.provisional_spawns
11894 .lock()
11895 .unwrap_or_else(|poisoned| poisoned.into_inner())
11896 .insert(id, (address, Instant::now()));
11897 tracing::info!(
11898 node_id = %self.node_id,
11899 instance_id = id.0,
11900 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11901 "draft spawn; waiting for HQ view",
11902 );
11903 }
11904
11905 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11906 let removed = self
11907 .provisional_spawns
11908 .lock()
11909 .unwrap_or_else(|poisoned| poisoned.into_inner())
11910 .remove(&instance_id);
11911 if removed.is_some() {
11912 tracing::info!(
11913 node_id = %self.node_id,
11914 instance_id = instance_id.0,
11915 "draft spawn claimed by HQ view",
11916 );
11917 }
11918 }
11919
11920 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11921 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11922 let mut map = self
11923 .provisional_spawns
11924 .lock()
11925 .unwrap_or_else(|poisoned| poisoned.into_inner());
11926 let due: Vec<AgentInstanceId> = map
11927 .iter()
11928 .filter(|(_, (_, at))| at.elapsed() >= grace)
11929 .map(|(id, _)| *id)
11930 .collect();
11931 due.into_iter()
11932 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11933 .collect()
11934 }
11935
11936 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11937 let instance_id = address.session.instance_id;
11938 tracing::warn!(
11939 node_id = %self.node_id,
11940 instance_id = instance_id.0,
11941 workspace = %address.workspace_id,
11942 reason = "attach_never_arrived",
11943 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11944 "draft spawn never viewed; stopping PTY",
11945 );
11946 let _ = self.dispatch(ControlCommand::Stop {
11947 instance_id,
11948 force: true,
11949 });
11950 let _ = self.remove_session(&address).await;
11951 }
11952
11953 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11954 if role != ClientRole::Operator {
11955 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11956 }
11957 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11958 let now = Instant::now();
11959 match controller.as_ref().copied() {
11960 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11961 Some(active) if active.expires_at <= now => {
11962 *controller = None;
11963 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11964 }
11965 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11966 }
11967 }
11968
11969 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11970 if !self
11971 .workspaces
11972 .read()
11973 .unwrap_or_else(|poisoned| poisoned.into_inner())
11974 .contains_key(&address.workspace_id)
11975 {
11976 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11977 }
11978 let binding = self
11979 .session_bindings
11980 .lock()
11981 .unwrap_or_else(|poisoned| poisoned.into_inner())
11982 .get(&address.session.instance_id)
11983 .cloned()
11984 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11985 if binding.workspace_id != address.workspace_id {
11986 return Err(failure(
11987 NodeFailureCode::SessionWorkspaceMismatch,
11988 "session belongs to another workspace",
11989 ));
11990 }
11991 if binding.generation != address.session.generation {
11992 return Err(failure(
11993 NodeFailureCode::StaleGeneration,
11994 &format!(
11995 "stale session generation: expected {}, received {}",
11996 binding.generation.0,
11997 address.session.generation.0,
11998 ),
11999 ));
12000 }
12001 let snapshot = self.handle.snapshot();
12002 let Some(current) = snapshot
12003 .sessions
12004 .iter()
12005 .find(|item| item.instance_id == address.session.instance_id)
12006 else {
12007 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12008 };
12009 if current.generation != address.session.generation {
12010 return Err(failure(
12011 NodeFailureCode::StaleGeneration,
12012 &format!(
12013 "stale session generation: expected {}, received {}",
12014 current.generation.0,
12015 address.session.generation.0,
12016 ),
12017 ));
12018 }
12019 Ok(current.agent_id.clone())
12020 }
12021
12022 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12023 self.dispatch_envelope(self.prepare_command(command))
12024 }
12025
12026 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12027 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12028 CommandEnvelope {
12029 id: CommandId(id),
12030 command,
12031 }
12032 }
12033
12034 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12035 let command_id = envelope.id;
12036 self.handle
12037 .dispatch(envelope)
12038 .map_err(|error| match error {
12039 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12040 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12041 })?;
12042 Ok(command_id)
12043 }
12044
12045 fn record_rejected_command(
12054 &self,
12055 command_id: CommandId,
12056 message: String,
12057 unsupported_transport: bool,
12058 ) {
12059 let now = Instant::now();
12060 let mut reasons = self.rejected_command_reasons.lock()
12061 .unwrap_or_else(|poisoned| poisoned.into_inner());
12062 reasons.retain(|_, reason| reason.expires_at > now);
12063 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12064 if let Some(&oldest) = reasons.keys().next() {
12065 reasons.remove(&oldest);
12066 }
12067 }
12068 reasons.insert(
12069 command_id,
12070 RejectedCommandReason {
12071 message,
12072 unsupported_transport,
12073 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12074 },
12075 );
12076 }
12077
12078 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12085 let now = Instant::now();
12086 let mut reasons = self.rejected_command_reasons.lock()
12087 .unwrap_or_else(|poisoned| poisoned.into_inner());
12088 match reasons.remove(&command_id) {
12089 Some(reason) if reason.expires_at > now => Some(reason),
12090 _ => None,
12091 }
12092 }
12093
12094 async fn dispatch_bounded(
12095 &self,
12096 command: ControlCommand,
12097 timeout_duration: Duration,
12098 ) -> Result<CommandId, NodeFailure> {
12099 let deadline = Instant::now() + timeout_duration;
12100 loop {
12101 match self.dispatch(command.clone()) {
12102 Ok(command_id) => return Ok(command_id),
12103 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12104 sleep(Duration::from_millis(2)).await;
12105 }
12106 Err(error) => return Err(error),
12107 }
12108 }
12109 }
12110
12111 fn internal_session_snapshot(
12112 &self,
12113 address: &SessionAddress,
12114 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12115 self.validate_address(address)?;
12116 self.handle
12117 .snapshot()
12118 .sessions
12119 .clone()
12120 .into_iter()
12121 .find(|session| {
12122 session.instance_id == address.session.instance_id
12123 && session.generation == address.session.generation
12124 })
12125 .ok_or_else(|| {
12126 failure(
12127 NodeFailureCode::UnknownSession,
12128 "session snapshot is unavailable",
12129 )
12130 })
12131 }
12132
12133 fn require_acp_session(
12141 &self,
12142 address: &SessionAddress,
12143 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12144 let snapshot = self.internal_session_snapshot(address)?;
12145 if snapshot.transport != TransportKind::Acp {
12146 return Err(failure(
12147 NodeFailureCode::UnsupportedTransport,
12148 "this verb requires a session spawned over the ACP transport",
12149 ));
12150 }
12151 Ok(snapshot)
12152 }
12153
12154 async fn dispatch_history_bounded(
12155 &self,
12156 address: &SessionAddress,
12157 command: ControlCommand,
12158 operation: HistoryOperation,
12159 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12160 self.validate_address(address)?;
12161 let started_after = self.current_sequence();
12162 let command_id = self
12163 .dispatch_bounded(
12164 command,
12165 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12166 )
12167 .await?;
12168 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12169 let mut scan_after = started_after;
12170 let mut accepted_after = None;
12171 loop {
12172 let events = self
12173 .history
12174 .lock()
12175 .unwrap_or_else(|poisoned| poisoned.into_inner())
12176 .events
12177 .iter()
12178 .filter(|envelope| envelope.sequence > scan_after)
12179 .cloned()
12180 .collect::<Vec<_>>();
12181 for envelope in events {
12182 scan_after = scan_after.max(envelope.sequence);
12183 let NodeEvent::Control {
12184 address: event_address,
12185 event,
12186 } = envelope.event
12187 else {
12188 continue;
12189 };
12190 if event_address != *address {
12191 continue;
12192 }
12193 if event.command_id == Some(command_id) {
12194 match &event.event {
12195 ControlEventKind::CommandRejected { .. } => {
12196 return Err(failure(
12197 NodeFailureCode::InvalidRequest,
12198 "history request was rejected",
12199 ));
12200 }
12201 ControlEventKind::HistoryRequested {
12202 operation: requested,
12203 ..
12204 } if requested == &operation => {
12205 accepted_after = Some(envelope.sequence);
12206 continue;
12207 }
12208 _ => {}
12209 }
12210 }
12211 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12212 match (&operation, &event.event) {
12213 (
12214 HistoryOperation::Discover { .. },
12215 ControlEventKind::HistoryDiscovered { .. },
12216 )
12217 | (
12218 HistoryOperation::Load { .. },
12219 ControlEventKind::HistoryLoaded { .. },
12220 ) => return self.internal_session_snapshot(address),
12221 (_, ControlEventKind::HistoryFailed { .. }) => {
12222 return Err(failure(
12223 NodeFailureCode::BackendOperationFailed,
12224 "history operation failed",
12225 ));
12226 }
12227 _ => {}
12228 }
12229 }
12230 }
12231 if Instant::now() >= deadline {
12232 return Err(failure(
12233 NodeFailureCode::BackendBusy,
12234 if accepted_after.is_some() {
12235 "history operation did not settle before the bounded deadline"
12236 } else {
12237 "history operation was not accepted before the bounded deadline"
12238 },
12239 ));
12240 }
12241 sleep(Duration::from_millis(2)).await;
12242 }
12243 }
12244
12245 async fn discover_history(
12246 &self,
12247 address: &SessionAddress,
12248 limit: u16,
12249 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12250 let query = HistoryQuery {
12251 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12252 limit,
12253 };
12254 query
12255 .validate()
12256 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12257 let snapshot = self
12258 .dispatch_history_bounded(
12259 address,
12260 ControlCommand::DiscoverHistory {
12261 instance_id: address.session.instance_id,
12262 query: query.clone(),
12263 },
12264 HistoryOperation::Discover { query },
12265 )
12266 .await?;
12267 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12268 return Err(failure(
12269 NodeFailureCode::BackendOperationFailed,
12270 "history discovery settled into an invalid state",
12271 ));
12272 }
12273 Ok(snapshot.history.candidates)
12274 }
12275
12276 async fn load_history(
12277 &self,
12278 address: &SessionAddress,
12279 candidate_id: String,
12280 ) -> Result<HistorySessionRecord, NodeFailure> {
12281 validate_candidate_id(&candidate_id)
12282 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12283 let snapshot = self
12284 .dispatch_history_bounded(
12285 address,
12286 ControlCommand::LoadHistory {
12287 instance_id: address.session.instance_id,
12288 candidate_id: candidate_id.clone(),
12289 },
12290 HistoryOperation::Load {
12291 candidate_id: candidate_id.clone(),
12292 },
12293 )
12294 .await?;
12295 if snapshot.history.pending.is_some()
12296 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12297 {
12298 return Err(failure(
12299 NodeFailureCode::BackendOperationFailed,
12300 "history load settled into an invalid state",
12301 ));
12302 }
12303 snapshot.history.loaded.ok_or_else(|| {
12304 failure(
12305 NodeFailureCode::BackendOperationFailed,
12306 "history load produced no bounded session",
12307 )
12308 })
12309 }
12310
12311 async fn export_context_pack_for_session_record(
12318 &self,
12319 record_id: &SessionRecordId,
12320 session: &SessionAddress,
12321 allow_clean_detachment: bool,
12322 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12323 let outcome = self
12329 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12330 .await;
12331 if let Err(failure) = &outcome {
12332 tracing::warn!(
12333 record = %record_id,
12334 code = ?failure.code,
12335 reason = %failure.message,
12336 "session-record context-pack export refused",
12337 );
12338 }
12339 outcome
12340 }
12341
12342 async fn export_context_pack_for_session_record_inner(
12343 &self,
12344 record_id: &SessionRecordId,
12345 session: &SessionAddress,
12346 allow_clean_detachment: bool,
12347 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12348 let step = |name: &'static str| move |failure: NodeFailure| {
12353 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12354 failure
12355 };
12356 let record = self.record(record_id).map_err(step("record-lookup"))?;
12357 let identity = match record.provider_session.clone() {
12373 Some(identity) => {
12374 if identity.key != ProviderSessionKey::SessionId {
12375 return Err(step("provider-identity-key")(failure(
12376 NodeFailureCode::BackendOperationFailed,
12377 "managed session provider identity cannot bind history exactly",
12378 )));
12379 }
12380 Some(identity)
12381 }
12382 None if record.state == ManagedSessionState::IdentityPending => {
12383 return Err(step("provider-session-identity")(failure(
12384 NodeFailureCode::BackendOperationFailed,
12385 "managed session has not yet observed its provider session identity",
12386 )));
12387 }
12388 None => None,
12389 };
12390 self.revalidate_session_record_context_export(
12391 &record,
12392 identity.as_ref(),
12393 session,
12394 allow_clean_detachment,
12395 ).map_err(step("revalidate-before-discovery"))?;
12396 let candidates = self
12397 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12398 .await
12399 .map_err(step("discover-history"))?;
12400 let candidate_id = select_history_candidate(
12405 &candidates,
12406 identity.as_ref().map(|identity| identity.id.as_str()),
12407 ).map_err(step("unique-candidate"))?;
12408 let loaded = self.load_history(session, candidate_id.clone()).await
12409 .map_err(step("load-history"))?;
12410 if let Some(identity) = identity.as_ref() {
12411 if loaded.session_id != identity.id {
12412 return Err(failure(
12413 NodeFailureCode::SessionRecordConflict,
12414 "loaded history identity does not match the managed session record",
12415 ));
12416 }
12417 }
12418 self.revalidate_session_record_context_export(
12419 &record,
12420 identity.as_ref(),
12421 session,
12422 allow_clean_detachment,
12423 )?;
12424 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12425 .map_err(step("revalidate-loaded-history"))?;
12426 let pack = self.materialize_context_pack(session).await
12427 .map_err(step("materialize-context-pack"))?;
12428 self.commit_context_pack_for_session_record(
12429 &record,
12430 identity.as_ref(),
12431 session,
12432 &candidate_id,
12433 &loaded,
12434 pack,
12435 allow_clean_detachment,
12436 )
12437 }
12438
12439 async fn export_context_pack(
12440 &self,
12441 address: &SessionAddress,
12442 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12443 let pack = self.materialize_context_pack(address).await?;
12444 self.commit_context_pack(pack)
12445 }
12446
12447 async fn materialize_context_pack(
12448 &self,
12449 address: &SessionAddress,
12450 ) -> Result<NodeContextPack, NodeFailure> {
12451 let snapshot = self.internal_session_snapshot(address)?;
12452 if !context_pack_source_status_is_usable(&snapshot.status) {
12453 return Err(failure(
12454 NodeFailureCode::InvalidRequest,
12455 "context export requires a running or successfully exited source session",
12456 ));
12457 }
12458 if snapshot.history.pending.is_some()
12459 || snapshot.history.loaded_candidate_id.is_none()
12460 {
12461 return Err(failure(
12462 NodeFailureCode::InvalidRequest,
12463 "context export requires settled loaded history",
12464 ));
12465 }
12466 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12467 failure(
12468 NodeFailureCode::InvalidRequest,
12469 "context export requires settled loaded history",
12470 )
12471 })?;
12472 let source_provider = snapshot.agent_id.clone();
12473 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12474 let repository = self
12475 .context_pack_repository(&address.workspace_id)
12476 .await?;
12477 let current = self.internal_session_snapshot(address)?;
12478 if !context_pack_source_status_is_usable(¤t.status)
12479 || current.agent_id != source_provider
12480 || current.history.pending.is_some()
12481 || current.history.loaded_candidate_id != loaded_candidate_id
12482 || current.history.loaded.as_ref() != Some(&loaded)
12483 {
12484 return Err(failure(
12485 NodeFailureCode::ContextPackMaterializationFailed,
12486 "context source changed during bounded repository capture",
12487 ));
12488 }
12489 NodeContextPack::export_with_repository(
12490 ContextPackLineageReceipt {
12491 source_node_id: self.node_id.clone(),
12492 source_session: address.clone(),
12493 source_provider,
12494 },
12495 &loaded,
12496 Some(repository),
12497 )
12498 .map_err(|_| {
12499 failure(
12500 NodeFailureCode::ContextPackMaterializationFailed,
12501 "bounded context pack export failed",
12502 )
12503 })
12504 }
12505
12506 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12512 let mut store = self
12513 .context_pack_store
12514 .lock()
12515 .unwrap_or_else(|poisoned| poisoned.into_inner());
12516 let Some(store) = store.as_mut() else {
12517 return Ok(());
12518 };
12519 store.commit(pack).map_err(|_| {
12520 failure(
12521 NodeFailureCode::ContextPackMaterializationFailed,
12522 "durable context pack commit failed",
12523 )
12524 })
12525 }
12526
12527 fn commit_context_pack(
12528 &self,
12529 pack: NodeContextPack,
12530 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12531 self.commit_context_pack_durably(&pack)?;
12532 self.context_catalog
12533 .write()
12534 .unwrap_or_else(|poisoned| poisoned.into_inner())
12535 .insert(pack)
12536 .map_err(|_| {
12537 failure(
12538 NodeFailureCode::ContextPackMaterializationFailed,
12539 "context pack catalog is full",
12540 )
12541 })
12542 }
12543
12544 fn commit_context_pack_for_session_record(
12545 &self,
12546 expected: &ManagedSessionRecord,
12547 identity: Option<&ProviderSessionIdentity>,
12548 session: &SessionAddress,
12549 candidate_id: &str,
12550 loaded: &HistorySessionRecord,
12551 pack: NodeContextPack,
12552 allow_clean_detachment: bool,
12553 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12554 let current_root = self.workspace_root(&expected.workspace_id)?;
12555 let _transaction = self
12556 .state_transaction
12557 .lock()
12558 .unwrap_or_else(|poisoned| poisoned.into_inner());
12559 let records = self
12560 .session_records
12561 .lock()
12562 .unwrap_or_else(|poisoned| poisoned.into_inner());
12563 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12564 failure(
12565 NodeFailureCode::SessionRecordConflict,
12566 "managed session record disappeared during context export",
12567 )
12568 })?;
12569 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12570 return Err(failure(
12571 NodeFailureCode::SessionRecordConflict,
12572 "managed session record changed during context export",
12573 ));
12574 }
12575 let bindings = self
12576 .session_bindings
12577 .lock()
12578 .unwrap_or_else(|poisoned| poisoned.into_inner());
12579 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12580 failure(
12581 NodeFailureCode::SessionRecordConflict,
12582 "managed session runtime binding disappeared during context export",
12583 )
12584 })?;
12585 let runtime = self
12586 .handle
12587 .snapshot()
12588 .sessions
12589 .iter()
12590 .find(|snapshot| {
12591 snapshot.instance_id == session.session.instance_id
12592 && snapshot.generation == session.session.generation
12593 })
12594 .cloned()
12595 .ok_or_else(|| {
12596 failure(
12597 NodeFailureCode::SessionRecordConflict,
12598 "managed session runtime disappeared during context export",
12599 )
12600 })?;
12601 let receipt = pack.receipt();
12602 if !session_record_context_export_binding_is_exact(
12603 expected,
12604 current,
12605 identity,
12606 session,
12607 binding,
12608 &runtime.agent_id,
12609 ¤t_root,
12610 allow_clean_detachment,
12611 ) || !session_record_context_export_source_is_usable(
12612 current,
12613 &runtime.status,
12614 allow_clean_detachment,
12615 ) || runtime.history.pending.is_some()
12616 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12617 || runtime.history.loaded.as_ref() != Some(loaded)
12618 || receipt.lineage.source_node_id != self.node_id
12619 || receipt.lineage.source_session != *session
12620 || receipt.lineage.source_provider != current.provider
12621 || receipt.source_message_count != loaded.message_count
12622 {
12623 return Err(failure(
12624 NodeFailureCode::SessionRecordConflict,
12625 "managed session source changed before context catalog commit",
12626 ));
12627 }
12628 self.commit_context_pack_durably(&pack)?;
12629 self.context_catalog
12630 .write()
12631 .unwrap_or_else(|poisoned| poisoned.into_inner())
12632 .insert(pack)
12633 .map_err(|_| {
12634 failure(
12635 NodeFailureCode::ContextPackMaterializationFailed,
12636 "context pack catalog is full",
12637 )
12638 })
12639 }
12640
12641 async fn context_pack_repository(
12642 &self,
12643 workspace_id: &WorkspaceId,
12644 ) -> Result<ContextPackRepository, NodeFailure> {
12645 let first = self
12646 .context_pack_repository_observation(workspace_id)
12647 .await?;
12648 let second = self
12649 .context_pack_repository_observation(workspace_id)
12650 .await?;
12651 stable_context_pack_repository(first, second)
12652 }
12653
12654 async fn context_pack_repository_observation(
12655 &self,
12656 workspace_id: &WorkspaceId,
12657 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12658 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12659 let head = if git.is_repository {
12660 let root = self.workspace_root(workspace_id)?;
12661 match resolve_base_commit_with_timeout(
12662 &root,
12663 "HEAD",
12664 GIT_COMMAND_TIMEOUT_MS,
12665 )
12666 .await
12667 {
12668 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12669 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12670 ContextPackRepositoryHead::Unborn
12671 }
12672 Err(_) => {
12673 return Err(failure(
12674 NodeFailureCode::ContextPackMaterializationFailed,
12675 "context pack HEAD observation failed",
12676 ));
12677 }
12678 }
12679 } else {
12680 ContextPackRepositoryHead::NotRepository
12681 };
12682 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12683 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12684 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12685 failure(
12686 NodeFailureCode::InvalidRepositoryPath,
12687 "context pack selected file path is invalid",
12688 )
12689 })?;
12690 match self.read_workspace_file(workspace_id.clone(), path).await {
12691 Ok(WorkspaceFileRead {
12692 content: WorkspaceFileContent::Utf8 { text, byte_len },
12693 ..
12694 }) => files.push(ContextPackRepositoryFileSource::utf8(
12695 selected_path,
12696 text,
12697 byte_len,
12698 )),
12699 Ok(WorkspaceFileRead {
12700 content: WorkspaceFileContent::NonUtf8 { byte_len },
12701 ..
12702 }) => files.push(ContextPackRepositoryFileSource::skipped(
12703 selected_path,
12704 ContextPackSelectedFileSkipReason::NonUtf8,
12705 Some(byte_len),
12706 )),
12707 Ok(WorkspaceFileRead {
12708 content: WorkspaceFileContent::TooLarge { .. },
12709 ..
12710 }) => files.push(ContextPackRepositoryFileSource::skipped(
12711 selected_path,
12712 ContextPackSelectedFileSkipReason::TooLarge,
12713 None,
12714 )),
12715 Err(error) => {
12716 let reason = if matches!(
12717 error.code,
12718 NodeFailureCode::InvalidRepositoryPath
12719 | NodeFailureCode::RepositoryFileNotRegular
12720 | NodeFailureCode::RepositoryPathUnsafe
12721 ) {
12722 ContextPackSelectedFileSkipReason::Unsafe
12723 } else {
12724 ContextPackSelectedFileSkipReason::Unavailable
12725 };
12726 files.push(ContextPackRepositoryFileSource::skipped(
12727 selected_path,
12728 reason,
12729 None,
12730 ));
12731 }
12732 }
12733 }
12734 Ok(ContextPackRepositoryObservation { head, git, files })
12735 }
12736
12737 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12738 let referenced_by_binding = self
12739 .session_bindings
12740 .lock()
12741 .unwrap_or_else(|poisoned| poisoned.into_inner())
12742 .values()
12743 .any(|binding| {
12744 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12745 });
12746 let referenced_by_record = self
12747 .session_records
12748 .lock()
12749 .unwrap_or_else(|poisoned| poisoned.into_inner())
12750 .records
12751 .values()
12752 .any(|record| record.context_id.as_ref() == Some(context_id));
12753 let referenced_by_materialization = self
12754 .materializations
12755 .lock()
12756 .unwrap_or_else(|poisoned| poisoned.into_inner())
12757 .values()
12758 .any(|ownership| {
12759 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12760 });
12761 if referenced_by_binding
12762 || referenced_by_record
12763 || referenced_by_materialization
12764 {
12765 return Err(failure(
12766 NodeFailureCode::ContextPackBusy,
12767 "context pack is still referenced",
12768 ));
12769 }
12770 let mut catalog = self.context_catalog
12771 .write()
12772 .unwrap_or_else(|poisoned| poisoned.into_inner());
12773 if catalog.get(context_id).is_none() {
12774 return Err(failure(
12775 NodeFailureCode::UnknownContextPack,
12776 "context pack is unavailable",
12777 ));
12778 }
12779 self.spawn_idempotency
12780 .lock()
12781 .unwrap_or_else(|poisoned| poisoned.into_inner())
12782 .entries
12783 .retain(|_, entry| !entry.value.references_context(context_id));
12784 let removed = catalog.remove(context_id);
12785 debug_assert!(removed.is_some(), "validated context pack remains present");
12786 Ok(())
12787 }
12788
12789 async fn dispatch_input_bounded(
12790 &self,
12791 address: &SessionAddress,
12792 action: InputAction,
12793 ) -> Result<(), NodeFailure> {
12794 let started_after = self.current_sequence();
12795 self.pty_input_marks
12796 .lock()
12797 .unwrap_or_else(|poisoned| poisoned.into_inner())
12798 .insert(address.session.instance_id, (Instant::now(), false));
12799 self.pty_input_wake.notify_one();
12800 let command_id = self
12801 .dispatch_bounded(
12802 ControlCommand::SendInput {
12803 instance_id: address.session.instance_id,
12804 action,
12805 },
12806 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12807 )
12808 .await?;
12809 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12813 let mut scan_after = started_after;
12814 let mut accepted_after = None;
12815 loop {
12816 let events = self
12817 .history
12818 .lock()
12819 .unwrap_or_else(|poisoned| poisoned.into_inner())
12820 .events
12821 .iter()
12822 .filter(|envelope| envelope.sequence > scan_after)
12823 .cloned()
12824 .collect::<Vec<_>>();
12825 for envelope in events {
12826 scan_after = scan_after.max(envelope.sequence);
12827 let NodeEvent::Control {
12828 address: event_address,
12829 event,
12830 } = envelope.event
12831 else {
12832 continue;
12833 };
12834 if event_address != *address {
12835 continue;
12836 }
12837 if event.command_id == Some(command_id) {
12838 match &event.event {
12839 ControlEventKind::CommandRejected { message } => {
12840 return Err(failure(NodeFailureCode::InvalidRequest, message));
12841 }
12842 ControlEventKind::InputRequested { .. } => {
12843 accepted_after = Some(envelope.sequence);
12844 continue;
12845 }
12846 _ => {}
12847 }
12848 }
12849 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12850 match &event.event {
12851 ControlEventKind::InputCompleted { .. } => return Ok(()),
12852 ControlEventKind::InputFailed { message, .. } => {
12853 return Err(failure(
12854 NodeFailureCode::BackendOperationFailed,
12855 message,
12856 ));
12857 }
12858 _ => {}
12859 }
12860 }
12861 }
12862 if Instant::now() >= deadline {
12863 return Err(failure(
12864 NodeFailureCode::BackendBusy,
12865 if accepted_after.is_some() {
12866 "input was accepted but did not settle before the bounded deadline"
12867 } else {
12868 "input was not accepted before the bounded deadline"
12869 },
12870 ));
12871 }
12872 sleep(Duration::from_millis(2)).await;
12873 }
12874 }
12875
12876 async fn dispatch_resize_bounded(
12877 &self,
12878 address: &SessionAddress,
12879 size: gate4agent_types::TerminalSize,
12880 ) -> Result<(), NodeFailure> {
12881 let started_after = self.current_sequence();
12882 let command_id = self
12883 .dispatch_bounded(
12884 ControlCommand::Resize {
12885 instance_id: address.session.instance_id,
12886 size,
12887 },
12888 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12889 )
12890 .await?;
12891 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12892 let mut scan_after = started_after;
12893 let mut accepted_after = None;
12894 loop {
12895 let events = self
12896 .history
12897 .lock()
12898 .unwrap_or_else(|poisoned| poisoned.into_inner())
12899 .events
12900 .iter()
12901 .filter(|envelope| envelope.sequence > scan_after)
12902 .cloned()
12903 .collect::<Vec<_>>();
12904 for envelope in events {
12905 scan_after = scan_after.max(envelope.sequence);
12906 let NodeEvent::Control {
12907 address: event_address,
12908 event,
12909 } = envelope.event
12910 else {
12911 continue;
12912 };
12913 if event_address != *address {
12914 continue;
12915 }
12916 if event.command_id == Some(command_id) {
12917 match &event.event {
12918 ControlEventKind::CommandRejected { message } => {
12919 return Err(failure(NodeFailureCode::InvalidRequest, message));
12920 }
12921 ControlEventKind::ResizeRequested {
12922 size: requested_size,
12923 ..
12924 } if *requested_size == size => {
12925 accepted_after = Some(envelope.sequence);
12926 continue;
12927 }
12928 _ => {}
12929 }
12930 }
12931 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12932 match &event.event {
12933 ControlEventKind::Resized { size: settled_size }
12934 if *settled_size == size =>
12935 {
12936 return Ok(());
12937 }
12938 ControlEventKind::ResizeFailed { message } => {
12939 return Err(failure(
12940 NodeFailureCode::BackendOperationFailed,
12941 message,
12942 ));
12943 }
12944 _ => {}
12945 }
12946 }
12947 }
12948 if Instant::now() >= deadline {
12949 return Err(failure(
12950 NodeFailureCode::BackendBusy,
12951 if accepted_after.is_some() {
12952 "resize was accepted but did not settle before the bounded deadline"
12953 } else {
12954 "resize was not accepted before the bounded deadline"
12955 },
12956 ));
12957 }
12958 sleep(Duration::from_millis(2)).await;
12959 }
12960 }
12961
12962 async fn spawn_session(
12963 &self,
12964 workspace_id: WorkspaceId,
12965 provider: AgentId,
12966 mode: SessionMode,
12967 terminal_size: gate4agent_types::TerminalSize,
12968 initial_prompt: Option<String>,
12969 ) -> Result<SessionAddress, NodeFailure> {
12970 if self.managed_worktrees.lock()
12971 .unwrap_or_else(|poisoned| poisoned.into_inner())
12972 .lease_for_workspace(&workspace_id)
12973 .is_some()
12974 {
12975 return Err(failure(
12976 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12977 "managed worktree targets may only be spawned through their lease request",
12978 ));
12979 }
12980 self
12981 .spawn_session_with_deadline(
12982 workspace_id,
12983 provider.clone(),
12984 mode,
12985 terminal_size,
12986 initial_prompt,
12987 None,
12988 None,
12989 None,
12990 None,
12991 None,
12992 SpawnRecordPolicy::ProviderIdentityOnly,
12993 None,
12994 &[],
12995 None,
12996 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13003 None,
13004 None,
13005 )
13006 .await
13007 .map(|(session, _runtime_policy)| session)
13008 }
13009
13010 async fn spawn_session_with_deadline(
13011 &self,
13012 workspace_id: WorkspaceId,
13013 provider: AgentId,
13014 mode: SessionMode,
13015 terminal_size: gate4agent_types::TerminalSize,
13016 initial_prompt: Option<String>,
13017 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13018 bundle: Option<ResolvedBundleReceipt>,
13019 context: Option<ResolvedContextPackReceipt>,
13020 managed_authority: Option<ManagedWorktreeLeaseId>,
13021 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13022 record_policy: SpawnRecordPolicy,
13023 deadline: Option<Instant>,
13024 required_capabilities: &[ProviderRuntimeCapability],
13025 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13026 approval_level: ApprovalLevel,
13027 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13028 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13034 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13035 let reserved_lease = self.managed_worktrees.lock()
13036 .unwrap_or_else(|poisoned| poisoned.into_inner())
13037 .lease_for_workspace(&workspace_id);
13038 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13039 (Some(expected), Some(provided)) if expected == provided => {}
13040 (None, None) => {}
13041 _ => {
13042 return Err(failure(
13043 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13044 "spawn lacks exact managed worktree lease authority",
13045 ));
13046 }
13047 }
13048 self.ensure_workspace_environment_profile_compatible(
13052 &workspace_id,
13053 environment_profile.as_ref(),
13054 )?;
13055 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13056 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13057 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13058 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13059 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13060 };
13061 let transport = match mode {
13067 SessionMode::Pty => TransportKind::Pty,
13068 SessionMode::Inline => TransportKind::Pipe,
13069 SessionMode::Acp => TransportKind::Acp,
13070 };
13071 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13072 let acp_transport = self.provider_supports_acp_transport(&provider);
13073 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13074 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13075 NodeFailureCode::UnsupportedSpawnCapability,
13076 "pre-admitted provider runtime no longer satisfies spawn requirements",
13077 ))?;
13078 runtime_policy
13079 } else if let Some(deadline) = deadline {
13080 let remaining = spawn_deadline_remaining(deadline)?;
13081 timeout(
13082 remaining,
13083 self.admit_provider_runtime(&provider, runtime_requirement),
13084 )
13085 .await
13086 .map_err(|_| {
13087 failure(
13088 NodeFailureCode::SpawnDeadlineExceeded,
13089 "provider admission exceeded the spawn deadline",
13090 )
13091 })??
13092 } else {
13093 self.admit_provider_runtime(&provider, runtime_requirement).await?
13094 };
13095 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13096 let runtime_policy =
13108 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13109 if required_capabilities
13110 .iter()
13111 .any(|capability| !runtime_policy.admits(*capability))
13112 {
13113 return Err(failure(
13114 NodeFailureCode::UnsupportedSpawnCapability,
13115 "provider runtime does not admit a required spawn capability",
13116 ));
13117 }
13118 if let Some(deadline) = deadline {
13119 spawn_deadline_remaining(deadline)?;
13120 }
13121 self.ensure_binding_capacity()?;
13122 let mut working_directory = self.workspace_root(&workspace_id)?;
13123 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13124 let session = SessionKey {
13125 instance_id,
13126 generation: SessionGeneration(1),
13127 };
13128 let address = SessionAddress {
13129 workspace_id: workspace_id.clone(),
13130 session,
13131 };
13132 tracing::info!(
13140 node_id = %self.node_id,
13141 workspace_id = %workspace_id,
13142 provider = %provider,
13143 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13144 session = ?address.session,
13145 "session spawn request accepted",
13146 );
13147 let mut prepared_spawn = SpawnPreparer::prepare(
13148 self,
13149 SpawnPrepareRequest {
13150 instance_id,
13151 address: &address,
13152 provider: &provider,
13153 mode,
13154 environment_profile: environment_profile.as_ref(),
13155 bundle: bundle.as_ref(),
13156 context: context.as_ref(),
13157 managed_authority: managed_authority.as_ref(),
13158 harness_mcp,
13159 approval_level,
13160 network_allowlist: network_allowlist.as_ref(),
13161 browser_profile_id,
13162 },
13163 )?;
13164 let materialization_id = prepared_spawn.materialization_id.take();
13165 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13166 let record_id = self.bind_spawn_session_with_materialization(
13167 &address,
13168 provider.clone(),
13169 mode,
13170 runtime_policy,
13171 record_policy,
13172 environment_profile,
13173 bundle,
13174 context,
13175 materialization_id,
13176 )?;
13177 prepared_spawn.retain_session_guards();
13178 let session_provider = provider.clone();
13182 let agent_id = provider;
13183 let register_command_id = match self.dispatch_bounded(
13184 ControlCommand::Register {
13185 instance_id,
13186 agent_id,
13187 transport,
13188 },
13189 dispatch_timeout,
13190 )
13191 .await {
13192 Ok(command_id) => command_id,
13193 Err(error) => {
13194 if let Some(binding) = self.remove_binding(&address) {
13195 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13196 }
13197 if let Some(record_id) = record_id.as_ref() {
13198 self.discard_record(record_id)?;
13199 }
13200 return Err(spawn_dispatch_error(error, deadline));
13201 }
13202 };
13203 let start_timeout = match spawn_dispatch_timeout(deadline) {
13204 Ok(timeout) => timeout,
13205 Err(error) => {
13206 let recovery = self
13207 .rollback_spawn(
13208 &address,
13209 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13210 )
13211 .await;
13212 return match recovery {
13213 Ok(()) => {
13214 if let Some(record_id) = record_id.as_ref() {
13215 self.discard_record(record_id)?;
13216 }
13217 Err(error)
13218 }
13219 Err(recovery_error) => {
13220 if let Some(record_id) = record_id.as_ref() {
13221 self.mark_record_error(
13222 record_id,
13223 "spawn deadline elapsed and registration recovery failed",
13224 )?;
13225 }
13226 Err(recovery_error)
13227 }
13228 };
13229 }
13230 };
13231 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13232 if let Err(recovery_error) = self
13233 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13234 .await
13235 {
13236 return Err(recovery_error);
13237 }
13238 if let Some(record_id) = record_id.as_ref() {
13239 self.discard_record(record_id)?;
13240 }
13241 return Err(start_error);
13242 }
13243 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13245 working_directory = dir.to_string_lossy().into_owned();
13246 }
13247 let start_result = self
13248 .dispatch_bounded(
13249 ControlCommand::Start {
13250 instance_id,
13251 runtime_policy,
13252 request: StartRequest {
13253 working_directory,
13254 terminal_size,
13255 initial_prompt,
13256 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13257 approval_level,
13258 },
13259 },
13260 start_timeout,
13261 )
13262 .await;
13263 let start_command_id = match start_result {
13264 Ok(command_id) => command_id,
13265 Err(start_error) => {
13266 let start_error = spawn_dispatch_error(start_error, deadline);
13267 let recovery = self
13268 .rollback_spawn(
13269 &address,
13270 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13271 )
13272 .await;
13273 return match recovery {
13274 Ok(()) => {
13275 if let Some(record_id) = record_id.as_ref() {
13276 self.discard_record(record_id)?;
13277 }
13278 Err(start_error)
13279 }
13280 Err(recovery_error) => {
13281 let diagnostic = format!(
13282 "start failed and registration recovery failed: {}",
13283 recovery_error.message,
13284 );
13285 if let Some(record_id) = record_id.as_ref() {
13286 self.mark_record_error(record_id, &diagnostic)?;
13287 }
13288 Err(failure(recovery_error.code, &diagnostic))
13289 }
13290 };
13291 }
13292 };
13293
13294 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13295 loop {
13296 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13305 .or_else(|| self.take_rejected_command_reason(start_command_id))
13306 {
13307 self.rollback_spawn(
13308 &address,
13309 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13310 ).await.map_err(|error| {
13311 failure(
13312 error.code,
13313 &format!(
13314 "spawn was rejected ({}) and registration recovery failed: {}",
13315 reason.message, error.message
13316 ),
13317 )
13318 })?;
13319 if let Some(record_id) = record_id.as_ref() {
13320 self.discard_record(record_id)?;
13321 }
13322 let code = if reason.unsupported_transport {
13323 NodeFailureCode::UnsupportedTransport
13324 } else {
13325 NodeFailureCode::BackendOperationFailed
13326 };
13327 return Err(failure(code, &reason.message));
13328 }
13329 if let Some(current) = self
13330 .handle
13331 .snapshot()
13332 .sessions
13333 .iter()
13334 .find(|current| current.instance_id == instance_id)
13335 {
13336 if current.generation != session.generation {
13337 let divergence = failure(
13338 NodeFailureCode::StaleGeneration,
13339 "spawn generation diverged",
13340 );
13341 return match self
13342 .rollback_spawn(
13343 &address,
13344 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13345 )
13346 .await
13347 {
13348 Ok(()) => {
13349 if let Some(record_id) = record_id.as_ref() {
13350 self.discard_record(record_id)?;
13351 }
13352 Err(divergence)
13353 }
13354 Err(recovery_error) => {
13355 if let Some(record_id) = record_id.as_ref() {
13356 self.mark_record_error(
13357 record_id,
13358 "spawn generation diverged and recovery failed",
13359 )?;
13360 }
13361 Err(recovery_error)
13362 }
13363 };
13364 }
13365 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13366 prepared_spawn.retain_launch_guards();
13367 return Ok((address, runtime_policy));
13368 }
13369 }
13370 if Instant::now() >= commit_deadline {
13371 self.rollback_spawn(
13372 &address,
13373 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13374 ).await.map_err(|error| {
13375 failure(
13376 error.code,
13377 &format!(
13378 "start did not commit and registration recovery failed: {}",
13379 error.message
13380 ),
13381 )
13382 })?;
13383 if let Some(record_id) = record_id.as_ref() {
13384 self.discard_record(record_id)?;
13385 }
13386 return Err(failure(
13387 if deadline.is_some() {
13388 NodeFailureCode::SpawnDeadlineExceeded
13389 } else {
13390 NodeFailureCode::BackendBusy
13391 },
13392 "start did not commit before the bounded deadline; registration was removed",
13393 ));
13394 }
13395 sleep(Duration::from_millis(2)).await;
13396 }
13397 }
13398
13399 fn effective_spawn_runtime_policy(
13400 &self,
13401 runtime_policy: ProviderRuntimePolicy,
13402 ) -> ProviderRuntimePolicy {
13403 #[cfg(feature = "fixture")]
13404 let runtime_policy = if self.fixture_semantic_hook_policy {
13405 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13406 .expect("monitoring Hook fixture policy is internally valid")
13407 } else {
13408 runtime_policy
13409 };
13410 runtime_policy
13411 }
13412
13413 fn verify_harness_mcp_before_start(
13414 &self,
13415 prepared: Option<&PreparedHarnessMcpSpawn>,
13416 ) -> Result<(), NodeFailure> {
13417 prepared
13418 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13419 .transpose()
13420 .map(|_| ())
13421 .map_err(harness_mcp_failure)
13422 }
13423
13424 async fn rollback_spawn(
13425 &self,
13426 address: &SessionAddress,
13427 commit_timeout: Duration,
13428 ) -> Result<(), NodeFailure> {
13429 self.dispatch_bounded(
13430 ControlCommand::Remove {
13431 instance_id: address.session.instance_id,
13432 },
13433 commit_timeout,
13434 )
13435 .await?;
13436 self.wait_until_removed(address, commit_timeout).await?;
13437 if let Some(binding) = self.remove_binding(address) {
13438 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13439 }
13440 Ok(())
13441 }
13442
13443 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13444 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13445 self.dispatch_bounded(
13446 ControlCommand::Remove {
13447 instance_id: address.session.instance_id,
13448 },
13449 commit_timeout,
13450 )
13451 .await?;
13452 self.wait_until_removed(address, commit_timeout).await?;
13453 let removed = self.remove_binding(address);
13454 if let Some(binding) = removed.as_ref() {
13455 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13456 }
13457 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13458 let snapshot = self.managed_worktrees.lock()
13459 .unwrap_or_else(|poisoned| poisoned.into_inner())
13460 .release_session(
13461 &lease_id,
13462 self.incarnation_id,
13463 address.session.instance_id,
13464 address.session.generation,
13465 unix_time_ms(),
13466 );
13467 if self.persist_state().is_err() {
13468 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13469 }
13470 if let Some(snapshot) = snapshot {
13471 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13472 }
13473 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13474 }
13475 Ok(())
13476 }
13477
13478 async fn wait_until_removed(
13524 &self,
13525 address: &SessionAddress,
13526 commit_timeout: Duration,
13527 ) -> Result<(), NodeFailure> {
13528 let deadline = Instant::now() + commit_timeout;
13529 let mut next_dispatch = Instant::now();
13530 loop {
13531 let snapshot = self.handle.snapshot();
13532 let status = snapshot
13533 .sessions
13534 .iter()
13535 .find(|session| session.instance_id == address.session.instance_id)
13536 .map(|session| session.status.clone());
13537 let Some(status) = status else {
13538 return Ok(());
13539 };
13540 let now = Instant::now();
13541 if now >= deadline {
13542 return Err(failure(
13543 NodeFailureCode::BackendBusy,
13544 "remove did not commit before the bounded deadline; session binding was retained",
13545 ));
13546 }
13547 if status.allows_remove() && now >= next_dispatch {
13548 let _ = self.dispatch(ControlCommand::Remove {
13549 instance_id: address.session.instance_id,
13550 });
13551 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13552 }
13553 sleep(Duration::from_millis(2)).await;
13554 }
13555 }
13556
13557 fn resync(&self, after_sequence: u64) -> NodeResponse {
13558 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13559 let event_sequence = history.last_sequence;
13560 let oldest_available_sequence = history.replay_floor_sequence;
13561 let events = history
13562 .events
13563 .iter()
13564 .filter(|event| event.sequence > after_sequence)
13565 .cloned()
13566 .collect();
13567 drop(history);
13568 NodeResponse::Resync {
13569 event_sequence,
13570 oldest_available_sequence,
13571 snapshot: self.snapshot(),
13572 events,
13573 }
13574 }
13575}
13576
13577fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13582 match kind {
13583 AgentStreamChunkKindV1::Text { .. } => "text",
13584 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13585 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13586 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13587 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13588 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13589 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13590 }
13591}
13592
13593async fn accept_connections(
13607 endpoint: &str,
13608 call_home: Option<std::net::SocketAddr>,
13609 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13610 shared: Arc<NodeShared>,
13611) -> Result<(), NodeServerError> {
13612 let mut connections = JoinSet::new();
13613 let result = match call_home {
13614 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13615 Some(relay) => {
13616 let listener =
13617 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13618 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13619 tokio::pin!(listener);
13620 tokio::pin!(dialler);
13621 tokio::select! {
13622 result = &mut listener => result,
13623 result = &mut dialler => result,
13624 }
13625 }
13626 };
13627 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13628 while !connections.is_empty() {
13629 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13630 if remaining.is_zero()
13631 || timeout(remaining, connections.join_next()).await.is_err()
13632 {
13633 break;
13634 }
13635 }
13636 connections.abort_all();
13637 while connections.join_next().await.is_some() {}
13638 result
13639}
13640
13641const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13645
13646async fn call_home_loop(
13660 relay: std::net::SocketAddr,
13661 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13662 shared: Arc<NodeShared>,
13663) -> Result<(), NodeServerError> {
13664 let mut failures = 0_usize;
13665 loop {
13666 if shared.shutdown.load(Ordering::Acquire) {
13667 return Ok(());
13668 }
13669 let permit = tokio::select! {
13670 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13671 permit.map_err(|_| NodeServerError::PreauthClosed)?
13672 }
13673 _ = shared.shutdown_notify.notified() => return Ok(()),
13674 };
13675 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13676 Ok(()) => {
13677 failures = 0;
13681 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13682 }
13683 Err(error) => {
13684 failures = failures.saturating_add(1);
13685 tracing::warn!(
13686 node_id = %shared.node_id,
13687 %relay,
13688 cause = %error,
13689 attempt = failures,
13690 "call-home connection failed",
13691 );
13692 let backoff = call_home_backoff(failures);
13693 tokio::select! {
13694 _ = sleep(backoff) => {}
13695 _ = shared.shutdown_notify.notified() => return Ok(()),
13696 }
13697 }
13698 }
13699 }
13700}
13701
13702const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13704const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13706
13707fn call_home_keepalive() -> socket2::TcpKeepalive {
13708 let keepalive = socket2::TcpKeepalive::new()
13709 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13710 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13711 #[cfg(not(windows))]
13712 let keepalive = keepalive.with_retries(3);
13713 keepalive
13714}
13715
13716fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13717 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13718}
13719
13720pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13722 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13723}
13724
13725fn call_home_backoff(attempt: usize) -> Duration {
13727 Duration::from_millis(
13728 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13729 )
13730}
13731
13732
13733async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13736 #[cfg(feature = "wireguard")]
13737 {
13738 let std_stream = tokio::task::spawn_blocking(move || {
13739 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13740 })
13741 .await
13742 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13743 .map_err(NodeServerError::CallHomeTransport)?;
13744 std_stream
13745 .set_nonblocking(true)
13746 .map_err(NodeServerError::CallHomeTransport)?;
13747 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13748 }
13749 #[cfg(not(feature = "wireguard"))]
13750 {
13751 tokio::net::TcpStream::connect(relay)
13752 .await
13753 .map_err(NodeServerError::CallHomeTransport)
13754 }
13755}
13756
13757async fn dial_relay_and_serve(
13759 relay: std::net::SocketAddr,
13760 identity: Option<&hatchery_enroll::NodeIdentity>,
13761 shared: Arc<NodeShared>,
13762 preauth_permit: OwnedSemaphorePermit,
13763) -> Result<(), NodeServerError> {
13764 let mut stream = dial_call_home(relay).await?;
13765 let _ = stream.set_nodelay(true);
13769 if let Err(error) = enable_call_home_keepalive(&stream) {
13775 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13776 }
13777 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13778 .await
13779 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13780 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13781 let mut session_token = None;
13782 if let Some(identity) = identity {
13783 let mut magic = [0_u8; 4];
13787 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13788 .await
13789 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13790 .map_err(NodeServerError::CallHomeTransport)?;
13791 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13792 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13793 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13794 .await
13795 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13796 .map_err(NodeServerError::CallHomeTransport)?;
13797 let (answer, token) = identity
13798 .answer_challenge(shared.node_id.as_str(), &frame)
13799 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13800 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13801 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13802 session_token = Some(token);
13803 }
13804 }
13805 serve_connection(stream, shared, session_token, preauth_permit).await
13806}
13807
13808async fn accept_connections_inner(
13809 endpoint: &str,
13810 shared: Arc<NodeShared>,
13811 connections: &mut JoinSet<Result<(), NodeServerError>>,
13812) -> Result<(), NodeServerError> {
13813 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13814 loop {
13815 while connections.try_join_next().is_some() {}
13816 if shared.shutdown.load(Ordering::Acquire) {
13817 return Ok(());
13818 }
13819 let preauth_permit = tokio::select! {
13820 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13821 permit.map_err(|_| NodeServerError::PreauthClosed)?
13822 }
13823 _ = shared.shutdown_notify.notified() => return Ok(()),
13824 };
13825 let server = tokio::select! {
13826 result = listener.accept() => result?,
13827 _ = shared.shutdown_notify.notified() => return Ok(()),
13828 };
13829 let connection_shared = Arc::clone(&shared);
13830 connections.spawn(async move {
13831 serve_connection(server, connection_shared, None, preauth_permit).await
13832 });
13833 if shared.shutdown.load(Ordering::Acquire) {
13834 return Ok(());
13835 }
13836 }
13837}
13838
13839async fn serve_connection<S>(
13847 mut pipe: S,
13848 shared: Arc<NodeShared>,
13849 session_token: Option<String>,
13850 preauth_permit: OwnedSemaphorePermit,
13851) -> Result<(), NodeServerError>
13852where
13853 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13854{
13855 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13857 let hello_frame = timeout(
13858 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13859 read_json_frame_limited_body_timeout(
13860 &mut pipe,
13861 MAX_NODE_HELLO_FRAME_BYTES,
13862 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13863 ),
13864 )
13865 .await
13866 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13867 let ClientFrame::Hello(hello) = hello_frame else {
13868 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13869 };
13870 if hello.build_stamp != BUILD_STAMP {
13871 return Err(NodeServerError::Handshake(format!(
13872 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13873 hello.build_stamp,
13874 )));
13875 }
13876 let compatibility = match hello.compatibility.as_ref() {
13877 Some(offer) => {
13878 let mut selected = node_compatibility_support(&shared)?
13879 .negotiate(offer)
13880 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13881 project_negotiated_provider_ids(&mut selected);
13882 Some(selected)
13883 }
13884 None => None,
13885 };
13886 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13887 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13888 (Some(offer), Some(selected)) => negotiated_auth_proof(
13889 auth_token,
13890 AuthDirection::Server,
13891 hello.role,
13892 &hello.client_nonce,
13893 &server_nonce,
13894 offer,
13895 selected,
13896 ),
13897 (None, None) => auth_proof(
13898 auth_token,
13899 AuthDirection::Server,
13900 hello.role,
13901 &hello.client_nonce,
13902 &server_nonce,
13903 ),
13904 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13905 }
13906 .map_err(NodeServerError::Authentication)?;
13907 write_json_frame_limited(
13908 &mut pipe,
13909 &ServerFrame::Challenge(ServerChallenge {
13910 build_stamp: BUILD_STAMP.to_owned(),
13911 server_nonce,
13912 server_proof,
13913 compatibility: compatibility.clone(),
13914 }),
13915 MAX_NODE_HELLO_FRAME_BYTES,
13916 )
13917 .await?;
13918 let authentication = timeout(
13919 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13920 read_json_frame_limited_body_timeout(
13921 &mut pipe,
13922 MAX_NODE_HELLO_FRAME_BYTES,
13923 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13924 ),
13925 )
13926 .await
13927 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13928 let ClientFrame::Authenticate(authentication) = authentication else {
13929 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13930 };
13931 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13932 (Some(offer), Some(selected)) => negotiated_auth_proof(
13933 auth_token,
13934 AuthDirection::Client,
13935 hello.role,
13936 &hello.client_nonce,
13937 &server_nonce,
13938 offer,
13939 selected,
13940 ),
13941 (None, None) => auth_proof(
13942 auth_token,
13943 AuthDirection::Client,
13944 hello.role,
13945 &hello.client_nonce,
13946 &server_nonce,
13947 ),
13948 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13949 }
13950 .map_err(NodeServerError::Authentication)?;
13951 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13952 return Err(NodeServerError::Handshake("access denied".to_owned()));
13953 }
13954 let selected_capabilities = compatibility
13955 .as_ref()
13956 .map(|selected| selected.capabilities.clone())
13957 .unwrap_or_default();
13958 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13959 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13960 });
13961 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13962 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13963 });
13964 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13965 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13966 });
13967 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13968 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13969 });
13970 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13971 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13972 });
13973 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13974 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13975 }) && selected_capabilities.iter().any(|capability| {
13976 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13977 });
13978 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13979 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13980 });
13981 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13982 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13983 });
13984 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13985 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13986 });
13987 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13988 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13989 });
13990 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13991 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13992 });
13993 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13994 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13995 });
13996 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13997 .try_acquire_owned()
13998 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13999 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14000 let _release_guard = ControllerReleaseGuard {
14001 shared: Arc::clone(&shared),
14002 connection_id,
14003 };
14004 drop(preauth_permit);
14005 let _authenticated_permit = authenticated_permit;
14006 let mut event_rx = shared.event_tx.subscribe();
14007 let mut terminal_event_rx = terminal_event_subscription(
14008 &shared,
14009 include_terminal_frame_events,
14010 );
14011 let mut agent_stream_event_rx = agent_stream_event_subscription(
14012 &shared,
14013 include_agent_stream_events,
14014 );
14015 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14016 &shared,
14017 include_harness_mcp_proxy,
14018 );
14019 write_json_frame(
14020 &mut pipe,
14021 &ServerFrame::Hello(NodeHello {
14022 build_stamp: BUILD_STAMP.to_owned(),
14023 incarnation_id: shared.incarnation_id,
14024 connection_id,
14025 role: hello.role,
14026 event_sequence: shared.current_sequence(),
14027 controller: shared.controller_state(),
14028 snapshot: snapshot_for_wire(
14029 &shared,
14030 include_provider_runtime_status,
14031 include_open_provider_ids,
14032 include_spawn_profiles,
14033 include_managed_worktrees,
14034 include_child_environment_profiles,
14035 include_session_bundles,
14036 include_history_context_packs,
14037 include_agent_progress,
14038 include_session_task_correlation,
14039 ),
14040 compatibility,
14041 }),
14042 )
14043 .await?;
14044 let (mut reader, mut writer) = tokio::io::split(pipe);
14045 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14046 let reader_task = tokio::spawn(async move {
14047 loop {
14048 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14049 &mut reader,
14050 MAX_NODE_CLIENT_FRAME_BYTES,
14051 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14052 )
14053 .await;
14054 let terminal = frame.is_err();
14055 if frame_tx.send(frame).await.is_err() || terminal {
14056 break;
14057 }
14058 }
14059 });
14060 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14061 let mut pending_events = Vec::new();
14062 let mut resync_required = false;
14063 let mut discard_events_through = 0_u64;
14064 loop {
14065 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14070 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14071 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14072 for _ in 0..durable_drain_budget {
14073 match event_rx.try_recv() {
14074 Ok(event) => queue_connection_event(
14075 &mut pending_events,
14076 event,
14077 discard_events_through,
14078 ),
14079 Err(broadcast::error::TryRecvError::Empty) => break,
14080 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14081 resync_required = true;
14082 break;
14083 }
14084 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14085 }
14086 }
14087 if let Some(receiver) = terminal_event_rx.as_mut() {
14088 match receiver.try_recv() {
14089 Ok(events) => queue_connection_event_batch(
14090 &mut pending_events,
14091 &events,
14092 discard_events_through,
14093 ),
14094 Err(broadcast::error::TryRecvError::Empty) => {}
14095 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14096 resync_required = true;
14097 }
14098 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14099 }
14100 }
14101 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14102 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14112 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14113 for _ in 0..agent_stream_drain_budget {
14114 match receiver.try_recv() {
14115 Ok(events) => queue_connection_event_batch(
14116 &mut pending_events,
14117 &events,
14118 discard_events_through,
14119 ),
14120 Err(broadcast::error::TryRecvError::Empty) => break,
14121 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14122 tracing::warn!(
14123 connection_id,
14124 dropped,
14125 "agent stream event subscription lagged; forcing a resync",
14126 );
14127 resync_required = true;
14128 break;
14129 }
14130 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14131 }
14132 }
14133 }
14134 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14135 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14141 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14142 for _ in 0..harness_mcp_proxy_drain_budget {
14143 match receiver.try_recv() {
14144 Ok(events) => queue_connection_event_batch(
14145 &mut pending_events,
14146 &events,
14147 discard_events_through,
14148 ),
14149 Err(broadcast::error::TryRecvError::Empty) => break,
14150 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14151 tracing::warn!(
14152 connection_id,
14153 dropped,
14154 "harness MCP proxy event subscription lagged; forcing a resync",
14155 );
14156 resync_required = true;
14157 break;
14158 }
14159 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14160 }
14161 }
14162 }
14163 if resync_required {
14164 let event = resync_required_event(&shared);
14165 apply_connection_resync_watermark(
14166 &mut pending_events,
14167 &mut discard_events_through,
14168 event.sequence,
14169 );
14170 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14171 resync_required = false;
14172 continue;
14173 }
14174 if !pending_events.is_empty() {
14175 pending_events.sort_unstable_by_key(|event| event.sequence);
14176 let burst_len = connection_event_burst_len(pending_events.len());
14177 shared
14178 .connection_events_sent
14179 .fetch_add(burst_len as u64, Ordering::Relaxed);
14180 for event in pending_events.drain(..burst_len) {
14181 let event = if include_managed_worktrees {
14182 Some(event)
14183 } else {
14184 project_event_without_managed_worktrees(event)
14185 };
14186 let event = event.and_then(|event| if include_open_provider_ids {
14187 Some(event)
14188 } else {
14189 project_event_legacy_provider_ids(&shared, event)
14190 });
14191 let event = event.map(|event| if include_child_environment_profiles {
14192 event
14193 } else {
14194 project_event_without_child_environment_profile(event)
14195 });
14196 let event = event.map(|event| if include_session_bundles {
14197 event
14198 } else {
14199 project_event_without_session_bundle(event)
14200 });
14201 let event = event.map(|event| {
14202 project_event_history_for_wire(event, include_history_context_packs)
14203 });
14204 let event = event.map(|event| if include_history_context_packs {
14205 event
14206 } else {
14207 project_event_without_context_pack(event)
14208 });
14209 let event = event.map(|event| if include_session_task_correlation {
14210 event
14211 } else {
14212 project_event_without_session_task_binding(event)
14213 });
14214 let exact_harness_controller = hello.role == ClientRole::Operator
14215 && shared.controller_state().is_some_and(|controller| {
14216 controller.connection_id == connection_id
14217 });
14218 let event = event.and_then(|event| {
14219 if include_harness_mcp_proxy && exact_harness_controller {
14220 Some(event)
14221 } else {
14222 project_event_without_harness_mcp_proxy(event)
14223 }
14224 });
14225 if let Some(event) = event {
14226 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14227 }
14228 }
14229 }
14230 tokio::select! {
14231 biased;
14232 frame = frame_rx.recv() => {
14233 let frame = match frame {
14234 Some(frame) => frame,
14235 None => break,
14236 };
14237 let frame = match frame {
14238 Ok(frame) => frame,
14239 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14240 Err(error) => return Err(error.into()),
14241 };
14242 let ClientFrame::Request(request) = frame else {
14243 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14244 };
14245 #[cfg(feature = "fixture")]
14246 let probe_spawn_managed_worktree_v2 = matches!(
14247 &request.request,
14248 NodeRequest::SpawnManagedWorktreeV2 { .. }
14249 );
14250 let requires_open_provider_ids =
14251 request_requires_open_provider_ids(&shared, &request.request);
14252 let requires_child_environment_profile =
14253 request_requires_child_environment_profile(&shared, &request.request);
14254 let requires_session_bundle =
14255 request_requires_session_bundle(&shared, &request.request);
14256 let requires_history_context_pack =
14257 request_requires_history_context_pack(&shared, &request.request);
14258 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14259 ResponseEnvelope {
14260 request_id: request.request_id,
14261 result: Err(failure(
14262 NodeFailureCode::UnsupportedCapability,
14263 "open provider IDs were not negotiated",
14264 )),
14265 }
14266 } else if requires_child_environment_profile
14267 && !include_child_environment_profiles
14268 {
14269 ResponseEnvelope {
14270 request_id: request.request_id,
14271 result: Err(failure(
14272 NodeFailureCode::UnsupportedCapability,
14273 "child environment profiles were not negotiated",
14274 )),
14275 }
14276 } else if requires_session_bundle && !include_session_bundles {
14277 ResponseEnvelope {
14278 request_id: request.request_id,
14279 result: Err(failure(
14280 NodeFailureCode::UnsupportedCapability,
14281 "session bundles were not negotiated",
14282 )),
14283 }
14284 } else if requires_history_context_pack && !include_history_context_packs {
14285 ResponseEnvelope {
14286 request_id: request.request_id,
14287 result: Err(failure(
14288 NodeFailureCode::UnsupportedCapability,
14289 "history context packs were not negotiated",
14290 )),
14291 }
14292 } else if request_uses_unnegotiated_capability(
14293 &request.request,
14294 &selected_capabilities,
14295 )
14296 {
14297 ResponseEnvelope {
14298 request_id: request.request_id,
14299 result: Err(failure(
14300 NodeFailureCode::UnsupportedCapability,
14301 "request capability was not negotiated",
14302 )),
14303 }
14304 } else {
14305 process_request(&shared, connection_id, hello.role, request).await
14306 };
14307 #[cfg(feature = "fixture")]
14308 if probe_spawn_managed_worktree_v2 {
14309 shared
14310 .fixture_spawn_managed_worktree_v2_failure_probe
14311 .record(reply.result.as_ref().err().map(|failure| failure.code));
14312 }
14313 if !include_provider_runtime_status {
14314 clear_response_provider_runtime_status(&mut reply);
14315 }
14316 if !include_managed_worktrees {
14317 project_response_without_managed_worktrees(&mut reply);
14318 }
14319 if !include_open_provider_ids {
14320 project_response_legacy_provider_ids(&shared, &mut reply);
14321 }
14322 if !include_child_environment_profiles {
14323 project_response_without_child_environment_profile(&mut reply);
14324 }
14325 if !include_session_bundles {
14326 project_response_without_session_bundle(&mut reply);
14327 }
14328 project_response_history_for_wire(
14329 &mut reply,
14330 include_history_context_packs,
14331 );
14332 if !include_history_context_packs {
14333 project_response_without_context_pack(&mut reply);
14334 }
14335 if !include_agent_progress {
14336 project_response_without_agent_progress(&mut reply);
14337 }
14338 if !include_session_task_correlation {
14339 project_response_without_session_task_binding(&mut reply);
14340 }
14341 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14342 }
14343 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14344 event = event_rx.recv() => {
14345 match event {
14346 Ok(event) => queue_connection_event(
14347 &mut pending_events,
14348 event,
14349 discard_events_through,
14350 ),
14351 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14352 Err(broadcast::error::RecvError::Closed) => break,
14353 }
14354 }
14355 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14356 match events {
14357 Ok(events) => queue_connection_event_batch(
14358 &mut pending_events,
14359 &events,
14360 discard_events_through,
14361 ),
14362 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14363 Err(broadcast::error::RecvError::Closed) => break,
14364 }
14365 }
14366 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14367 match events {
14368 Ok(events) => queue_connection_event_batch(
14369 &mut pending_events,
14370 &events,
14371 discard_events_through,
14372 ),
14373 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14374 Err(broadcast::error::RecvError::Closed) => break,
14375 }
14376 }
14377 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14378 match events {
14379 Ok(events) => queue_connection_event_batch(
14380 &mut pending_events,
14381 &events,
14382 discard_events_through,
14383 ),
14384 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14385 Err(broadcast::error::RecvError::Closed) => break,
14386 }
14387 }
14388 _ = shared.shutdown_notify.notified() => break,
14389 }
14390 }
14391 Ok(())
14392}
14393
14394async fn receive_terminal_event_batch(
14395 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14396) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14397 match receiver {
14398 Some(receiver) => receiver.recv().await,
14399 None => std::future::pending().await,
14400 }
14401}
14402
14403fn terminal_event_subscription(
14404 shared: &NodeShared,
14405 enabled: bool,
14406) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14407 enabled.then(|| shared.terminal_event_tx.subscribe())
14408}
14409
14410fn agent_stream_event_subscription(
14414 shared: &NodeShared,
14415 enabled: bool,
14416) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14417 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14418}
14419
14420fn harness_mcp_proxy_event_subscription(
14425 shared: &NodeShared,
14426 enabled: bool,
14427) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14428 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14429}
14430
14431fn queue_connection_event(
14432 pending: &mut Vec<NodeEventEnvelope>,
14433 event: NodeEventEnvelope,
14434 discard_through: u64,
14435) {
14436 if event.sequence <= discard_through
14437 && !event.event.requires_harness_mcp_proxy_capability()
14438 {
14439 return;
14440 }
14441 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14442 if let Some(index) = pending.iter().position(|current| {
14443 matches!(
14444 ¤t.event,
14445 NodeEvent::TerminalFrame {
14446 address: current_address,
14447 ..
14448 } if current_address == address
14449 )
14450 }) {
14451 pending.remove(index);
14452 }
14453 }
14454 pending.push(event);
14455}
14456
14457fn project_event_without_harness_mcp_proxy(
14458 envelope: NodeEventEnvelope,
14459) -> Option<NodeEventEnvelope> {
14460 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14461}
14462
14463fn queue_connection_event_batch(
14464 pending: &mut Vec<NodeEventEnvelope>,
14465 events: &[NodeEventEnvelope],
14466 discard_through: u64,
14467) {
14468 for event in events {
14469 queue_connection_event(pending, event.clone(), discard_through);
14470 }
14471}
14472
14473fn apply_connection_resync_watermark(
14474 pending: &mut Vec<NodeEventEnvelope>,
14475 discard_through: &mut u64,
14476 marker_sequence: u64,
14477) {
14478 *discard_through = (*discard_through).max(marker_sequence);
14479 pending.retain(|event| event.sequence > *discard_through);
14480}
14481
14482fn connection_event_burst_len(pending_len: usize) -> usize {
14483 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14484}
14485
14486fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14487 let history = shared
14488 .history
14489 .lock()
14490 .unwrap_or_else(|poisoned| poisoned.into_inner());
14491 let sequence = history.last_sequence;
14492 let oldest_available_sequence = history.replay_floor_sequence;
14493 NodeEventEnvelope {
14494 sequence,
14495 event: NodeEvent::ResyncRequired {
14496 oldest_available_sequence,
14497 },
14498 }
14499}
14500
14501fn node_compatibility_support(
14502 shared: &NodeShared,
14503) -> Result<NodeCompatibilitySupport, NodeServerError> {
14504 let mut support = node_compatibility_support_for_manifest(
14505 &shared.provider_contracts,
14506 &shared.provider_adapter_contracts,
14507 )?;
14508 if shared
14509 .delivery_store
14510 .lock()
14511 .unwrap_or_else(|poisoned| poisoned.into_inner())
14512 .is_some()
14513 {
14514 support.capabilities.push(
14515 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14516 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14517 );
14518 }
14519 if shared.harness_mcp_registry.is_some() {
14520 support.capabilities.push(
14521 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14522 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14523 );
14524 }
14525 Ok(support)
14526}
14527
14528fn node_compatibility_support_for_manifest(
14529 provider_contracts: &[ProviderContractSupport],
14530 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14531) -> Result<NodeCompatibilitySupport, NodeServerError> {
14532 Ok(NodeCompatibilitySupport {
14533 build_stamp: BUILD_STAMP.to_owned(),
14534 capabilities: baseline_capabilities()?,
14535 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14536 path_semantics: platform::path_semantics(),
14537 local_transport: platform::local_transport(),
14538 state_schema: StateSchemaSupport {
14539 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14540 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14541 },
14542 provider_contracts: provider_contracts.to_vec(),
14543 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14544 })
14545}
14546
14547fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14548 let capabilities = [
14549 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14550 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14551 NODE_REPOSITORY_PATH_CAPABILITY,
14552 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14553 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14554 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14555 NODE_GIT_READ_CAPABILITY,
14556 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14557 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14558 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14559 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14560 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14561 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14562 NODE_ACP_CONTROL_CAPABILITY,
14563 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14564 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14565 NODE_WORKTREE_SELECTION_CAPABILITY,
14566 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14567 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14568 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14569 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14570 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14571 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14572 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14573 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14574 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14575 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14576 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14577 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14578 ]
14579 .into_iter()
14580 .map(|capability| {
14581 CapabilityId::new(capability)
14582 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14583 })
14584 .collect::<Result<Vec<_>, _>>()?;
14585 #[cfg(windows)]
14586 let capabilities = {
14587 let mut capabilities = capabilities;
14588 capabilities.push(
14589 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14590 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14591 );
14592 capabilities
14593 };
14594 Ok(capabilities)
14595}
14596
14597fn request_uses_unnegotiated_capability(
14598 request: &NodeRequest,
14599 selected_capabilities: &[CapabilityId],
14600) -> bool {
14601 let selected = |required: &str| {
14602 selected_capabilities
14603 .iter()
14604 .any(|capability| capability.as_str() == required)
14605 };
14606 request
14607 .required_capability()
14608 .is_some_and(|required| !selected(required))
14609 || (request.requires_worktree_selection_capability()
14610 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14611 || (request.requires_spawn_spec_defaults_overrides_capability()
14612 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14613 || (request.requires_spawn_profile_revision_capability()
14614 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14615 || (request.requires_child_environment_profile_capability()
14616 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14617 || (request.requires_session_bundle_materialization_capability()
14618 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14619 || (request.requires_history_context_pack_capability()
14620 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14621}
14622
14623fn snapshot_for_wire(
14624 shared: &NodeShared,
14625 include_provider_runtime_status: bool,
14626 include_open_provider_ids: bool,
14627 include_spawn_profiles: bool,
14628 include_managed_worktrees: bool,
14629 include_child_environment_profiles: bool,
14630 include_session_bundles: bool,
14631 include_history_context_packs: bool,
14632 include_agent_progress: bool,
14633 include_session_task_correlation: bool,
14634) -> NodeSnapshot {
14635 let mut snapshot = shared.snapshot();
14636 if !include_provider_runtime_status {
14637 snapshot.provider_runtime_statuses.clear();
14638 }
14639 if !include_open_provider_ids {
14640 project_snapshot_legacy_provider_ids(&mut snapshot);
14641 }
14642 if !include_managed_worktrees {
14643 snapshot.managed_worktrees.clear();
14644 for workspace in &mut snapshot.workspaces {
14645 workspace.managed_worktree_profiles = None;
14646 }
14647 }
14648 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14649 if !include_spawn_profiles {
14650 inventory.spawn_profiles = None;
14651 }
14652 if !include_session_bundles {
14653 inventory.bundles = None;
14654 }
14655 if inventory.spawn_profiles.is_none()
14656 && inventory.bundles.is_none()
14657 && inventory.network_allowlists.is_none()
14658 {
14659 snapshot.launch_inventory = None;
14660 }
14661 }
14662 if !include_child_environment_profiles {
14663 clear_snapshot_child_environment_profiles(&mut snapshot);
14664 }
14665 if !include_session_bundles {
14666 clear_snapshot_session_bundles(&mut snapshot);
14667 }
14668 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14669 if !include_history_context_packs {
14670 clear_snapshot_context_packs(&mut snapshot);
14671 }
14672 if !include_agent_progress {
14673 snapshot.agent_progress.clear();
14674 }
14675 if !include_session_task_correlation {
14676 clear_snapshot_session_task_bindings(&mut snapshot);
14677 }
14678 snapshot
14679}
14680
14681fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14682 for record in &mut snapshot.session_records {
14683 record.task_binding = None;
14684 }
14685}
14686
14687fn project_event_without_session_task_binding(
14688 mut envelope: NodeEventEnvelope,
14689) -> NodeEventEnvelope {
14690 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14691 record.task_binding = None;
14692 }
14693 envelope
14694}
14695
14696fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14697 let Ok(response) = reply.result.as_mut() else { return };
14698 match response {
14699 NodeResponse::Snapshot { snapshot, .. } => {
14700 clear_snapshot_session_task_bindings(snapshot);
14701 }
14702 NodeResponse::Resync { snapshot, events, .. } => {
14703 clear_snapshot_session_task_bindings(snapshot);
14704 for event in events {
14705 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14706 record.task_binding = None;
14707 }
14708 }
14709 }
14710 NodeResponse::SessionRecordUpdated { record }
14711 | NodeResponse::ProviderSessionIndexed { record }
14712 | NodeResponse::NativeSessionIndexed { record, .. }
14713 | NodeResponse::SessionRecordResumed { record, .. } => {
14714 record.task_binding = None;
14715 }
14716 _ => {}
14717 }
14718}
14719
14720fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14721 let Ok(response) = reply.result.as_mut() else { return };
14722 match response {
14723 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14724 snapshot.agent_progress.clear();
14725 }
14726 _ => {}
14727 }
14728}
14729
14730fn project_event_without_managed_worktrees(
14731 mut envelope: NodeEventEnvelope,
14732) -> Option<NodeEventEnvelope> {
14733 if matches!(
14734 envelope.event,
14735 NodeEvent::ManagedWorktreeUpserted { .. }
14736 | NodeEvent::ManagedWorktreeRemoved { .. }
14737 ) {
14738 return None;
14739 }
14740 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14741 workspace.managed_worktree_profiles = None;
14742 }
14743 Some(envelope)
14744}
14745
14746fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14747 if matches!(
14748 reply.result,
14749 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14750 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14751 ) {
14752 reply.result = Err(failure(
14753 NodeFailureCode::UnsupportedCapability,
14754 "managed worktree capability was not negotiated",
14755 ));
14756 return;
14757 }
14758 let Ok(response) = reply.result.as_mut() else { return };
14759 match response {
14760 NodeResponse::Snapshot { snapshot, .. } => {
14761 snapshot.managed_worktrees.clear();
14762 for workspace in &mut snapshot.workspaces {
14763 workspace.managed_worktree_profiles = None;
14764 }
14765 }
14766 NodeResponse::Resync { snapshot, events, .. } => {
14767 snapshot.managed_worktrees.clear();
14768 for workspace in &mut snapshot.workspaces {
14769 workspace.managed_worktree_profiles = None;
14770 }
14771 *events = std::mem::take(events)
14772 .into_iter()
14773 .filter_map(project_event_without_managed_worktrees)
14774 .collect();
14775 }
14776 NodeResponse::WorkspaceRegistered { workspace }
14777 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14778 | NodeResponse::WorktreeCreated { workspace, .. } => {
14779 workspace.managed_worktree_profiles = None;
14780 }
14781 NodeResponse::WorkspaceInspected { inspection } => {
14782 inspection.git.managed_worktree = None;
14783 }
14784 _ => {}
14785 }
14786}
14787
14788fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14789 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14790 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14791 });
14792 if includes_open_provider_ids {
14793 return;
14794 }
14795 compatibility
14796 .provider_contracts
14797 .retain(|contract| provider_id_is_legacy(&contract.provider));
14798 compatibility
14799 .provider_adapter_contracts
14800 .retain(|contract| provider_id_is_legacy(&contract.provider));
14801}
14802
14803fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14804 if let Some(spec) = match request {
14805 NodeRequest::SpawnSpec { spec }
14806 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14807 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14808 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14809 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14810 _ => None,
14811 } {
14812 return shared
14813 .resolve_spawn_spec(spec)
14814 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14815 }
14816 request_requires_open_provider_ids_with(
14817 request,
14818 |session| shared.validate_address(session).ok(),
14819 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14820 )
14821}
14822
14823fn request_requires_child_environment_profile(
14824 shared: &NodeShared,
14825 request: &NodeRequest,
14826) -> bool {
14827 if let Some(spec) = match request {
14828 NodeRequest::SpawnSpec { spec }
14829 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14830 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14831 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14832 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14833 _ => None,
14834 } {
14835 return shared
14836 .resolve_spawn_spec(spec)
14837 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14838 }
14839 match request {
14840 NodeRequest::Resume { session, .. }
14841 | NodeRequest::Prompt { session, .. }
14842 | NodeRequest::Paste { session, .. }
14843 | NodeRequest::Input { session, .. }
14844 | NodeRequest::TerminalBytes { session, .. }
14845 | NodeRequest::TerminalControl { session, .. }
14846 | NodeRequest::Resize { session, .. }
14847 | NodeRequest::Interrupt { session }
14848 | NodeRequest::Stop { session, .. }
14849 | NodeRequest::Remove { session }
14850 | NodeRequest::ResolveInteraction { session, .. }
14851 | NodeRequest::SetSessionMode { session, .. }
14852 | NodeRequest::SetSessionConfigOption { session, .. }
14853 | NodeRequest::SetSessionModel { session, .. }
14854 | NodeRequest::DiscoverHistory { session, .. }
14855 | NodeRequest::LoadHistory { session, .. }
14856 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14857 | NodeRequest::ExportContextPack { session } => shared
14858 .session_bindings
14859 .lock()
14860 .unwrap_or_else(|poisoned| poisoned.into_inner())
14861 .get(&session.session.instance_id)
14862 .filter(|binding| {
14863 binding.workspace_id == session.workspace_id
14864 && binding.generation == session.session.generation
14865 })
14866 .map_or(true, |binding| binding.environment_profile.is_some()),
14867 NodeRequest::RenameSessionRecord { record_id, .. }
14868 | NodeRequest::SetSessionTask { record_id, .. }
14869 | NodeRequest::ResumeSessionRecord { record_id, .. }
14870 | NodeRequest::ForgetSessionRecord { record_id } => shared
14871 .record(record_id)
14872 .map_or(true, |record| record.environment_profile.is_some()),
14873 NodeRequest::ArmHarnessMcpReservation { .. }
14874 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14875 | NodeRequest::ActivateHarnessMcpReservation { .. }
14876 | NodeRequest::AbortHarnessMcpReservation { .. }
14877 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14878 | NodeRequest::RejectHarnessMcpCall { .. }
14879 | NodeRequest::Snapshot
14880 | NodeRequest::Resync { .. }
14881 | NodeRequest::BeginDeliveryStage { .. }
14882 | NodeRequest::PutDeliveryBlobChunk { .. }
14883 | NodeRequest::CommitDeliveryStage { .. }
14884 | NodeRequest::AbortDeliveryStage { .. }
14885 | NodeRequest::BrowseHostDirectories { .. }
14886 | NodeRequest::InspectWorkspace { .. }
14887 | NodeRequest::ReadWorkspaceFile { .. }
14888 | NodeRequest::WriteWorkspaceFile { .. }
14889 | NodeRequest::CreateWorkspaceFile { .. }
14890 | NodeRequest::CreateWorkspaceDirectory { .. }
14891 | NodeRequest::ReadGitHistory { .. }
14892 | NodeRequest::ReadGitDiff { .. }
14893 | NodeRequest::AcquireController { .. }
14894 | NodeRequest::ReleaseController
14895 | NodeRequest::Spawn { .. }
14896 | NodeRequest::SpawnWithOptions { .. }
14897 | NodeRequest::SpawnSpec { .. }
14898 | NodeRequest::RegisterWorkspace { .. }
14899 | NodeRequest::CreateStandaloneWorkspace { .. }
14900 | NodeRequest::UnregisterWorkspace { .. }
14901 | NodeRequest::CreateWorktree { .. }
14902 | NodeRequest::RemoveWorktree { .. }
14903 | NodeRequest::SpawnManagedWorktree { .. }
14904 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14905 | NodeRequest::CleanupManagedWorktree { .. }
14906 | NodeRequest::IndexProviderSession { .. }
14907 | NodeRequest::IndexNativeSession { .. }
14908 | NodeRequest::CatalogNativeSessions { .. }
14909 | NodeRequest::PageNativeSessions { .. }
14910 | NodeRequest::PreviewNativeSession { .. }
14911 | NodeRequest::PreviewSessionRecord { .. }
14912 | NodeRequest::ForgetContextPack { .. }
14913 | NodeRequest::ResolveDurableContextPack { .. }
14914 | NodeRequest::ReadContextPack { .. }
14915 | NodeRequest::Shutdown => false,
14916 }
14917}
14918
14919fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14920 if let Some(spec) = match request {
14921 NodeRequest::SpawnSpec { spec }
14922 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14923 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14924 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14925 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14926 _ => None,
14927 } {
14928 return shared
14929 .resolve_spawn_spec(spec)
14930 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14931 }
14932 match request {
14933 NodeRequest::Resume { session, .. }
14934 | NodeRequest::Prompt { session, .. }
14935 | NodeRequest::Paste { session, .. }
14936 | NodeRequest::Input { session, .. }
14937 | NodeRequest::TerminalBytes { session, .. }
14938 | NodeRequest::TerminalControl { session, .. }
14939 | NodeRequest::Resize { session, .. }
14940 | NodeRequest::Interrupt { session }
14941 | NodeRequest::Stop { session, .. }
14942 | NodeRequest::Remove { session }
14943 | NodeRequest::ResolveInteraction { session, .. }
14944 | NodeRequest::SetSessionMode { session, .. }
14945 | NodeRequest::SetSessionConfigOption { session, .. }
14946 | NodeRequest::SetSessionModel { session, .. }
14947 | NodeRequest::DiscoverHistory { session, .. }
14948 | NodeRequest::LoadHistory { session, .. }
14949 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14950 | NodeRequest::ExportContextPack { session } => shared
14951 .session_bindings
14952 .lock()
14953 .unwrap_or_else(|poisoned| poisoned.into_inner())
14954 .get(&session.session.instance_id)
14955 .filter(|binding| {
14956 binding.workspace_id == session.workspace_id
14957 && binding.generation == session.session.generation
14958 })
14959 .map_or(true, |binding| binding.bundle.is_some()),
14960 NodeRequest::RenameSessionRecord { record_id, .. }
14961 | NodeRequest::SetSessionTask { record_id, .. }
14962 | NodeRequest::ResumeSessionRecord { record_id, .. }
14963 | NodeRequest::ForgetSessionRecord { record_id } => shared
14964 .record(record_id)
14965 .map_or(true, |record| record.bundle.is_some()),
14966 NodeRequest::ArmHarnessMcpReservation { .. }
14967 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14968 | NodeRequest::ActivateHarnessMcpReservation { .. }
14969 | NodeRequest::AbortHarnessMcpReservation { .. }
14970 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14971 | NodeRequest::RejectHarnessMcpCall { .. }
14972 | NodeRequest::Snapshot
14973 | NodeRequest::Resync { .. }
14974 | NodeRequest::BeginDeliveryStage { .. }
14975 | NodeRequest::PutDeliveryBlobChunk { .. }
14976 | NodeRequest::CommitDeliveryStage { .. }
14977 | NodeRequest::AbortDeliveryStage { .. }
14978 | NodeRequest::BrowseHostDirectories { .. }
14979 | NodeRequest::InspectWorkspace { .. }
14980 | NodeRequest::ReadWorkspaceFile { .. }
14981 | NodeRequest::WriteWorkspaceFile { .. }
14982 | NodeRequest::CreateWorkspaceFile { .. }
14983 | NodeRequest::CreateWorkspaceDirectory { .. }
14984 | NodeRequest::ReadGitHistory { .. }
14985 | NodeRequest::ReadGitDiff { .. }
14986 | NodeRequest::AcquireController { .. }
14987 | NodeRequest::ReleaseController
14988 | NodeRequest::Spawn { .. }
14989 | NodeRequest::SpawnWithOptions { .. }
14990 | NodeRequest::SpawnSpec { .. }
14991 | NodeRequest::RegisterWorkspace { .. }
14992 | NodeRequest::CreateStandaloneWorkspace { .. }
14993 | NodeRequest::UnregisterWorkspace { .. }
14994 | NodeRequest::CreateWorktree { .. }
14995 | NodeRequest::RemoveWorktree { .. }
14996 | NodeRequest::SpawnManagedWorktree { .. }
14997 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14998 | NodeRequest::CleanupManagedWorktree { .. }
14999 | NodeRequest::IndexProviderSession { .. }
15000 | NodeRequest::IndexNativeSession { .. }
15001 | NodeRequest::CatalogNativeSessions { .. }
15002 | NodeRequest::PageNativeSessions { .. }
15003 | NodeRequest::PreviewNativeSession { .. }
15004 | NodeRequest::PreviewSessionRecord { .. }
15005 | NodeRequest::ForgetContextPack { .. }
15006 | NodeRequest::ResolveDurableContextPack { .. }
15007 | NodeRequest::ReadContextPack { .. }
15008 | NodeRequest::Shutdown => false,
15009 }
15010}
15011
15012fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15013 if request.requires_history_context_pack_capability() {
15014 return true;
15015 }
15016 if let Some(spec) = match request {
15017 NodeRequest::SpawnSpec { spec }
15018 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15019 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15020 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15021 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15022 _ => None,
15023 } {
15024 return shared
15025 .resolve_spawn_spec(spec)
15026 .map_or(true, |resolved| resolved.context_id.is_some());
15027 }
15028 match request {
15029 NodeRequest::Resume { session, .. }
15030 | NodeRequest::Prompt { session, .. }
15031 | NodeRequest::Paste { session, .. }
15032 | NodeRequest::Input { session, .. }
15033 | NodeRequest::TerminalBytes { session, .. }
15034 | NodeRequest::TerminalControl { session, .. }
15035 | NodeRequest::Resize { session, .. }
15036 | NodeRequest::Interrupt { session }
15037 | NodeRequest::Stop { session, .. }
15038 | NodeRequest::Remove { session }
15039 | NodeRequest::ResolveInteraction { session, .. }
15040 | NodeRequest::SetSessionMode { session, .. }
15041 | NodeRequest::SetSessionConfigOption { session, .. }
15042 | NodeRequest::SetSessionModel { session, .. } => shared
15043 .session_bindings
15044 .lock()
15045 .unwrap_or_else(|poisoned| poisoned.into_inner())
15046 .get(&session.session.instance_id)
15047 .filter(|binding| {
15048 binding.workspace_id == session.workspace_id
15049 && binding.generation == session.session.generation
15050 })
15051 .map_or(true, |binding| binding.context.is_some()),
15052 NodeRequest::RenameSessionRecord { record_id, .. }
15053 | NodeRequest::SetSessionTask { record_id, .. }
15054 | NodeRequest::ResumeSessionRecord { record_id, .. }
15055 | NodeRequest::ForgetSessionRecord { record_id } => shared
15056 .record(record_id)
15057 .map_or(true, |record| record.context.is_some()),
15058 NodeRequest::ArmHarnessMcpReservation { .. }
15059 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15060 | NodeRequest::ActivateHarnessMcpReservation { .. }
15061 | NodeRequest::AbortHarnessMcpReservation { .. }
15062 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15063 | NodeRequest::RejectHarnessMcpCall { .. }
15064 | NodeRequest::Snapshot
15065 | NodeRequest::Resync { .. }
15066 | NodeRequest::BeginDeliveryStage { .. }
15067 | NodeRequest::PutDeliveryBlobChunk { .. }
15068 | NodeRequest::CommitDeliveryStage { .. }
15069 | NodeRequest::AbortDeliveryStage { .. }
15070 | NodeRequest::BrowseHostDirectories { .. }
15071 | NodeRequest::InspectWorkspace { .. }
15072 | NodeRequest::ReadWorkspaceFile { .. }
15073 | NodeRequest::WriteWorkspaceFile { .. }
15074 | NodeRequest::CreateWorkspaceFile { .. }
15075 | NodeRequest::CreateWorkspaceDirectory { .. }
15076 | NodeRequest::ReadGitHistory { .. }
15077 | NodeRequest::ReadGitDiff { .. }
15078 | NodeRequest::AcquireController { .. }
15079 | NodeRequest::ReleaseController
15080 | NodeRequest::Spawn { .. }
15081 | NodeRequest::SpawnWithOptions { .. }
15082 | NodeRequest::SpawnSpec { .. }
15083 | NodeRequest::RegisterWorkspace { .. }
15084 | NodeRequest::CreateStandaloneWorkspace { .. }
15085 | NodeRequest::UnregisterWorkspace { .. }
15086 | NodeRequest::CreateWorktree { .. }
15087 | NodeRequest::RemoveWorktree { .. }
15088 | NodeRequest::SpawnManagedWorktree { .. }
15089 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15090 | NodeRequest::CleanupManagedWorktree { .. }
15091 | NodeRequest::IndexProviderSession { .. }
15092 | NodeRequest::IndexNativeSession { .. }
15093 | NodeRequest::CatalogNativeSessions { .. }
15094 | NodeRequest::PageNativeSessions { .. }
15095 | NodeRequest::PreviewNativeSession { .. }
15096 | NodeRequest::PreviewSessionRecord { .. }
15097 | NodeRequest::DiscoverHistory { .. }
15098 | NodeRequest::LoadHistory { .. }
15099 | NodeRequest::ExportContextPackForSessionRecord { .. }
15100 | NodeRequest::ExportContextPack { .. }
15101 | NodeRequest::ForgetContextPack { .. }
15102 | NodeRequest::ResolveDurableContextPack { .. }
15103 | NodeRequest::ReadContextPack { .. }
15104 | NodeRequest::Shutdown => false,
15105 }
15106}
15107
15108fn request_requires_open_provider_ids_with(
15109 request: &NodeRequest,
15110 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15111 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15112) -> bool {
15113 match request {
15114 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15115 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15116 NodeRequest::CatalogNativeSessions { route, .. }
15117 | NodeRequest::PageNativeSessions { route, .. } => {
15118 !provider_id_is_legacy(&route.provider)
15119 }
15120 NodeRequest::PreviewNativeSession { selection, .. }
15121 | NodeRequest::IndexNativeSession { selection, .. } => {
15122 !provider_id_is_legacy(&selection.route.provider)
15123 }
15124 NodeRequest::SpawnSpec { .. }
15125 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15126 NodeRequest::SpawnManagedWorktree { .. }
15127 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15128 NodeRequest::Resume { session, .. }
15129 | NodeRequest::Prompt { session, .. }
15130 | NodeRequest::Paste { session, .. }
15131 | NodeRequest::Input { session, .. }
15132 | NodeRequest::TerminalBytes { session, .. }
15133 | NodeRequest::TerminalControl { session, .. }
15134 | NodeRequest::Resize { session, .. }
15135 | NodeRequest::Interrupt { session }
15136 | NodeRequest::Stop { session, .. }
15137 | NodeRequest::Remove { session }
15138 | NodeRequest::ResolveInteraction { session, .. }
15139 | NodeRequest::SetSessionMode { session, .. }
15140 | NodeRequest::SetSessionConfigOption { session, .. }
15141 | NodeRequest::SetSessionModel { session, .. }
15142 | NodeRequest::DiscoverHistory { session, .. }
15143 | NodeRequest::LoadHistory { session, .. }
15144 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15145 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15146 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15147 NodeRequest::RenameSessionRecord { record_id, .. }
15148 | NodeRequest::SetSessionTask { record_id, .. }
15149 | NodeRequest::ResumeSessionRecord { record_id, .. }
15150 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15151 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15152 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15153 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15154 NodeRequest::ArmHarnessMcpReservation { .. }
15155 | NodeRequest::ActivateHarnessMcpReservation { .. }
15156 | NodeRequest::AbortHarnessMcpReservation { .. }
15157 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15158 | NodeRequest::RejectHarnessMcpCall { .. }
15159 | NodeRequest::Snapshot
15160 | NodeRequest::Resync { .. }
15161 | NodeRequest::BeginDeliveryStage { .. }
15162 | NodeRequest::PutDeliveryBlobChunk { .. }
15163 | NodeRequest::CommitDeliveryStage { .. }
15164 | NodeRequest::AbortDeliveryStage { .. }
15165 | NodeRequest::BrowseHostDirectories { .. }
15166 | NodeRequest::InspectWorkspace { .. }
15167 | NodeRequest::ReadWorkspaceFile { .. }
15168 | NodeRequest::WriteWorkspaceFile { .. }
15169 | NodeRequest::CreateWorkspaceFile { .. }
15170 | NodeRequest::CreateWorkspaceDirectory { .. }
15171 | NodeRequest::ReadGitHistory { .. }
15172 | NodeRequest::ReadGitDiff { .. }
15173 | NodeRequest::AcquireController { .. }
15174 | NodeRequest::ReleaseController
15175 | NodeRequest::RegisterWorkspace { .. }
15176 | NodeRequest::CreateStandaloneWorkspace { .. }
15177 | NodeRequest::UnregisterWorkspace { .. }
15178 | NodeRequest::CreateWorktree { .. }
15179 | NodeRequest::RemoveWorktree { .. }
15180 | NodeRequest::CleanupManagedWorktree { .. }
15181 | NodeRequest::Shutdown => false,
15182 NodeRequest::ForgetContextPack { .. } => true,
15183 NodeRequest::ResolveDurableContextPack { .. }
15189 | NodeRequest::ReadContextPack { .. } => true,
15190 }
15191}
15192
15193fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15194 snapshot.enabled_providers.retain(provider_id_is_legacy);
15195 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15196 snapshot
15197 .provider_runtime_statuses
15198 .as_slice()
15199 .iter()
15200 .filter(|status| provider_id_is_legacy(status.provider()))
15201 .cloned(),
15202 )
15203 .expect("a filtered provider runtime projection remains valid");
15204 snapshot
15205 .session_records
15206 .retain(|record| provider_id_is_legacy(&record.provider));
15207 for workspace in &mut snapshot.workspaces {
15208 workspace
15209 .sessions
15210 .retain(|session| provider_id_is_legacy(&session.agent_id));
15211 }
15212}
15213
15214fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15215 for record in &mut snapshot.session_records {
15216 record.environment_profile = None;
15217 }
15218 if let Some(profiles) = snapshot
15219 .launch_inventory
15220 .as_mut()
15221 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15222 {
15223 for profile in profiles {
15224 profile.environment_profile = None;
15225 }
15226 }
15227}
15228
15229fn project_event_without_child_environment_profile(
15230 mut envelope: NodeEventEnvelope,
15231) -> NodeEventEnvelope {
15232 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15233 record.environment_profile = None;
15234 }
15235 envelope
15236}
15237
15238fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15239 let contains_environment_profile = match reply.result.as_ref() {
15240 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15241 receipt.environment_profile.is_some()
15242 }
15243 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15244 receipt.spawn.environment_profile.is_some()
15245 }
15246 _ => false,
15247 };
15248 if contains_environment_profile {
15249 reply.result = Err(failure(
15250 NodeFailureCode::UnsupportedCapability,
15251 "child environment profiles were not negotiated",
15252 ));
15253 return;
15254 }
15255 let Ok(response) = reply.result.as_mut() else {
15256 return;
15257 };
15258 match response {
15259 NodeResponse::Snapshot { snapshot, .. } => {
15260 clear_snapshot_child_environment_profiles(snapshot);
15261 }
15262 NodeResponse::Resync { snapshot, events, .. } => {
15263 clear_snapshot_child_environment_profiles(snapshot);
15264 for event in events {
15265 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15266 record.environment_profile = None;
15267 }
15268 }
15269 }
15270 NodeResponse::SessionRecordUpdated { record }
15271 | NodeResponse::ProviderSessionIndexed { record }
15272 | NodeResponse::NativeSessionIndexed { record, .. }
15273 | NodeResponse::SessionRecordResumed { record, .. } => {
15274 record.environment_profile = None;
15275 }
15276 NodeResponse::Armed { .. }
15277 | NodeResponse::Spawned { .. }
15278 | NodeResponse::Activated { .. }
15279 | NodeResponse::Aborted { .. }
15280 | NodeResponse::ReplyChunkAccepted { .. }
15281 | NodeResponse::CallRejected { .. }
15282 | NodeResponse::DeliveryStageBegun { .. }
15283 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15284 | NodeResponse::DeliveryCommitted { .. }
15285 | NodeResponse::DeliveryStageAborted { .. }
15286 | NodeResponse::WorkspaceInspected { .. }
15287 | NodeResponse::HostDirectoriesBrowsed { .. }
15288 | NodeResponse::WorkspaceFileRead { .. }
15289 | NodeResponse::WorkspaceFileWritten { .. }
15290 | NodeResponse::WorkspaceFileCreated { .. }
15291 | NodeResponse::WorkspaceDirectoryCreated { .. }
15292 | NodeResponse::GitHistoryRead { .. }
15293 | NodeResponse::GitDiffRead { .. }
15294 | NodeResponse::Controller { .. }
15295 | NodeResponse::SpawnAccepted { .. }
15296 | NodeResponse::SpawnSpecAccepted { .. }
15297 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15298 | NodeResponse::ManagedWorktreeCleanup { .. }
15299 | NodeResponse::NativeSessionsCataloged { .. }
15300 | NodeResponse::NativeSessionsPaged { .. }
15301 | NodeResponse::NativeSessionPreviewed { .. }
15302 | NodeResponse::SessionRecordPreviewed { .. }
15303 | NodeResponse::HistoryDiscovered { .. }
15304 | NodeResponse::HistoryLoaded { .. }
15305 | NodeResponse::ContextPackForSessionRecordExported { .. }
15306 | NodeResponse::ContextPackExported { .. }
15307 | NodeResponse::ContextPackForgotten { .. }
15308 | NodeResponse::DurableContextPackResolved { .. }
15309 | NodeResponse::ContextPackBytesRead { .. }
15310 | NodeResponse::SessionRecordForgotten { .. }
15311 | NodeResponse::WorkspaceRegistered { .. }
15312 | NodeResponse::StandaloneWorkspaceCreated { .. }
15313 | NodeResponse::WorkspaceUnregistered { .. }
15314 | NodeResponse::WorktreeCreated { .. }
15315 | NodeResponse::WorktreeRemoved { .. }
15316 | NodeResponse::Accepted
15317 | NodeResponse::ShuttingDown => {}
15318 }
15319}
15320
15321fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15322 for record in &mut snapshot.session_records {
15323 record.bundle = None;
15324 }
15325}
15326
15327fn project_event_without_session_bundle(
15328 mut envelope: NodeEventEnvelope,
15329) -> NodeEventEnvelope {
15330 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15331 record.bundle = None;
15332 }
15333 envelope
15334}
15335
15336fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15337 let contains_bundle = match reply.result.as_ref() {
15338 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15339 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15340 receipt.spawn.bundle.is_some()
15341 }
15342 _ => false,
15343 };
15344 if contains_bundle {
15345 reply.result = Err(failure(
15346 NodeFailureCode::UnsupportedCapability,
15347 "session bundles were not negotiated",
15348 ));
15349 return;
15350 }
15351 let Ok(response) = reply.result.as_mut() else {
15352 return;
15353 };
15354 match response {
15355 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15356 NodeResponse::Resync { snapshot, events, .. } => {
15357 clear_snapshot_session_bundles(snapshot);
15358 for event in events {
15359 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15360 record.bundle = None;
15361 }
15362 }
15363 }
15364 NodeResponse::SessionRecordUpdated { record }
15365 | NodeResponse::ProviderSessionIndexed { record }
15366 | NodeResponse::NativeSessionIndexed { record, .. }
15367 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15368 NodeResponse::Armed { .. }
15369 | NodeResponse::Spawned { .. }
15370 | NodeResponse::Activated { .. }
15371 | NodeResponse::Aborted { .. }
15372 | NodeResponse::ReplyChunkAccepted { .. }
15373 | NodeResponse::CallRejected { .. }
15374 | NodeResponse::DeliveryStageBegun { .. }
15375 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15376 | NodeResponse::DeliveryCommitted { .. }
15377 | NodeResponse::DeliveryStageAborted { .. }
15378 | NodeResponse::WorkspaceInspected { .. }
15379 | NodeResponse::HostDirectoriesBrowsed { .. }
15380 | NodeResponse::WorkspaceFileRead { .. }
15381 | NodeResponse::WorkspaceFileWritten { .. }
15382 | NodeResponse::WorkspaceFileCreated { .. }
15383 | NodeResponse::WorkspaceDirectoryCreated { .. }
15384 | NodeResponse::GitHistoryRead { .. }
15385 | NodeResponse::GitDiffRead { .. }
15386 | NodeResponse::Controller { .. }
15387 | NodeResponse::SpawnAccepted { .. }
15388 | NodeResponse::SpawnSpecAccepted { .. }
15389 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15390 | NodeResponse::ManagedWorktreeCleanup { .. }
15391 | NodeResponse::NativeSessionsCataloged { .. }
15392 | NodeResponse::NativeSessionsPaged { .. }
15393 | NodeResponse::NativeSessionPreviewed { .. }
15394 | NodeResponse::SessionRecordPreviewed { .. }
15395 | NodeResponse::HistoryDiscovered { .. }
15396 | NodeResponse::HistoryLoaded { .. }
15397 | NodeResponse::ContextPackForSessionRecordExported { .. }
15398 | NodeResponse::ContextPackExported { .. }
15399 | NodeResponse::ContextPackForgotten { .. }
15400 | NodeResponse::DurableContextPackResolved { .. }
15401 | NodeResponse::ContextPackBytesRead { .. }
15402 | NodeResponse::SessionRecordForgotten { .. }
15403 | NodeResponse::WorkspaceRegistered { .. }
15404 | NodeResponse::StandaloneWorkspaceCreated { .. }
15405 | NodeResponse::WorkspaceUnregistered { .. }
15406 | NodeResponse::WorktreeCreated { .. }
15407 | NodeResponse::WorktreeRemoved { .. }
15408 | NodeResponse::Accepted
15409 | NodeResponse::ShuttingDown => {}
15410 }
15411}
15412
15413fn project_snapshot_history_for_wire(
15414 snapshot: &mut NodeSnapshot,
15415 include_history_metadata: bool,
15416) {
15417 for workspace in &mut snapshot.workspaces {
15418 for session in &mut workspace.sessions {
15419 if include_history_metadata {
15420 session.history.loaded = None;
15421 } else {
15422 session.history = Default::default();
15423 }
15424 }
15425 }
15426}
15427
15428fn project_event_history_for_wire(
15429 mut envelope: NodeEventEnvelope,
15430 include_history_metadata: bool,
15431) -> NodeEventEnvelope {
15432 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15433 for session in &mut workspace.sessions {
15434 if include_history_metadata {
15435 session.history.loaded = None;
15436 } else {
15437 session.history = Default::default();
15438 }
15439 }
15440 }
15441 envelope
15442}
15443
15444fn project_response_history_for_wire(
15445 reply: &mut ResponseEnvelope,
15446 include_history_metadata: bool,
15447) {
15448 let Ok(response) = reply.result.as_mut() else {
15449 return;
15450 };
15451 match response {
15452 NodeResponse::Snapshot { snapshot, .. } => {
15453 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15454 }
15455 NodeResponse::Resync {
15456 snapshot, events, ..
15457 } => {
15458 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15459 for event in events {
15460 *event = project_event_history_for_wire(
15461 event.clone(),
15462 include_history_metadata,
15463 );
15464 }
15465 }
15466 NodeResponse::WorkspaceRegistered { workspace }
15467 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15468 | NodeResponse::WorktreeCreated { workspace, .. } => {
15469 for session in &mut workspace.sessions {
15470 if include_history_metadata {
15471 session.history.loaded = None;
15472 } else {
15473 session.history = Default::default();
15474 }
15475 }
15476 }
15477 _ => {}
15478 }
15479}
15480
15481fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15482 for record in &mut snapshot.session_records {
15483 record.context_id = None;
15484 record.context = None;
15485 record.exported_context = None;
15486 }
15487}
15488
15489fn project_event_without_context_pack(
15490 mut envelope: NodeEventEnvelope,
15491) -> NodeEventEnvelope {
15492 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15493 record.context_id = None;
15494 record.context = None;
15495 record.exported_context = None;
15496 }
15497 envelope
15498}
15499
15500fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15501 let contains_context = match reply.result.as_ref() {
15502 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15503 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15504 receipt.spawn.context.is_some()
15505 }
15506 Ok(NodeResponse::HistoryDiscovered { .. })
15507 | Ok(NodeResponse::HistoryLoaded { .. })
15508 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15509 | Ok(NodeResponse::ContextPackExported { .. })
15510 | Ok(NodeResponse::ContextPackForgotten { .. })
15511 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15512 _ => false,
15513 };
15514 if contains_context {
15515 reply.result = Err(failure(
15516 NodeFailureCode::UnsupportedCapability,
15517 "history context packs were not negotiated",
15518 ));
15519 return;
15520 }
15521 let Ok(response) = reply.result.as_mut() else {
15522 return;
15523 };
15524 match response {
15525 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15526 NodeResponse::Resync {
15527 snapshot, events, ..
15528 } => {
15529 clear_snapshot_context_packs(snapshot);
15530 for event in events {
15531 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15532 record.context_id = None;
15533 record.context = None;
15534 record.exported_context = None;
15535 }
15536 }
15537 }
15538 NodeResponse::SessionRecordUpdated { record }
15539 | NodeResponse::ProviderSessionIndexed { record }
15540 | NodeResponse::NativeSessionIndexed { record, .. }
15541 | NodeResponse::SessionRecordResumed { record, .. } => {
15542 record.context_id = None;
15543 record.context = None;
15544 record.exported_context = None;
15545 }
15546 _ => {}
15547 }
15548}
15549
15550fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15551 let contains_open_record = match reply.result.as_ref() {
15552 Ok(NodeResponse::SessionRecordUpdated { record })
15553 | Ok(NodeResponse::ProviderSessionIndexed { record })
15554 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15555 !provider_id_is_legacy(&record.provider)
15556 }
15557 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15558 !provider_id_is_legacy(&selection.route.provider)
15559 || !provider_id_is_legacy(&record.provider)
15560 }
15561 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15562 !provider_id_is_legacy(&receipt.provider)
15563 }
15564 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15565 !provider_id_is_legacy(&receipt.spawn.provider)
15566 }
15567 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15568 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15569 !provider_id_is_legacy(&route.provider)
15570 }
15571 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15572 !provider_id_is_legacy(&selection.route.provider)
15573 }
15574 _ => false,
15575 };
15576 if contains_open_record {
15577 reply.result = Err(failure(
15578 NodeFailureCode::UnsupportedCapability,
15579 "open provider IDs were not negotiated",
15580 ));
15581 return;
15582 }
15583 let Ok(response) = reply.result.as_mut() else {
15584 return;
15585 };
15586 match response {
15587 NodeResponse::Snapshot { snapshot, .. } => {
15588 project_snapshot_legacy_provider_ids(snapshot);
15589 }
15590 NodeResponse::Resync { snapshot, events, .. } => {
15591 project_snapshot_legacy_provider_ids(snapshot);
15592 *events = std::mem::take(events)
15593 .into_iter()
15594 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15595 .collect();
15596 }
15597 NodeResponse::WorkspaceRegistered { workspace }
15598 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15599 | NodeResponse::WorktreeCreated { workspace, .. } => {
15600 workspace
15601 .sessions
15602 .retain(|session| provider_id_is_legacy(&session.agent_id));
15603 }
15604 NodeResponse::Armed { .. }
15605 | NodeResponse::Spawned { .. }
15606 | NodeResponse::Activated { .. }
15607 | NodeResponse::Aborted { .. }
15608 | NodeResponse::ReplyChunkAccepted { .. }
15609 | NodeResponse::CallRejected { .. }
15610 | NodeResponse::DeliveryStageBegun { .. }
15611 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15612 | NodeResponse::DeliveryCommitted { .. }
15613 | NodeResponse::DeliveryStageAborted { .. }
15614 | NodeResponse::WorkspaceInspected { .. }
15615 | NodeResponse::HostDirectoriesBrowsed { .. }
15616 | NodeResponse::WorkspaceFileRead { .. }
15617 | NodeResponse::WorkspaceFileWritten { .. }
15618 | NodeResponse::WorkspaceFileCreated { .. }
15619 | NodeResponse::WorkspaceDirectoryCreated { .. }
15620 | NodeResponse::GitHistoryRead { .. }
15621 | NodeResponse::GitDiffRead { .. }
15622 | NodeResponse::Controller { .. }
15623 | NodeResponse::SpawnAccepted { .. }
15624 | NodeResponse::SpawnSpecAccepted { .. }
15625 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15626 | NodeResponse::ManagedWorktreeCleanup { .. }
15627 | NodeResponse::NativeSessionsCataloged { .. }
15628 | NodeResponse::NativeSessionsPaged { .. }
15629 | NodeResponse::NativeSessionPreviewed { .. }
15630 | NodeResponse::SessionRecordPreviewed { .. }
15631 | NodeResponse::HistoryDiscovered { .. }
15632 | NodeResponse::HistoryLoaded { .. }
15633 | NodeResponse::ContextPackForSessionRecordExported { .. }
15634 | NodeResponse::ContextPackExported { .. }
15635 | NodeResponse::ContextPackForgotten { .. }
15636 | NodeResponse::DurableContextPackResolved { .. }
15637 | NodeResponse::ContextPackBytesRead { .. }
15638 | NodeResponse::SessionRecordUpdated { .. }
15639 | NodeResponse::ProviderSessionIndexed { .. }
15640 | NodeResponse::NativeSessionIndexed { .. }
15641 | NodeResponse::SessionRecordResumed { .. }
15642 | NodeResponse::SessionRecordForgotten { .. }
15643 | NodeResponse::WorkspaceUnregistered { .. }
15644 | NodeResponse::WorktreeRemoved { .. }
15645 | NodeResponse::Accepted
15646 | NodeResponse::ShuttingDown => {}
15647 }
15648}
15649
15650fn project_event_legacy_provider_ids(
15651 shared: &NodeShared,
15652 mut envelope: NodeEventEnvelope,
15653) -> Option<NodeEventEnvelope> {
15654 let include = match &mut envelope.event {
15655 NodeEvent::HarnessMcpReadCall { .. } => true,
15656 NodeEvent::Control { address, .. }
15657 | NodeEvent::AgentStream { address, .. }
15658 | NodeEvent::TerminalFrame { address, .. } => shared
15659 .validate_address(address)
15660 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15661 NodeEvent::WorkspaceAdded { workspace } => {
15662 workspace
15663 .sessions
15664 .retain(|session| provider_id_is_legacy(&session.agent_id));
15665 true
15666 }
15667 NodeEvent::SessionRecordUpserted { record } => {
15668 provider_id_is_legacy(&record.provider)
15669 }
15670 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15671 NodeEvent::SessionRecordRemoved { .. } => shared
15672 .history
15673 .lock()
15674 .unwrap_or_else(|poisoned| poisoned.into_inner())
15675 .removed_record_providers
15676 .get(&envelope.sequence)
15677 .is_some_and(provider_id_is_legacy),
15678 NodeEvent::ControllerChanged { .. }
15679 | NodeEvent::ManagedWorktreeUpserted { .. }
15680 | NodeEvent::ManagedWorktreeRemoved { .. }
15681 | NodeEvent::WorkspaceRemoved { .. }
15682 | NodeEvent::ResyncRequired { .. } => true,
15683 };
15684 include.then_some(envelope)
15685}
15686
15687fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15688 let Ok(response) = reply.result.as_mut() else {
15689 return;
15690 };
15691 match response {
15692 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15693 snapshot.provider_runtime_statuses.clear();
15694 }
15695 NodeResponse::Armed { .. }
15696 | NodeResponse::Spawned { .. }
15697 | NodeResponse::Activated { .. }
15698 | NodeResponse::Aborted { .. }
15699 | NodeResponse::ReplyChunkAccepted { .. }
15700 | NodeResponse::CallRejected { .. }
15701 | NodeResponse::DeliveryStageBegun { .. }
15702 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15703 | NodeResponse::DeliveryCommitted { .. }
15704 | NodeResponse::DeliveryStageAborted { .. }
15705 | NodeResponse::WorkspaceInspected { .. }
15706 | NodeResponse::HostDirectoriesBrowsed { .. }
15707 | NodeResponse::WorkspaceFileRead { .. }
15708 | NodeResponse::WorkspaceFileWritten { .. }
15709 | NodeResponse::WorkspaceFileCreated { .. }
15710 | NodeResponse::WorkspaceDirectoryCreated { .. }
15711 | NodeResponse::GitHistoryRead { .. }
15712 | NodeResponse::GitDiffRead { .. }
15713 | NodeResponse::Controller { .. }
15714 | NodeResponse::SpawnAccepted { .. }
15715 | NodeResponse::SpawnSpecAccepted { .. }
15716 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15717 | NodeResponse::ManagedWorktreeCleanup { .. }
15718 | NodeResponse::NativeSessionsCataloged { .. }
15719 | NodeResponse::NativeSessionsPaged { .. }
15720 | NodeResponse::NativeSessionPreviewed { .. }
15721 | NodeResponse::SessionRecordPreviewed { .. }
15722 | NodeResponse::HistoryDiscovered { .. }
15723 | NodeResponse::HistoryLoaded { .. }
15724 | NodeResponse::ContextPackForSessionRecordExported { .. }
15725 | NodeResponse::ContextPackExported { .. }
15726 | NodeResponse::ContextPackForgotten { .. }
15727 | NodeResponse::DurableContextPackResolved { .. }
15728 | NodeResponse::ContextPackBytesRead { .. }
15729 | NodeResponse::SessionRecordUpdated { .. }
15730 | NodeResponse::ProviderSessionIndexed { .. }
15731 | NodeResponse::NativeSessionIndexed { .. }
15732 | NodeResponse::SessionRecordResumed { .. }
15733 | NodeResponse::SessionRecordForgotten { .. }
15734 | NodeResponse::WorkspaceRegistered { .. }
15735 | NodeResponse::StandaloneWorkspaceCreated { .. }
15736 | NodeResponse::WorkspaceUnregistered { .. }
15737 | NodeResponse::WorktreeCreated { .. }
15738 | NodeResponse::WorktreeRemoved { .. }
15739 | NodeResponse::Accepted
15740 | NodeResponse::ShuttingDown => {}
15741 }
15742}
15743
15744struct ControllerReleaseGuard {
15745 shared: Arc<NodeShared>,
15746 connection_id: u64,
15747}
15748
15749impl Drop for ControllerReleaseGuard {
15750 fn drop(&mut self) {
15751 self.shared.release_controller(self.connection_id);
15752 }
15753}
15754
15755struct AbortTaskOnDrop(AbortHandle);
15756
15757impl Drop for AbortTaskOnDrop {
15758 fn drop(&mut self) {
15759 self.0.abort();
15760 }
15761}
15762
15763fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15768 std::env::var(name)
15769 .ok()
15770 .and_then(|value| value.parse::<u64>().ok())
15771 .filter(|value| *value > 0)
15772 .unwrap_or(default)
15773 .min(max)
15774}
15775
15776fn workspace_inspection_time_budget_ms() -> u64 {
15777 env_config_bounded(
15778 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15779 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15780 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15781 )
15782}
15783
15784fn workspace_inspection_entry_cap() -> usize {
15785 env_config_bounded(
15786 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15787 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15788 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15789 ) as usize
15790}
15791
15792struct WorkspaceWalkBudget {
15798 deadline: Instant,
15799 entry_cap: usize,
15800 entries_visited: u64,
15801 time_budget_exceeded: bool,
15802 entry_cap_exceeded: bool,
15803}
15804
15805impl WorkspaceWalkBudget {
15806 fn new(deadline: Instant, entry_cap: usize) -> Self {
15807 Self {
15808 deadline,
15809 entry_cap,
15810 entries_visited: 0,
15811 time_budget_exceeded: false,
15812 entry_cap_exceeded: false,
15813 }
15814 }
15815
15816 fn is_exhausted(&self) -> bool {
15817 self.time_budget_exceeded || self.entry_cap_exceeded
15818 }
15819
15820 fn record_visit(&mut self) -> bool {
15823 self.entries_visited += 1;
15824 if self.entries_visited as usize >= self.entry_cap {
15825 self.entry_cap_exceeded = true;
15826 }
15827 if Instant::now() >= self.deadline {
15828 self.time_budget_exceeded = true;
15829 }
15830 self.is_exhausted()
15831 }
15832}
15833
15834fn collect_workspace_entries(
15835 root: &Path,
15836 deadline: Instant,
15837 entry_cap: usize,
15838) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15839 let mut entries = Vec::new();
15840 let mut truncated = false;
15841 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15842 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15843 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15847 (entries, truncated, budget)
15848}
15849
15850fn workspace_inspection_truncation(
15854 walk_budget: &WorkspaceWalkBudget,
15855 git_time_budget_exceeded: bool,
15856 elapsed: Duration,
15857) -> Option<WorkspaceInspectionTruncationV1> {
15858 if !walk_budget.time_budget_exceeded
15859 && !walk_budget.entry_cap_exceeded
15860 && !git_time_budget_exceeded
15861 {
15862 return None;
15863 }
15864 Some(WorkspaceInspectionTruncationV1 {
15865 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15866 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15867 git_time_budget_exceeded,
15868 entries_visited: walk_budget.entries_visited,
15869 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15870 })
15871}
15872
15873fn walk_workspace_directory(
15874 directory: &Path,
15875 relative_directory: Option<&RepositoryPath>,
15876 depth: usize,
15877 entries: &mut Vec<WorkspaceEntry>,
15878 truncated: &mut bool,
15879 budget: &mut WorkspaceWalkBudget,
15880) {
15881 if budget.is_exhausted() {
15882 *truncated = true;
15883 return;
15884 }
15885 let read_dir = match std::fs::read_dir(directory) {
15886 Ok(read_dir) => read_dir,
15887 Err(_) => {
15888 *truncated = true;
15889 return;
15890 }
15891 };
15892 let mut children = Vec::new();
15893 for child in read_dir {
15894 match child {
15895 Ok(child) => children.push(child),
15896 Err(_) => *truncated = true,
15897 }
15898 }
15899 children.sort_by_key(|child| child.file_name());
15900 for child in children {
15901 if budget.record_visit() {
15902 *truncated = true;
15903 return;
15904 }
15905 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15906 *truncated = true;
15907 return;
15908 }
15909 let file_type = match child.file_type() {
15910 Ok(file_type) => file_type,
15911 Err(_) => {
15912 *truncated = true;
15913 continue;
15914 }
15915 };
15916 if file_type.is_symlink() {
15917 continue;
15918 }
15919 let name = match child.file_name().into_string() {
15920 Ok(name) => name,
15921 Err(_) => {
15922 *truncated = true;
15923 continue;
15924 }
15925 };
15926 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15927 continue;
15928 }
15929 let relative_path = match relative_directory {
15930 None => windows_repository_path(name),
15931 Some(parent) => parent
15932 .as_utf8()
15933 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15934 };
15935 let Some(relative_path) = relative_path else {
15936 *truncated = true;
15937 continue;
15938 };
15939 let kind = if file_type.is_dir() {
15940 WorkspaceEntryKind::Directory
15941 } else if file_type.is_file() {
15942 WorkspaceEntryKind::File
15943 } else {
15944 continue;
15945 };
15946 entries.push(WorkspaceEntry {
15947 relative_path: relative_path.clone(),
15948 kind,
15949 });
15950 if file_type.is_dir() {
15951 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15952 walk_workspace_directory(
15953 &child.path(),
15954 Some(&relative_path),
15955 depth + 1,
15956 entries,
15957 truncated,
15958 budget,
15959 );
15960 } else {
15961 *truncated = true;
15962 }
15963 }
15964 }
15965}
15966
15967fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15968 if value.contains('\\') {
15969 return None;
15970 }
15971 RepositoryPath::utf8(value).ok()
15972}
15973
15974fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15975 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15976}
15977
15978fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15987 if name.eq_ignore_ascii_case(".git")
15988 || name.eq_ignore_ascii_case("target")
15989 || name.eq_ignore_ascii_case("node_modules")
15990 {
15991 return true;
15992 }
15993 path.join("CACHEDIR.TAG").is_file()
15994}
15995
15996fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15997 matches!(
15998 status,
15999 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16000 )
16001}
16002
16003#[derive(Clone, Debug, Eq, PartialEq)]
16004enum ContextPackRepositoryHead {
16005 NotRepository,
16006 Unborn,
16007 Commit(String),
16008}
16009
16010#[derive(Clone, Debug, Eq, PartialEq)]
16011struct ContextPackRepositoryObservation {
16012 head: ContextPackRepositoryHead,
16013 git: GitSnapshot,
16014 files: Vec<ContextPackRepositoryFileSource>,
16015}
16016
16017fn stable_context_pack_repository(
16018 first: ContextPackRepositoryObservation,
16019 second: ContextPackRepositoryObservation,
16020) -> Result<ContextPackRepository, NodeFailure> {
16021 if first != second {
16022 return Err(failure(
16023 NodeFailureCode::ContextPackMaterializationFailed,
16024 "context repository changed during bounded capture",
16025 ));
16026 }
16027 Ok(ContextPackRepository::from_sources(
16028 &second.git,
16029 second.files,
16030 ))
16031}
16032
16033fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16040 if Instant::now() < deadline {
16041 return false;
16042 }
16043 append_git_diagnostic(
16044 snapshot,
16045 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16046 );
16047 true
16048}
16049
16050async fn run_git_bounded_with_deadline(
16059 root: &str,
16060 arguments: &[&str],
16061 output_limit: usize,
16062 deadline: Instant,
16063) -> io::Result<GitCommandOutput> {
16064 let remaining = deadline.checked_duration_since(Instant::now());
16065 let timeout_ms = remaining
16066 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16067 .unwrap_or(0)
16068 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16069 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16070}
16071
16072async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16079 let mut snapshot = GitSnapshot {
16080 is_repository: false,
16081 branch: None,
16082 status: Vec::new(),
16083 recent_commits: Vec::new(),
16084 worktrees: Vec::new(),
16085 managed_worktree: None,
16086 truncated: false,
16087 diagnostic: None,
16088 };
16089 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16090 return (snapshot, true);
16091 }
16092 let repository = match run_git_bounded_with_deadline(
16093 root,
16094 &["rev-parse", "--is-inside-work-tree"],
16095 4 * 1_024,
16096 deadline,
16097 )
16098 .await
16099 {
16100 Ok(output) => output,
16101 Err(error) => {
16102 append_git_diagnostic(
16103 &mut snapshot,
16104 &format!("git inspection unavailable: {error}"),
16105 );
16106 return (snapshot, false);
16107 }
16108 };
16109 snapshot.truncated |= repository.truncated;
16110 if repository.timed_out {
16111 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16112 return (snapshot, false);
16113 }
16114 if !repository.success
16115 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16116 {
16117 let stderr = String::from_utf8_lossy(&repository.stderr);
16118 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16119 append_git_diagnostic(&mut snapshot, stderr.trim());
16120 }
16121 return (snapshot, false);
16122 }
16123 snapshot.is_repository = true;
16124
16125 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16126 return (snapshot, true);
16127 }
16128 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16129 Ok(output) => {
16130 snapshot.truncated |= output.truncated;
16131 if output.timed_out {
16132 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16133 } else if output.success {
16134 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16135 if !branch.is_empty() {
16136 snapshot.branch = Some(truncate_git_field(
16137 &branch,
16138 MAX_REPOSITORY_PATH_BYTES,
16139 &mut snapshot.truncated,
16140 ));
16141 }
16142 }
16143 }
16144 Err(error) => append_git_diagnostic(
16145 &mut snapshot,
16146 &format!("git branch query failed: {error}"),
16147 ),
16148 }
16149 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16150 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16151 snapshot.truncated |= output.truncated;
16152 if output.success && !output.timed_out {
16153 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16154 if !head.is_empty() {
16155 snapshot.branch = Some(format!("detached:{head}"));
16156 }
16157 }
16158 }
16159 }
16160
16161 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16162 return (snapshot, true);
16163 }
16164 match run_git_bounded_with_deadline(
16165 root,
16166 &[
16167 "status",
16168 "--porcelain=v1",
16169 "-z",
16170 "--renames",
16171 "--untracked-files=normal",
16172 "--",
16173 ".",
16174 ],
16175 GIT_OUTPUT_MAX_BYTES,
16176 deadline,
16177 )
16178 .await
16179 {
16180 Ok(output) => {
16181 snapshot.truncated |= output.truncated;
16182 if output.timed_out {
16183 append_git_diagnostic(&mut snapshot, "git status timed out");
16184 } else if output.success {
16185 parse_git_status(&output.stdout, &mut snapshot);
16186 } else {
16187 append_git_diagnostic(
16188 &mut snapshot,
16189 String::from_utf8_lossy(&output.stderr).trim(),
16190 );
16191 }
16192 }
16193 Err(error) => append_git_diagnostic(
16194 &mut snapshot,
16195 &format!("git status failed: {error}"),
16196 ),
16197 }
16198
16199 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16200 return (snapshot, true);
16201 }
16202 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16203 match run_git_bounded_with_deadline(
16204 root,
16205 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16206 16 * 1_024,
16207 deadline,
16208 )
16209 .await
16210 {
16211 Ok(output) => {
16212 snapshot.truncated |= output.truncated;
16213 if output.timed_out {
16214 append_git_diagnostic(&mut snapshot, "git log timed out");
16215 } else if output.success {
16216 parse_git_commits(&output.stdout, &mut snapshot);
16217 } else {
16218 let stderr = String::from_utf8_lossy(&output.stderr);
16219 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16220 append_git_diagnostic(&mut snapshot, stderr.trim());
16221 }
16222 }
16223 }
16224 Err(error) => append_git_diagnostic(
16225 &mut snapshot,
16226 &format!("git log failed: {error}"),
16227 ),
16228 }
16229
16230 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16231 return (snapshot, true);
16232 }
16233 match list_git_worktrees_with_deadline(root, deadline).await {
16234 Ok(worktrees) => {
16235 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16236 }
16237 Err(error) => append_git_diagnostic(
16238 &mut snapshot,
16239 &format!("git worktree list failed: {}", error.message),
16240 ),
16241 }
16242 (snapshot, false)
16243}
16244
16245fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16246 let mut remaining = output;
16247 while !remaining.is_empty() {
16248 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16249 snapshot.truncated = true;
16250 break;
16251 }
16252 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16253 snapshot.truncated = true;
16254 break;
16255 };
16256 let record = &remaining[..record_end];
16257 remaining = &remaining[record_end + 1..];
16258 let has_previous_path = record
16259 .get(..2)
16260 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16261 let previous_path_bytes = if has_previous_path {
16262 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16263 snapshot.truncated = true;
16264 break;
16265 };
16266 let previous = &remaining[..previous_end];
16267 remaining = &remaining[previous_end + 1..];
16268 Some(previous)
16269 } else {
16270 None
16271 };
16272 if record.len() < 4
16273 || record[2] != b' '
16274 || !is_git_status_code(record[0])
16275 || !is_git_status_code(record[1])
16276 {
16277 snapshot.truncated = true;
16278 continue;
16279 }
16280 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16281 snapshot.truncated = true;
16282 continue;
16283 };
16284 let previous_path = match previous_path_bytes {
16285 Some(previous) => {
16286 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16287 snapshot.truncated = true;
16288 continue;
16289 };
16290 Some(previous)
16291 }
16292 None => None,
16293 };
16294 snapshot.status.push(GitStatusEntry {
16295 index_status: (record[0] as char).to_string(),
16296 worktree_status: (record[1] as char).to_string(),
16297 path,
16298 previous_path,
16299 });
16300 }
16301}
16302
16303fn is_git_status_code(byte: u8) -> bool {
16304 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16305}
16306
16307fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16308 for line in String::from_utf8_lossy(output).lines() {
16309 let Some((id, summary)) = line.split_once('\u{1f}') else {
16310 continue;
16311 };
16312 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16313 snapshot.truncated = true;
16314 break;
16315 }
16316 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16317 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16318 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16319 }
16320}
16321
16322fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16323 if value.len() <= max_bytes {
16324 return value.to_owned();
16325 }
16326 *truncated = true;
16327 let mut boundary = max_bytes;
16328 while boundary > 0 && !value.is_char_boundary(boundary) {
16329 boundary -= 1;
16330 }
16331 value[..boundary].to_owned()
16332}
16333
16334fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16335 if message.is_empty() {
16336 return;
16337 }
16338 let normalized = message
16339 .chars()
16340 .map(|character| if character.is_control() { ' ' } else { character })
16341 .collect::<String>();
16342 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16343 if !diagnostic.is_empty() {
16344 diagnostic.push_str("; ");
16345 }
16346 diagnostic.push_str(normalized.trim());
16347 snapshot.diagnostic = Some(truncate_git_field(
16348 &diagnostic,
16349 GIT_DIAGNOSTIC_MAX_BYTES,
16350 &mut snapshot.truncated,
16351 ));
16352}
16353
16354async fn run_git_bounded(
16355 root: &str,
16356 arguments: &[&str],
16357 output_limit: usize,
16358) -> io::Result<GitCommandOutput> {
16359 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16360}
16361
16362async fn read_git_history_bounded(
16363 root: &str,
16364 path: Option<&RepositoryPath>,
16365 before: Option<&GitObjectId>,
16366 limit: u16,
16367) -> Result<GitHistoryPage, NodeFailure> {
16368 let path = path
16369 .map(|path| {
16370 path.as_utf8().ok_or_else(|| {
16371 failure(
16372 NodeFailureCode::InvalidRepositoryPath,
16373 "git history path must use UTF-8",
16374 )
16375 })
16376 })
16377 .transpose()?
16378 .unwrap_or(".");
16379 let count = (usize::from(limit) + 1).to_string();
16380 let start = before
16381 .map(|revision| format!("{}^", revision.as_str()))
16382 .unwrap_or_else(|| "HEAD".to_owned());
16383 let arguments = [
16384 "--no-pager",
16385 "log",
16386 "--no-decorate",
16387 "--date=iso-strict",
16388 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16389 "-n",
16390 count.as_str(),
16391 start.as_str(),
16392 "--",
16393 path,
16394 ];
16395 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16396 .await
16397 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16398 if output.timed_out {
16399 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16400 }
16401 if !output.success {
16402 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16403 }
16404 let mut commits = parse_git_history(&output.stdout);
16405 let has_more = commits.len() > usize::from(limit) || output.truncated;
16406 commits.truncate(usize::from(limit));
16407 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16408 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16409}
16410
16411fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16412 String::from_utf8_lossy(output)
16413 .split('\u{1e}')
16414 .filter_map(|record| {
16415 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16416 if fields.len() != 9 {
16417 return None;
16418 }
16419 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16420 let parents = if fields[1].is_empty() {
16421 Vec::new()
16422 } else {
16423 fields[1]
16424 .split(' ')
16425 .map(|value| GitObjectId::new(value.to_owned()))
16426 .collect::<Result<Vec<_>, _>>()
16427 .ok()?
16428 };
16429 Some(GitCommitDetails {
16430 id,
16431 parents,
16432 subject: bounded_git_text(fields[2], 512),
16433 author_name: bounded_git_text(fields[3], 256),
16434 author_email: bounded_git_text(fields[4], 320),
16435 authored_at: bounded_git_text(fields[5], 64),
16436 committer_name: bounded_git_text(fields[6], 256),
16437 committer_email: bounded_git_text(fields[7], 320),
16438 committed_at: bounded_git_text(fields[8], 64),
16439 signature_status: GitSignatureStatus::NoSignature,
16440 signer: None,
16441 })
16442 })
16443 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16444 .collect()
16445}
16446
16447fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16448 let normalized = value
16449 .chars()
16450 .map(|character| if character.is_control() { ' ' } else { character })
16451 .collect::<String>();
16452 if normalized.len() <= max_bytes {
16453 return normalized;
16454 }
16455 let mut boundary = max_bytes;
16456 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16457 boundary -= 1;
16458 }
16459 normalized[..boundary].to_owned()
16460}
16461
16462async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16463 let path = request.path.as_ref().map(|path| {
16464 path.as_utf8()
16465 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16466 }).transpose()?;
16467 let mut arguments = match &request.mode {
16468 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16469 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16470 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16471 };
16472 if let GitDiffMode::Commit { revision } = &request.mode {
16473 arguments.push(revision.as_str());
16474 }
16475 arguments.push("--");
16476 if let Some(path) = path {
16477 arguments.push(path);
16478 }
16479 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16480 .await
16481 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16482 if output.timed_out {
16483 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16484 }
16485 if !output.success {
16486 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16487 }
16488 Ok(GitDiff {
16489 mode: request.mode,
16490 path: request.path,
16491 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16492 truncated: output.truncated,
16493 })
16494}
16495
16496async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16497 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16498 ResponseEnvelope { request_id: envelope.request_id, result }
16499}
16500
16501async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16502 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16503 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16504 }
16505 let read_only = matches!(
16506 &request,
16507 NodeRequest::Snapshot
16508 | NodeRequest::Resync { .. }
16509 | NodeRequest::BrowseHostDirectories { .. }
16510 | NodeRequest::InspectWorkspace { .. }
16511 | NodeRequest::ReadWorkspaceFile { .. }
16512 | NodeRequest::ReadGitHistory { .. }
16513 | NodeRequest::ReadGitDiff { .. }
16514 | NodeRequest::CatalogNativeSessions { .. }
16515 | NodeRequest::PageNativeSessions { .. }
16516 | NodeRequest::PreviewNativeSession { .. }
16517 | NodeRequest::PreviewSessionRecord { .. }
16518 );
16519 let _mutation_guard = if read_only {
16520 None
16521 } else {
16522 let guard = shared.mutation_gate.lock().await;
16523 if shared.shutdown.load(Ordering::Acquire) {
16524 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16525 }
16526 Some(guard)
16527 };
16528 match request {
16529 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16530 event_sequence: shared.current_sequence(),
16531 controller: shared.controller_state(),
16532 snapshot: shared.snapshot(),
16533 }),
16534 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16535 NodeRequest::ArmHarnessMcpReservation {
16536 reservation_id,
16537 activation_digest,
16538 spawn_spec,
16539 launch,
16540 expires_at_unix_ms,
16541 } => {
16542 shared.require_controller(connection_id, role)?;
16543 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16544 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16545 })?;
16546 let expires_at_unix_ms = registry
16547 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16548 .await
16549 .map_err(harness_mcp_failure)?;
16550 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16551 }
16552 NodeRequest::SpawnSpecWithHarnessMcp {
16553 reservation_id,
16554 activation_digest,
16555 spec,
16556 deadline_unix_ms,
16557 } => {
16558 shared.require_controller(connection_id, role)?;
16559 let receipt = shared
16560 .spawn_from_spec_with_harness_mcp(
16561 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16562 )
16563 .await?;
16564 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16565 }
16566 NodeRequest::ActivateHarnessMcpReservation {
16567 reservation_id,
16568 activation_digest,
16569 record_id,
16570 session,
16571 } => {
16572 shared.require_controller(connection_id, role)?;
16573 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16574 current.instance_id == session.session.instance_id
16575 && current.generation == session.session.generation
16576 }).and_then(|current| current.process_id).ok_or_else(|| {
16577 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16578 })?;
16579 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16580 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16581 })?.activate(
16582 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16583 ).map_err(harness_mcp_failure)?;
16584 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16585 }
16586 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16587 shared.require_controller(connection_id, role)?;
16588 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16589 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16590 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16591 if let Some(instance_id) = instance_id {
16592 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16593 }
16594 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16595 }
16596 NodeRequest::PutHarnessMcpReplyChunk {
16597 reservation_id, activation_digest, record_id, session, call_id,
16598 offset, final_chunk, chunk_hex,
16599 } => {
16600 shared.require_controller(connection_id, role)?;
16601 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16602 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16603 })?.put_reply_chunk(
16604 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16605 offset, final_chunk, &chunk_hex,
16606 ).map_err(harness_mcp_failure)?;
16607 Ok(NodeResponse::ReplyChunkAccepted {
16608 reservation_id, activation_digest, record_id, session, call_id,
16609 next_offset, completed,
16610 })
16611 }
16612 NodeRequest::RejectHarnessMcpCall {
16613 reservation_id, activation_digest, record_id, session, call_id, reason,
16614 } => {
16615 shared.require_controller(connection_id, role)?;
16616 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16617 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16618 })?.reject_call(
16619 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16620 ).map_err(harness_mcp_failure)?;
16621 Ok(NodeResponse::CallRejected {
16622 reservation_id, activation_digest, record_id, session, call_id,
16623 })
16624 }
16625 NodeRequest::BeginDeliveryStage { manifest } => {
16626 shared.require_controller(connection_id, role)?;
16627 shared.begin_delivery_stage(manifest)
16628 }
16629 NodeRequest::PutDeliveryBlobChunk {
16630 stage_id,
16631 blob_digest,
16632 offset,
16633 chunk_hex,
16634 } => {
16635 shared.require_controller(connection_id, role)?;
16636 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16637 }
16638 NodeRequest::CommitDeliveryStage { stage_id } => {
16639 shared.require_controller(connection_id, role)?;
16640 let receipt = shared.commit_delivery_stage(stage_id)?;
16641 Ok(NodeResponse::DeliveryCommitted { receipt })
16642 }
16643 NodeRequest::AbortDeliveryStage { stage_id } => {
16644 shared.require_controller(connection_id, role)?;
16645 shared.abort_delivery_stage(&stage_id)?;
16646 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16647 }
16648 NodeRequest::BrowseHostDirectories { directory, after } => {
16649 let listing = shared.browse_host_directories(directory, after).await?;
16650 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16651 }
16652 NodeRequest::InspectWorkspace { workspace_id } => {
16653 let inspection = shared.inspect_workspace(workspace_id).await?;
16654 Ok(NodeResponse::WorkspaceInspected { inspection })
16655 }
16656 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16657 let file = shared.read_workspace_file(workspace_id, path).await?;
16658 Ok(NodeResponse::WorkspaceFileRead { file })
16659 }
16660 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16661 shared.require_controller(connection_id, role)?;
16662 let file = shared
16663 .write_workspace_file(workspace_id, path, expected_revision, text)
16664 .await?;
16665 Ok(NodeResponse::WorkspaceFileWritten { file })
16666 }
16667 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16668 shared.require_controller(connection_id, role)?;
16669 let file = shared.create_workspace_file(workspace_id, path).await?;
16670 Ok(NodeResponse::WorkspaceFileCreated { file })
16671 }
16672 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16673 shared.require_controller(connection_id, role)?;
16674 let entry = shared
16675 .create_workspace_directory(workspace_id.clone(), path)
16676 .await?;
16677 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16678 }
16679 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16680 if role != ClientRole::Operator {
16681 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16682 }
16683 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16684 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16685 }
16686 let page = shared
16687 .read_git_history(workspace_id.clone(), path, before, limit)
16688 .await?;
16689 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16690 }
16691 NodeRequest::ReadGitDiff { workspace_id, request } => {
16692 if role != ClientRole::Operator {
16693 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16694 }
16695 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16696 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16697 }
16698 NodeRequest::CatalogNativeSessions { route, limit } => {
16699 if role != ClientRole::Operator {
16700 return Err(failure(
16701 NodeFailureCode::ObserverReadOnly,
16702 "native session catalog requires an operator connection",
16703 ));
16704 }
16705 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16706 return Err(failure(
16707 NodeFailureCode::InvalidRequest,
16708 "native session catalog limit is invalid",
16709 ));
16710 }
16711 let (entries, summary) = shared
16712 .catalog_native_sessions(route.clone(), limit)
16713 .await?;
16714 Ok(NodeResponse::NativeSessionsCataloged {
16715 route,
16716 entries,
16717 summary: Some(summary),
16718 })
16719 }
16720 NodeRequest::PageNativeSessions {
16721 route,
16722 window,
16723 catalog_revision,
16724 recent_cutoff_unix_ms,
16725 after_selection_id,
16726 limit,
16727 } => {
16728 if role != ClientRole::Operator {
16729 return Err(failure(
16730 NodeFailureCode::ObserverReadOnly,
16731 "native session catalog paging requires an operator connection",
16732 ));
16733 }
16734 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16735 || after_selection_id
16736 .as_deref()
16737 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16738 {
16739 return Err(failure(
16740 NodeFailureCode::InvalidRequest,
16741 "native session catalog page request is invalid",
16742 ));
16743 }
16744 let page = shared
16745 .page_native_sessions(
16746 route.clone(),
16747 window,
16748 catalog_revision,
16749 recent_cutoff_unix_ms,
16750 after_selection_id,
16751 limit,
16752 )
16753 .await?;
16754 Ok(NodeResponse::NativeSessionsPaged {
16755 route,
16756 page,
16757 })
16758 }
16759 NodeRequest::PreviewNativeSession {
16760 selection,
16761 message_limit,
16762 } => {
16763 if role != ClientRole::Operator {
16764 return Err(failure(
16765 NodeFailureCode::ObserverReadOnly,
16766 "native session preview requires an operator connection",
16767 ));
16768 }
16769 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16770 .contains(&message_limit)
16771 || selection.validate().is_err()
16772 {
16773 return Err(failure(
16774 NodeFailureCode::InvalidRequest,
16775 "native session preview request is invalid",
16776 ));
16777 }
16778 let preview = shared
16779 .preview_native_session(selection.clone(), message_limit)
16780 .await?;
16781 Ok(NodeResponse::NativeSessionPreviewed {
16782 selection,
16783 preview,
16784 })
16785 }
16786 NodeRequest::IndexNativeSession {
16787 selection,
16788 display_name,
16789 } => {
16790 shared.require_controller(connection_id, role)?;
16791 let record = shared
16792 .index_native_session(selection.clone(), display_name)
16793 .await?;
16794 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16795 }
16796 NodeRequest::PreviewSessionRecord {
16797 record_id,
16798 message_limit,
16799 } => {
16800 if role != ClientRole::Operator {
16801 return Err(failure(
16802 NodeFailureCode::ObserverReadOnly,
16803 "native session preview requires an operator connection",
16804 ));
16805 }
16806 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16807 .contains(&message_limit)
16808 {
16809 return Err(failure(
16810 NodeFailureCode::InvalidRequest,
16811 "native session preview request is invalid",
16812 ));
16813 }
16814 let record = shared.record(&record_id)?;
16815 let working_directory = shared.workspace_root(&record.workspace_id)?;
16816 if !platform::roots_equal(
16817 &working_directory,
16818 windows_path_text(&record.canonical_root),
16819 ) {
16820 return Err(failure(
16821 NodeFailureCode::SessionRecordConflict,
16822 "managed session workspace root changed; refusing to preview another directory",
16823 ));
16824 }
16825 let identity = record.provider_session.clone().ok_or_else(|| {
16826 failure(
16827 NodeFailureCode::BackendOperationFailed,
16828 "native session preview is unavailable for this session record",
16829 )
16830 })?;
16831 if identity.key != ProviderSessionKey::SessionId {
16832 return Err(failure(
16833 NodeFailureCode::BackendOperationFailed,
16834 "native session preview is unavailable for this session identity",
16835 ));
16836 }
16837 let preview = shared
16838 .preview_native_session_by_session_id(
16839 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16840 )
16841 .await?;
16842 if preview.session_id != identity.id {
16843 return Err(failure(
16844 NodeFailureCode::SessionRecordConflict,
16845 "native session preview identity changed while loading",
16846 ));
16847 }
16848 shared.revalidate_session_record_preview(&record, &identity)?;
16849 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16850 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16851 record_id: record_id.clone(),
16852 summary: SessionHistorySummaryV1::from(&preview),
16853 });
16854 Ok(NodeResponse::SessionRecordPreviewed {
16855 record_id,
16856 preview,
16857 })
16858 }
16859 NodeRequest::AcquireController { lease_ms } => {
16860 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16861 Ok(NodeResponse::Controller { controller: Some(controller) })
16862 }
16863 NodeRequest::ReleaseController => {
16864 shared.release_controller(connection_id);
16865 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16866 }
16867 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16868 shared.require_controller(connection_id, role)?;
16869 let root = require_windows_path(root)?;
16870 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16871 let workspace = shared.register_workspace(workspace_id, root).await?;
16872 Ok(NodeResponse::WorkspaceRegistered { workspace })
16873 }
16874 NodeRequest::CreateStandaloneWorkspace {
16875 workspace_id,
16876 root,
16877 initial_branch,
16878 } => {
16879 shared.require_controller(connection_id, role)?;
16880 let root = require_windows_path(root)?;
16881 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16882 let workspace = shared
16883 .create_standalone_workspace(workspace_id, root, initial_branch)
16884 .await?;
16885 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16886 }
16887 NodeRequest::UnregisterWorkspace { workspace_id } => {
16888 shared.require_controller(connection_id, role)?;
16889 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16890 shared.unregister_workspace(&workspace_id)?;
16891 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16892 }
16893 NodeRequest::CreateWorktree {
16894 source_workspace_id,
16895 workspace_id,
16896 target_root,
16897 branch,
16898 base,
16899 } => {
16900 shared.require_controller(connection_id, role)?;
16901 let target_root = require_windows_path(target_root)?;
16902 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16903 let (worktree, workspace) = shared
16904 .create_worktree(
16905 source_workspace_id,
16906 workspace_id,
16907 target_root,
16908 branch,
16909 base,
16910 )
16911 .await?;
16912 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16913 }
16914 NodeRequest::RemoveWorktree {
16915 source_workspace_id,
16916 target_root,
16917 } => {
16918 shared.require_controller(connection_id, role)?;
16919 let native_target_root = require_windows_path(target_root.clone())?;
16920 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16921 let workspace_id = shared
16922 .remove_worktree(source_workspace_id, native_target_root)
16923 .await?;
16924 Ok(NodeResponse::WorktreeRemoved {
16925 target_root,
16926 workspace_id,
16927 })
16928 }
16929 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16930 shared.require_controller(connection_id, role)?;
16931 if !terminal_size.is_valid() {
16932 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16933 }
16934 if let Some(prompt) = initial_prompt.as_deref() {
16935 validate_node_text("spawn initial prompt", prompt)?;
16936 }
16937 match shared
16938 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16939 .await
16940 {
16941 Ok(session) => {
16942 shared.mark_spawn_provisional(session.clone());
16943 Ok(NodeResponse::SpawnAccepted { session })
16944 }
16945 Err(err) => {
16946 tracing::warn!(
16947 %provider,
16948 %workspace_id,
16949 ?mode,
16950 code = ?err.code,
16951 message = %err.message,
16952 "session spawn refused"
16953 );
16954 Err(err)
16955 }
16956 }
16957 }
16958 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16959 shared.require_controller(connection_id, role)?;
16960 if !terminal_size.is_valid() {
16961 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16962 }
16963 if let Some(prompt) = initial_prompt.as_deref() {
16964 validate_node_text("spawn initial prompt", prompt)?;
16965 }
16966 let plan = backend_paths::plan_launch_options(
16967 provider.as_str(),
16968 mode,
16969 options.model.is_some(),
16970 options.effort.is_some(),
16971 )
16972 .map_err(|r| {
16973 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16974 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16975 })?;
16976 let selection = spawn_selection(&options)?;
16977 let pty_selection = match plan {
16978 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16979 _ => None,
16980 };
16981 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16982 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16983 SPAWN_SESSION_OPTIONS
16984 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16985 .await
16986 }).await
16987 }).await;
16988 match spawned {
16989 Ok(session) => {
16990 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16991 shared
16992 .pending_acp_options
16993 .lock()
16994 .unwrap_or_else(|p| p.into_inner())
16995 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16996 }
16997 shared.mark_spawn_provisional(session.clone());
16998 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16999 Ok(NodeResponse::SpawnAccepted { session })
17000 }
17001 Err(err) => {
17002 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17003 Err(err)
17004 }
17005 }
17006 }
17007 NodeRequest::SpawnSpec { spec } => {
17008 shared.require_controller(connection_id, role)?;
17009 let receipt = shared.spawn_from_spec(spec).await?;
17010 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17011 }
17012 NodeRequest::SpawnManagedWorktree { request } => {
17013 shared.require_controller(connection_id, role)?;
17014 let receipt = shared.spawn_managed_worktree(request).await?;
17015 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17016 }
17017 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17018 shared.require_controller(connection_id, role)?;
17019 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17020 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17021 }
17022 NodeRequest::CleanupManagedWorktree { lease_id } => {
17023 shared.require_controller(connection_id, role)?;
17024 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17025 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17026 }
17027 NodeRequest::DiscoverHistory { session, limit } => {
17028 controlled_session(shared, connection_id, role, &session)?;
17029 let candidates = shared.discover_history(&session, limit).await?;
17030 Ok(NodeResponse::HistoryDiscovered {
17031 session,
17032 candidates,
17033 })
17034 }
17035 NodeRequest::LoadHistory {
17036 session,
17037 candidate_id,
17038 } => {
17039 controlled_session(shared, connection_id, role, &session)?;
17040 let loaded = shared
17041 .load_history(&session, candidate_id)
17042 .await?;
17043 Ok(NodeResponse::HistoryLoaded {
17044 session,
17045 session_id: loaded.session_id,
17046 message_count: loaded.message_count,
17047 completed_turn_count: loaded.completed_turn_count,
17048 })
17049 }
17050 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17051 shared.require_controller(connection_id, role)?;
17052 let context = shared
17053 .export_context_pack_for_session_record(&record_id, &session, false)
17054 .await?;
17055 Ok(NodeResponse::ContextPackForSessionRecordExported {
17056 record_id,
17057 session,
17058 context,
17059 })
17060 }
17061 NodeRequest::ExportContextPack { session } => {
17062 controlled_session(shared, connection_id, role, &session)?;
17063 let context = shared.export_context_pack(&session).await?;
17064 Ok(NodeResponse::ContextPackExported { context })
17065 }
17066 NodeRequest::ForgetContextPack { context_id } => {
17067 shared.require_controller(connection_id, role)?;
17068 shared.forget_context_pack(&context_id)?;
17069 Ok(NodeResponse::ContextPackForgotten { context_id })
17070 }
17071 NodeRequest::ResolveDurableContextPack { context_id } => {
17072 let context = shared
17076 .context_catalog
17077 .read()
17078 .unwrap_or_else(|poisoned| poisoned.into_inner())
17079 .get(&context_id)
17080 .map(|pack| pack.receipt().clone())
17081 .ok_or_else(|| {
17082 failure(
17083 NodeFailureCode::UnknownContextPack,
17084 "context pack is not present",
17085 )
17086 })?;
17087 Ok(NodeResponse::DurableContextPackResolved { context })
17088 }
17089 NodeRequest::ReadContextPack { digest } => {
17090 let (id, byte_len, bytes) = shared
17095 .context_catalog
17096 .read()
17097 .unwrap_or_else(|poisoned| poisoned.into_inner())
17098 .get_by_digest(&digest)
17099 .map(|pack| {
17100 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17101 })
17102 .ok_or_else(|| {
17103 failure(
17104 NodeFailureCode::UnknownContextPack,
17105 &format!("context pack {digest} not on this node"),
17106 )
17107 })?;
17108 tracing::debug!(
17109 digest = digest.as_str(),
17110 byte_len,
17111 "read context pack bytes",
17112 );
17113 Ok(NodeResponse::ContextPackBytesRead {
17114 pack: ContextPackBytesRead {
17115 digest,
17116 id,
17117 byte_len,
17118 bytes,
17119 },
17120 })
17121 }
17122 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17123 let provider = controlled_session(shared, connection_id, role, &session)?;
17124 if let Some(prompt) = initial_prompt.as_deref() {
17125 validate_node_text("resume initial prompt", prompt)?;
17126 }
17127 let runtime_requirement = if initial_prompt.is_some() {
17128 ProviderRuntimeRequirement::ResumeWithPrompt
17129 } else {
17130 ProviderRuntimeRequirement::Resume
17131 };
17132 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17133 let runtime_policy = shared
17134 .admit_provider_runtime(&provider, runtime_requirement)
17135 .await?;
17136 let working_directory = shared.bound_workspace_root(&session)?;
17137 let request = ResumeLaunchRequest {
17138 working_directory,
17139 terminal_size,
17140 initial_prompt,
17141 };
17142 request
17143 .validate()
17144 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17145 let command = shared.prepare_command(ControlCommand::Resume {
17146 instance_id: session.session.instance_id,
17147 target: ResumeTarget::CurrentProvider,
17148 runtime_policy,
17149 request,
17150 });
17151 shared.arm_resume(&session, command.id, runtime_policy)?;
17152 let dispatch = shared.dispatch_envelope(command);
17153 if let Err(error) = dispatch {
17154 shared.clear_armed_resume(&session);
17155 return Err(error);
17156 }
17157 Ok(NodeResponse::Accepted)
17158 }
17159 NodeRequest::RenameSessionRecord {
17160 record_id,
17161 display_name,
17162 } => {
17163 shared.require_controller(connection_id, role)?;
17164 let record = shared.rename_session_record(&record_id, display_name)?;
17165 Ok(NodeResponse::SessionRecordUpdated { record })
17166 }
17167 NodeRequest::SetSessionTask {
17168 record_id,
17169 expected_revision,
17170 target,
17171 } => {
17172 shared.require_controller(connection_id, role)?;
17173 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17174 Ok(NodeResponse::SessionRecordUpdated { record })
17175 }
17176 NodeRequest::IndexProviderSession {
17177 workspace_id,
17178 provider,
17179 identity,
17180 display_name,
17181 } => {
17182 shared.require_controller(connection_id, role)?;
17183 let record = shared.index_provider_session(
17184 workspace_id,
17185 provider,
17186 identity,
17187 display_name,
17188 )?;
17189 Ok(NodeResponse::ProviderSessionIndexed { record })
17190 }
17191 NodeRequest::ResumeSessionRecord {
17192 record_id,
17193 terminal_size,
17194 initial_prompt,
17195 } => {
17196 shared.require_controller(connection_id, role)?;
17197 if let Some(prompt) = initial_prompt.as_deref() {
17198 validate_node_text("managed resume initial prompt", prompt)?;
17199 }
17200 let (record, session) = shared
17201 .resume_session_record(
17202 &record_id,
17203 terminal_size,
17204 initial_prompt,
17205 )
17206 .await?;
17207 Ok(NodeResponse::SessionRecordResumed { record, session })
17208 }
17209 NodeRequest::ForgetSessionRecord { record_id } => {
17210 shared.require_controller(connection_id, role)?;
17211 shared.forget_session_record(&record_id).await?;
17212 Ok(NodeResponse::SessionRecordForgotten { record_id })
17213 }
17214 NodeRequest::Prompt { session, text } => {
17215 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17216 validate_node_text("prompt", &text)?;
17217 shared.require_session_runtime_policy(
17218 &session,
17219 ProviderRuntimeRequirement::SemanticPrompt,
17220 )?;
17221 let framing = prompt_framing(&agent_id);
17222 shared
17223 .dispatch_input_bounded(
17224 &session,
17225 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17226 )
17227 .await?;
17228 Ok(NodeResponse::Accepted)
17229 }
17230 NodeRequest::Paste { session, text } => {
17231 controlled_session(shared, connection_id, role, &session)?;
17232 validate_node_text("paste", &text)?;
17233 shared.require_session_runtime_policy(
17234 &session,
17235 ProviderRuntimeRequirement::SemanticPrompt,
17236 )?;
17237 shared
17238 .dispatch_input_bounded(
17239 &session,
17240 InputAction::InsertDraft(PromptPayload {
17241 text,
17242 framing: PromptFraming::BracketedPaste,
17243 }),
17244 )
17245 .await?;
17246 Ok(NodeResponse::Accepted)
17247 }
17248 NodeRequest::Input { session, text } => {
17249 controlled_session(shared, connection_id, role, &session)?;
17250 validate_node_text("terminal input", &text)?;
17251 shared
17252 .dispatch_input_bounded(
17253 &session,
17254 InputAction::TerminalText(TerminalText { text }),
17255 )
17256 .await?;
17257 Ok(NodeResponse::Accepted)
17258 }
17259 NodeRequest::TerminalBytes { session, bytes } => {
17260 controlled_session(shared, connection_id, role, &session)?;
17261 shared.sessionize_spawn(session.session.instance_id);
17262 let action = terminal_bytes_action(bytes)?;
17263 shared
17264 .dispatch_input_bounded(&session, action)
17265 .await?;
17266 Ok(NodeResponse::Accepted)
17267 }
17268 NodeRequest::TerminalControl { session, control } => {
17269 controlled_session(shared, connection_id, role, &session)?;
17270 shared
17271 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17272 .await?;
17273 Ok(NodeResponse::Accepted)
17274 }
17275 NodeRequest::Resize { session, size } => {
17276 controlled_session(shared, connection_id, role, &session)?;
17277 shared.sessionize_spawn(session.session.instance_id);
17278 if !size.is_valid() {
17279 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17280 }
17281 shared.dispatch_resize_bounded(&session, size).await?;
17282 Ok(NodeResponse::Accepted)
17283 }
17284 NodeRequest::Interrupt { session } => {
17285 controlled_session(shared, connection_id, role, &session)?;
17286 shared
17287 .dispatch_input_bounded(
17288 &session,
17289 InputAction::TerminalControl(TerminalControl::Interrupt),
17290 )
17291 .await?;
17292 Ok(NodeResponse::Accepted)
17293 }
17294 NodeRequest::Stop { session, force } => {
17295 controlled_session(shared, connection_id, role, &session)?;
17296 shared.sessionize_spawn(session.session.instance_id);
17297 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17298 shared.retire_session_grok_leader(session.session.instance_id);
17299 Ok(NodeResponse::Accepted)
17300 }
17301 NodeRequest::Remove { session } => {
17302 controlled_session(shared, connection_id, role, &session)?;
17303 shared.remove_session(&session).await?;
17304 Ok(NodeResponse::Accepted)
17305 }
17306 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17307 shared.require_controller(connection_id, role)?;
17308 let snapshot = shared.require_acp_session(&session)?;
17309 let interaction = snapshot
17310 .provider
17311 .interactions
17312 .iter()
17313 .find(|interaction| {
17314 interaction_correlation(
17315 session.session.instance_id,
17316 session.session.generation,
17317 interaction.id.0,
17318 ) == correlation_id
17319 })
17320 .ok_or_else(|| failure(
17321 NodeFailureCode::InvalidRequest,
17322 "unknown interaction correlation",
17323 ))?;
17324 if interaction.status != ProviderInteractionStatus::Pending {
17325 return Err(failure(
17326 NodeFailureCode::BackendOperationFailed,
17327 "interaction has already been resolved",
17328 ));
17329 }
17330 response
17331 .validate_for(interaction.interaction_kind)
17332 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17333 let interaction_id = interaction.id;
17334 let response_kind = response.kind();
17335 let response_outcome = response.outcome();
17336 shared.dispatch(ControlCommand::ResolveInteraction {
17337 instance_id: session.session.instance_id,
17338 generation: session.session.generation,
17339 interaction_id,
17340 response,
17341 })?;
17342 tracing::info!(
17348 node_id = %shared.node_id,
17349 session = ?session.session,
17350 correlation_id = %correlation_id,
17351 interaction_id = interaction_id.0,
17352 response_kind = ?response_kind,
17353 outcome = ?response_outcome,
17354 "interaction resolved",
17355 );
17356 Ok(NodeResponse::Accepted)
17357 }
17358 NodeRequest::SetSessionMode { session, mode_id } => {
17359 shared.require_controller(connection_id, role)?;
17360 shared.require_acp_session(&session)?;
17361 shared.dispatch(ControlCommand::SetSessionMode {
17362 instance_id: session.session.instance_id,
17363 mode_id,
17364 })?;
17365 Ok(NodeResponse::Accepted)
17366 }
17367 NodeRequest::SetSessionConfigOption {
17368 session,
17369 option_id,
17370 value_json,
17371 } => {
17372 shared.require_controller(connection_id, role)?;
17373 shared.require_acp_session(&session)?;
17374 shared.dispatch(ControlCommand::SetSessionConfigOption {
17375 instance_id: session.session.instance_id,
17376 option_id,
17377 value_json,
17378 })?;
17379 Ok(NodeResponse::Accepted)
17380 }
17381 NodeRequest::SetSessionModel { session, model_id } => {
17382 shared.require_controller(connection_id, role)?;
17383 shared.require_acp_session(&session)?;
17384 shared.dispatch(ControlCommand::SetSessionModel {
17385 instance_id: session.session.instance_id,
17386 model_id,
17387 })?;
17388 Ok(NodeResponse::Accepted)
17389 }
17390 NodeRequest::Shutdown => {
17391 shared.require_controller(connection_id, role)?;
17392 shared.begin_shutdown_locked().await?;
17393 Ok(NodeResponse::ShuttingDown)
17394 }
17395 }
17396}
17397
17398fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17399 shared.require_controller(connection_id, role)?;
17400 shared.validate_address(session)
17401}
17402
17403fn select_history_candidate(
17409 candidates: &[HistoryCandidateSummary],
17410 provider_session_id: Option<&str>,
17411) -> Result<String, NodeFailure> {
17412 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17413 Some(id) => candidate.session_id_hint == id,
17414 None => true,
17415 });
17416 let candidate = matches.next().ok_or_else(|| {
17417 failure(
17418 NodeFailureCode::SessionRecordConflict,
17419 if provider_session_id.is_some() {
17420 "no history candidate matches the managed provider session"
17421 } else {
17422 "no history candidate exists for the managed session"
17423 },
17424 )
17425 })?;
17426 if matches.next().is_some() {
17427 return Err(failure(
17428 NodeFailureCode::SessionRecordConflict,
17429 if provider_session_id.is_some() {
17430 "history identity is ambiguous for the managed provider session"
17431 } else {
17432 "history is ambiguous without a verified provider session identity"
17433 },
17434 ));
17435 }
17436 Ok(candidate.id.clone())
17437}
17438
17439fn resolved_spawn_profile_summary(
17440 profile: &SpawnProfileDefaults,
17441 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17442) -> Option<SpawnProfileSummary> {
17443 let environment_profile = match profile.environment_profile_id.as_ref() {
17444 None => None,
17445 Some(profile_id) => {
17446 let binding = environment_profiles.get(profile_id)?;
17447 if binding.provider != profile.provider
17448 || binding.native_profile_id(profile.mode).is_none()
17449 {
17450 return None;
17451 }
17452 Some(ResolvedEnvironmentProfileReceipt {
17453 profile_id: binding.id.clone(),
17454 profile_revision: binding.revision.clone(),
17455 network_allowlist: None,
17456 browser_profile_id: None,
17457 })
17458 }
17459 };
17460 Some(SpawnProfileSummary {
17461 id: profile.profile_id.clone(),
17462 revision: profile.revision.clone(),
17463 environment_profile,
17464 })
17465}
17466
17467fn session_record_export_target_matches(
17486 expected: &ManagedSessionRecord,
17487 current: &ManagedSessionRecord,
17488 session: &SessionAddress,
17489 allow_clean_detachment: bool,
17490) -> bool {
17491 if current == expected && current.active_session.as_ref() == Some(session) {
17492 return current.state == ManagedSessionState::Live;
17493 }
17494 allow_clean_detachment
17495 && current.state == ManagedSessionState::Dormant
17496 && current.active_session.is_none()
17497 && current.record_id == expected.record_id
17498 && current.display_name == expected.display_name
17499 && current.provider == expected.provider
17500 && current.mode == expected.mode
17501 && current.workspace_id == expected.workspace_id
17502 && current.canonical_root == expected.canonical_root
17503 && current.provider_session == expected.provider_session
17504 && current.environment_profile == expected.environment_profile
17505 && current.bundle == expected.bundle
17506 && current.context_id == expected.context_id
17507 && current.context == expected.context
17508 && current.exported_context == expected.exported_context
17509 && current.task_binding == expected.task_binding
17510 && current.created_at_unix_ms == expected.created_at_unix_ms
17511 && current.last_error == expected.last_error
17512}
17513
17514fn session_record_context_export_binding_is_exact(
17515 expected: &ManagedSessionRecord,
17516 current: &ManagedSessionRecord,
17517 identity: Option<&ProviderSessionIdentity>,
17518 session: &SessionAddress,
17519 binding: &SessionBinding,
17520 provider: &AgentId,
17521 current_root: &str,
17522 allow_clean_detachment: bool,
17523) -> bool {
17524 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17525 && current.provider_session.as_ref() == identity
17526 && current.workspace_id == session.workspace_id
17527 && provider == ¤t.provider
17528 && binding.workspace_id == session.workspace_id
17529 && binding.generation == session.session.generation
17530 && binding.record_id.as_ref() == Some(¤t.record_id)
17531 && platform::roots_equal(
17532 current_root,
17533 windows_path_text(¤t.canonical_root),
17534 )
17535}
17536
17537fn session_record_context_export_source_is_usable(
17538 record: &ManagedSessionRecord,
17539 status: &SessionStatus,
17540 allow_clean_detachment: bool,
17541) -> bool {
17542 (record.state == ManagedSessionState::Live
17543 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17544 && context_pack_source_status_is_usable(status)
17545}
17546
17547fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17548 PromptFraming::BracketedPaste
17549}
17550
17551fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17552 if text.len() > MAX_NODE_TEXT_BYTES {
17553 return Err(failure(
17554 NodeFailureCode::InvalidRequest,
17555 &format!(
17556 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17557 text.len(),
17558 ),
17559 ));
17560 }
17561 Ok(())
17562}
17563
17564fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17565 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17566 return Err(failure(
17567 NodeFailureCode::InvalidRequest,
17568 &format!(
17569 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17570 bytes.len(),
17571 ),
17572 ));
17573 }
17574 Ok(())
17575}
17576
17577fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17578 validate_terminal_bytes(&bytes)?;
17579 Ok(InputAction::TerminalBytes(bytes))
17580}
17581
17582fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17583 if root.is_empty()
17584 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17585 || root.chars().any(char::is_control)
17586 {
17587 return Err(failure(
17588 NodeFailureCode::InvalidWorkspaceRoot,
17589 &format!(
17590 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17591 ),
17592 ));
17593 }
17594 Ok(())
17595}
17596
17597fn canonical_standalone_workspace_candidate(
17598 workspace_id: &WorkspaceId,
17599 root: &str,
17600) -> Result<String, NodeServerError> {
17601 let path = Path::new(root);
17602 match std::fs::symlink_metadata(path) {
17603 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17604 .map(|workspace| workspace.canonical_root),
17605 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17606 if !path.is_absolute() {
17607 return Err(NodeServerError::InvalidWorkspaceRoot {
17608 workspace_id: workspace_id.clone(),
17609 path: root.to_owned(),
17610 message: "path is not absolute".to_owned(),
17611 });
17612 }
17613 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17614 workspace_id: workspace_id.clone(),
17615 path: root.to_owned(),
17616 message: "path has no standalone directory name".to_owned(),
17617 })?;
17618 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17619 workspace_id: workspace_id.clone(),
17620 path: root.to_owned(),
17621 message: "path has no parent directory".to_owned(),
17622 })?;
17623 let canonical = std::fs::canonicalize(parent)
17624 .map(|parent| parent.join(name))
17625 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17626 workspace_id: workspace_id.clone(),
17627 path: root.to_owned(),
17628 message: error.to_string(),
17629 })?;
17630 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17631 NodeServerError::InvalidWorkspaceRoot {
17632 workspace_id: workspace_id.clone(),
17633 path: path.to_string_lossy().into_owned(),
17634 message: "canonical path is not valid Unicode".to_owned(),
17635 }
17636 })?;
17637 let canonical_root = platform::normalize_canonical_root(canonical_root);
17638 validate_workspace_root(workspace_id, &canonical_root)?;
17639 Ok(canonical_root)
17640 }
17641 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17642 workspace_id: workspace_id.clone(),
17643 path: root.to_owned(),
17644 message: error.to_string(),
17645 }),
17646 }
17647}
17648
17649fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17650 let Some(revision) = revision else {
17651 return Ok(());
17652 };
17653 if revision.is_empty()
17654 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17655 || revision.chars().any(char::is_control)
17656 {
17657 return Err(failure(
17658 NodeFailureCode::InvalidRequest,
17659 &format!(
17660 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17661 ),
17662 ));
17663 }
17664 Ok(())
17665}
17666
17667fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17668 let code = match error.kind {
17669 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17670 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17671 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17672 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17673 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17674 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17675 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17676 };
17677 failure(code, &error.message)
17678}
17679
17680fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17681 let code = match error.kind {
17682 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17683 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17684 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17685 NodeFailureCode::BackendOperationFailed
17686 }
17687 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17688 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17689 }
17690 };
17691 failure(code, error.message)
17692}
17693
17694fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17695 let (code, message) = match error.kind {
17696 GitWorktreeErrorKind::Invalid => (
17697 NodeFailureCode::InvalidRequest,
17698 "managed Git input is invalid",
17699 ),
17700 GitWorktreeErrorKind::NotRepository => (
17701 NodeFailureCode::NotGitRepository,
17702 "managed source is not a Git repository",
17703 ),
17704 GitWorktreeErrorKind::Conflict => (
17705 NodeFailureCode::WorktreeConflict,
17706 "managed Git identity conflicts with existing repository state",
17707 ),
17708 GitWorktreeErrorKind::Protected => (
17709 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17710 "managed worktree ownership validation failed",
17711 ),
17712 GitWorktreeErrorKind::Dirty => (
17713 NodeFailureCode::WorktreeDirty,
17714 "managed worktree contains uncommitted changes",
17715 ),
17716 GitWorktreeErrorKind::Locked => (
17717 NodeFailureCode::WorktreeLocked,
17718 "managed worktree is locked",
17719 ),
17720 GitWorktreeErrorKind::Failed => (
17721 NodeFailureCode::BackendOperationFailed,
17722 "managed Git operation failed",
17723 ),
17724 };
17725 NodeFailure { code, message: message.to_owned() }
17726}
17727
17728fn managed_spawn_receipt(
17729 resolved: &ResolvedSpawnSpec,
17730 incarnation_id: NodeIncarnationId,
17731 session: SessionAddress,
17732 lease: ManagedWorktreeLeaseSnapshot,
17733 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17734 bundle: Option<ResolvedBundleReceipt>,
17735 context: Option<ResolvedContextPackReceipt>,
17736) -> ManagedWorktreeSpawnReceipt {
17737 let mut managed = resolved.clone();
17738 managed.target.worktree_id = Some(lease.workspace_id.clone());
17739 ManagedWorktreeSpawnReceipt {
17740 spawn: managed.receipt_with_materialization(
17741 incarnation_id,
17742 session,
17743 environment_profile,
17744 bundle,
17745 context,
17746 ),
17747 lease,
17748 }
17749}
17750
17751fn validate_selected_worktree(
17752 source_root: &str,
17753 selected_root: &str,
17754 worktrees: &[NativeGitWorktreeSnapshot],
17755) -> Result<(), NodeFailure> {
17756 if !worktrees
17757 .iter()
17758 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17759 {
17760 return Err(failure(
17761 NodeFailureCode::WorktreeProtected,
17762 "source workspace is not an authoritative non-bare Git worktree root",
17763 ));
17764 }
17765 if !worktrees.iter().any(|worktree| {
17766 worktree_paths_equal(&worktree.path, selected_root)
17767 && !worktree.is_main
17768 && !worktree.is_bare
17769 }) {
17770 return Err(failure(
17771 NodeFailureCode::WorktreeProtected,
17772 "selected workspace is not an eligible Git worktree in the source repository",
17773 ));
17774 }
17775 Ok(())
17776}
17777
17778fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17779 let code = match error.kind() {
17780 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17781 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17782 NodeFailureCode::RepositoryPathUnsafe
17783 }
17784 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17785 WorkspaceFileReadErrorKind::NotRegularFile => {
17786 NodeFailureCode::RepositoryFileNotRegular
17787 }
17788 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17789 NodeFailureCode::RepositoryFileReadFailed
17790 }
17791 WorkspaceFileReadErrorKind::RevisionConflict => {
17792 NodeFailureCode::RepositoryFileRevisionConflict
17793 }
17794 WorkspaceFileReadErrorKind::AlreadyExists
17795 | WorkspaceFileReadErrorKind::ParentNotFound
17796 | WorkspaceFileReadErrorKind::ParentNotDirectory
17797 | WorkspaceFileReadErrorKind::Canceled => {
17798 NodeFailureCode::RepositoryFileReadFailed
17799 }
17800 };
17801 failure(code, "repository file read failed")
17802}
17803
17804fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17805 let code = match error.kind() {
17806 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17807 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17808 NodeFailureCode::RepositoryPathUnsafe
17809 }
17810 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17811 WorkspaceFileReadErrorKind::NotRegularFile => {
17812 NodeFailureCode::RepositoryFileNotRegular
17813 }
17814 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17815 NodeFailureCode::RepositoryFileWriteFailed
17816 }
17817 WorkspaceFileReadErrorKind::RevisionConflict => {
17818 NodeFailureCode::RepositoryFileRevisionConflict
17819 }
17820 WorkspaceFileReadErrorKind::AlreadyExists
17821 | WorkspaceFileReadErrorKind::ParentNotFound
17822 | WorkspaceFileReadErrorKind::ParentNotDirectory
17823 | WorkspaceFileReadErrorKind::Canceled => {
17824 NodeFailureCode::RepositoryFileWriteFailed
17825 }
17826 };
17827 failure(code, "repository file write failed")
17828}
17829
17830async fn settle_workspace_entry_create<T>(
17831 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17832 commit_state: Arc<AtomicU8>,
17833 deadline: Duration,
17834 timeout_message: &'static str,
17835 task_failure_message: &'static str,
17836) -> Result<T, NodeFailure> {
17837 let joined = match timeout(deadline, &mut task).await {
17838 Ok(joined) => joined,
17839 Err(_) => {
17840 if commit_state
17841 .compare_exchange(
17842 WORKSPACE_ENTRY_CREATE_PENDING,
17843 WORKSPACE_ENTRY_CREATE_CANCELED,
17844 Ordering::AcqRel,
17845 Ordering::Acquire,
17846 )
17847 .is_ok()
17848 {
17849 return Err(failure(
17850 NodeFailureCode::RepositoryEntryCreateTimedOut,
17851 timeout_message,
17852 ));
17853 }
17854 debug_assert_eq!(
17855 commit_state.load(Ordering::Acquire),
17856 WORKSPACE_ENTRY_CREATE_COMMITTING,
17857 );
17858 task.await
17859 }
17860 };
17861 joined
17862 .map_err(|_| {
17863 failure(
17864 NodeFailureCode::RepositoryEntryCreateFailed,
17865 task_failure_message,
17866 )
17867 })?
17868 .map_err(workspace_entry_create_failure)
17869}
17870
17871fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17872 let code = match error.kind() {
17873 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17874 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17875 NodeFailureCode::RepositoryPathUnsafe
17876 }
17877 WorkspaceFileReadErrorKind::AlreadyExists => {
17878 NodeFailureCode::RepositoryEntryAlreadyExists
17879 }
17880 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17881 NodeFailureCode::RepositoryParentNotFound
17882 }
17883 WorkspaceFileReadErrorKind::ParentNotDirectory
17884 | WorkspaceFileReadErrorKind::NotRegularFile => {
17885 NodeFailureCode::RepositoryParentNotDirectory
17886 }
17887 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17888 NodeFailureCode::RepositoryEntryCreateFailed
17889 }
17890 WorkspaceFileReadErrorKind::RevisionConflict => {
17891 NodeFailureCode::RepositoryEntryCreateFailed
17892 }
17893 WorkspaceFileReadErrorKind::Canceled => {
17894 NodeFailureCode::RepositoryEntryCreateTimedOut
17895 }
17896 };
17897 failure(code, "repository entry create failed")
17898}
17899
17900fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17901 let value = digest(&SHA256, bytes)
17902 .as_ref()
17903 .iter()
17904 .map(|byte| format!("{byte:02x}"))
17905 .collect();
17906 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17907}
17908
17909fn managed_spawn_request_digest_v2(
17910 request: &ManagedWorktreeSpawnRequestV2,
17911) -> Result<String, NodeFailure> {
17912 let encoded = serde_json::to_vec(request).map_err(|_| {
17913 failure(
17914 NodeFailureCode::InvalidRequest,
17915 "managed worktree V2 request could not be canonicalized",
17916 )
17917 })?;
17918 let mut value = String::with_capacity(71);
17919 value.push_str("sha256:");
17920 for byte in digest(&SHA256, &encoded).as_ref() {
17921 use std::fmt::Write as _;
17922 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17923 }
17924 Ok(value)
17925}
17926
17927fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17928 let code = match error {
17929 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17930 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17931 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17932 NativeSessionPreviewError::UnsupportedProvider
17933 | NativeSessionPreviewError::PreviewUnavailable
17934 | NativeSessionPreviewError::SessionNotFound
17935 | NativeSessionPreviewError::AmbiguousSession => {
17936 NodeFailureCode::BackendOperationFailed
17937 }
17938 };
17939 failure(code, "native session preview failed")
17940}
17941
17942fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17943 let code = match error.kind() {
17944 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17945 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17946 };
17947 failure(code, "host directory browse failed")
17948}
17949
17950fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17951 let code = match error {
17952 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17953 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17954 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17955 NodeFailureCode::DeliveryStageConflict
17956 }
17957 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17958 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17959 NodeFailureCode::DeliveryChunkOutOfOrder
17960 }
17961 DeliveryStoreError::BlobDigestMismatch => {
17962 NodeFailureCode::DeliveryBlobDigestMismatch
17963 }
17964 DeliveryStoreError::BundleDigestMismatch => {
17965 NodeFailureCode::DeliveryBundleDigestMismatch
17966 }
17967 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17968 DeliveryStoreError::Unavailable
17969 | DeliveryStoreError::Corrupt
17970 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17971 };
17972 failure(code, "delivery operation failed")
17973}
17974
17975fn validate_workspace_root(
17976 workspace_id: &WorkspaceId,
17977 canonical_root: &str,
17978) -> Result<(), NodeServerError> {
17979 #[cfg(windows)]
17980 if canonical_root.starts_with(r"\\") {
17981 return Err(NodeServerError::InvalidWorkspaceRoot {
17982 workspace_id: workspace_id.clone(),
17983 path: canonical_root.to_owned(),
17984 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17985 });
17986 }
17987 if !platform::workspace_root_supported(canonical_root)
17988 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17989 {
17990 return Err(NodeServerError::InvalidWorkspaceRoot {
17991 workspace_id: workspace_id.clone(),
17992 path: canonical_root.to_owned(),
17993 message: "canonical path must be absolute, bounded, and free of control characters"
17994 .to_owned(),
17995 });
17996 }
17997 Ok(())
17998}
17999
18000fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18001 NodeFailure {
18002 code,
18003 message: node_failure_category(code).to_owned(),
18004 }
18005}
18006
18007fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18008 let code = match error {
18009 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18010 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18011 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18012 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18013 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18014 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18015 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18016 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18017 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18018 };
18019 failure(code, "harness MCP proxy operation failed")
18020}
18021
18022static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18026
18027fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18034 let trace = launch.trace.as_ref()?;
18035 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18036 if trace_dir.is_empty() {
18037 return None;
18038 }
18039 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18040 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18041 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18042}
18043
18044fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18045 let remaining = deadline.saturating_duration_since(Instant::now());
18046 if remaining.is_zero() {
18047 Err(failure(
18048 NodeFailureCode::SpawnDeadlineExceeded,
18049 "spawn deadline elapsed",
18050 ))
18051 } else {
18052 Ok(remaining)
18053 }
18054}
18055
18056fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18057 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18058 match deadline {
18059 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18060 None => Ok(bounded),
18061 }
18062}
18063
18064fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18065 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18066 failure(
18067 NodeFailureCode::SpawnDeadlineExceeded,
18068 "spawn dispatch exceeded the spawn deadline",
18069 )
18070 } else {
18071 error
18072 }
18073}
18074
18075fn spawn_runtime_capabilities(
18076 capabilities: &SpawnRequiredCapabilities,
18077) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18078 capabilities
18079 .iter()
18080 .map(|capability| match capability.as_str() {
18081 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18082 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18083 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18084 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18085 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18086 }
18087 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18088 _ => Err(failure(
18089 NodeFailureCode::UnsupportedSpawnCapability,
18090 "spawn required capability is unknown",
18091 )),
18092 })
18093 .collect()
18094}
18095
18096fn persistence_failure(_error: io::Error) -> NodeFailure {
18097 NodeFailure {
18098 code: NodeFailureCode::BackendOperationFailed,
18099 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18100 }
18101}
18102
18103fn node_failure_category(code: NodeFailureCode) -> &'static str {
18104 match code {
18105 NodeFailureCode::InvalidRequest => "invalid-request",
18106 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18107 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18108 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18109 NodeFailureCode::ReservationConflict => "reservation-conflict",
18110 NodeFailureCode::ReservationExpired => "reservation-expired",
18111 NodeFailureCode::BindingMismatch => "binding-mismatch",
18112 NodeFailureCode::NotActivated => "not-activated",
18113 NodeFailureCode::CallNotFound => "call-not-found",
18114 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18115 NodeFailureCode::ResponseTooLarge => "response-too-large",
18116 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18117 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18118 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18119 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18120 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18121 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18122 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18123 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18124 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18125 NodeFailureCode::Unauthorized => "unauthorized",
18126 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18127 NodeFailureCode::ControllerBusy => "controller-busy",
18128 NodeFailureCode::ControllerRequired => "controller-required",
18129 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18130 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18131 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18132 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18133 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18134 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18135 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18136 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18137 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18138 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18139 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18140 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18141 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18142 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18143 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18144 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18145 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18146 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18147 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18148 NodeFailureCode::GitReadFailed => "git-read-failed",
18149 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18150 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18151 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18152 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18153 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18154 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18155 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18156 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18157 NodeFailureCode::TurnInFlight => "turn-in-flight",
18158 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18159 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18160 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18161 "unsupported-network-allowlist-mapping"
18162 }
18163 NodeFailureCode::BrowserStationProbeUnavailable => {
18164 "browser-station-probe-unavailable"
18165 }
18166 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18167 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18168 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18169 "environment-profile-binding-mismatch"
18170 }
18171 NodeFailureCode::UnknownBundle => "unknown-bundle",
18172 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18173 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18174 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18175 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18176 NodeFailureCode::ContextPackMaterializationFailed => {
18177 "context-pack-materialization-failed"
18178 }
18179 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18180 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18181 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18182 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18183 NodeFailureCode::LastWorkspace => "last-workspace",
18184 NodeFailureCode::NotGitRepository => "not-git-repository",
18185 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18186 NodeFailureCode::WorktreeProtected => "worktree-protected",
18187 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18188 NodeFailureCode::WorktreeLocked => "worktree-locked",
18189 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18190 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18191 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18192 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18193 "managed-worktree-profile-revision-mismatch"
18194 }
18195 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18196 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18197 "standalone-workspace-recovery-required"
18198 }
18199 NodeFailureCode::UnknownSession => "unknown-session",
18200 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18201 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18202 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18203 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18204 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18205 NodeFailureCode::WorkspaceRegistrationRequired => {
18206 "workspace-registration-required"
18207 }
18208 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18209 NodeFailureCode::StaleGeneration => "stale-generation",
18210 NodeFailureCode::BackendBusy => "backend-busy",
18211 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18212 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18213 NodeFailureCode::ShuttingDown => "shutting-down",
18214 }
18215}
18216
18217fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18218 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18219}
18220
18221fn durable_state_load_error(error: io::Error) -> NodeServerError {
18222 let category = match session_registry::state_load_refusal(&error) {
18223 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18224 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18225 }
18226 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18227 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18228 }
18229 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18230 DURABLE_STATE_CONFLICT_ERROR
18231 }
18232 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18233 };
18234 durable_state_server_error(error, category)
18235}
18236
18237fn unix_time_ms() -> u64 {
18238 std::time::SystemTime::now()
18239 .duration_since(std::time::UNIX_EPOCH)
18240 .unwrap_or_default()
18241 .as_millis()
18242 .min(u64::MAX as u128) as u64
18243}
18244
18245fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18246 if record.provider_session.is_some() {
18247 ManagedSessionState::Dormant
18248 } else {
18249 ManagedSessionState::Unavailable
18250 }
18251}
18252
18253fn active_record_state(
18254 identity_admitted: bool,
18255 provider_session_is_known: bool,
18256) -> ManagedSessionState {
18257 if identity_admitted && !provider_session_is_known {
18258 ManagedSessionState::IdentityPending
18259 } else {
18260 ManagedSessionState::Live
18261 }
18262}
18263
18264fn managed_worktree_record_holders(
18265 records: &[ManagedSessionRecord],
18266) -> Vec<(SessionRecordId, WorkspaceId)> {
18267 records
18268 .iter()
18269 .filter(|record| record.provider_session.is_some())
18270 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18271 .collect()
18272}
18273
18274fn managed_spawn_record_ownership(
18275 binding: &SessionBinding,
18276 effective_runtime_policy: ProviderRuntimePolicy,
18277) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18278 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18279 if effective_runtime_policy.provider_session_identity {
18280 (binding.record_id.clone(), None)
18281 } else {
18282 (None, binding.record_id.clone())
18283 }
18284}
18285
18286#[derive(Debug, Error)]
18287pub enum NodeServerError {
18288 #[error("node endpoint must be a bounded absolute local transport path")]
18289 InvalidEndpoint,
18290 #[error("node access token must contain 1..=4096 bytes")]
18291 InvalidAccessToken,
18292 #[error("node HTTP observer must listen on a loopback address: {0}")]
18293 InvalidApiListen(std::net::SocketAddr),
18294 #[error("node browser bridge must listen on a loopback address: {0}")]
18295 InvalidBridgeListen(std::net::SocketAddr),
18296 #[error("node browser bridge token must contain 1..=4096 bytes")]
18297 InvalidBridgeToken,
18298 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18299 BridgeUnderlayRequiresBridgeListen,
18300 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18301 BridgeUnderlayRequiresBridgeToken,
18302 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18303 InvalidBridgeUnderlayToken,
18304 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18305 InvalidBridgeUnderlayKey,
18306 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18307 InvalidCallHome(std::net::SocketAddr),
18308 #[error("node could not reach its call-home relay: {0}")]
18309 CallHomeTransport(#[source] std::io::Error),
18310 #[error("node durable state path must be an absolute file path: {0}")]
18311 InvalidStatePath(String),
18312 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18313 InvalidHarnessMcpHelper,
18314 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18315 InvalidNetworkAllowlistCatalogPath(String),
18316 #[error("network allowlist catalog could not be read: {0}")]
18317 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18318 #[error("network allowlist catalog line {line}: {message}")]
18319 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18320 #[error("network allowlist catalog: {0}")]
18321 InvalidNetworkAllowlistCatalog(String),
18322 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18323 NetworkAllowlistCatalogCapacity { max: usize },
18324 #[error("the local state directory is unavailable; supply an explicit state path")]
18325 LocalStateDirectoryUnavailable,
18326 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18327 LocalRuntimeDirectoryUnavailable,
18328 #[error("node durable state failed: {0}")]
18329 DurableState(io::Error),
18330 #[error("node requires at least one configured workspace")]
18331 NoWorkspaces,
18332 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18333 InvalidWorkspaceRoot {
18334 workspace_id: WorkspaceId,
18335 path: String,
18336 message: String,
18337 },
18338 #[error("duplicate workspace ID: {0}")]
18339 DuplicateWorkspaceId(WorkspaceId),
18340 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18341 DuplicateWorkspaceRoot {
18342 first: WorkspaceId,
18343 second: WorkspaceId,
18344 root: String,
18345 },
18346 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18347 ManagedWorktreeProfileRequired(WorkspaceId),
18348 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18349 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18350 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18351 DuplicateManagedWorktreeProfile {
18352 workspace_id: WorkspaceId,
18353 profile_id: WorktreeProfileId,
18354 },
18355 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18356 ManagedWorktreeProfileCapacity {
18357 workspace_id: WorkspaceId,
18358 max: usize,
18359 },
18360 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18361 InvalidManagedWorktreeProfile {
18362 workspace_id: WorkspaceId,
18363 profile_id: WorktreeProfileId,
18364 message: String,
18365 },
18366 #[error("duplicate node environment profile: {0}")]
18367 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18368 #[error("node environment profile provider is unavailable: {0}")]
18369 EnvironmentProfileProviderUnavailable(AgentId),
18370 #[error("node environment profile capacity is {max}")]
18371 EnvironmentProfileCapacity { max: usize },
18372 #[error("node environment profiles reuse native profile ID '{0}'")]
18373 DuplicateNativeEnvironmentProfile(String),
18374 #[error("node native environment profile is invalid: {0}")]
18375 NativeEnvironmentProfile(String),
18376 #[error("node session-environment materialization root must be absolute")]
18377 InvalidSessionEnvironmentRoot,
18378 #[error("node session-environment materializer is unavailable")]
18379 SessionEnvironmentMaterializerRequired,
18380 #[error("node session-environment materializer failed to initialize")]
18381 SessionEnvironmentMaterializer,
18382 #[error("node bundle catalog is invalid: {0}")]
18383 BundleCatalog(String),
18384 #[error("node delivery store failed to initialize: {0}")]
18385 DeliveryStore(String),
18386 #[error("node context pack store failed to initialize")]
18387 ContextPackStore,
18388 #[error("active agent registry failed: {0}")]
18389 Registry(String),
18390 #[error("node provider contract manifest is invalid: {0}")]
18391 ProviderContractManifest(String),
18392 #[error("named pipe I/O failed: {0}")]
18393 Io(#[from] io::Error),
18394 #[error("node HTTP observer failed: {0}")]
18395 HttpApi(io::Error),
18396 #[error("node browser bridge failed: {0}")]
18397 BridgeApi(io::Error),
18398 #[error(transparent)]
18399 Frame(#[from] FrameError),
18400 #[error("node handshake failed: {0}")]
18401 Handshake(String),
18402 #[error("node authentication frame was not received before the bounded deadline")]
18403 AuthenticationTimedOut,
18404 #[error("node authentication primitive failed: {0}")]
18405 Authentication(String),
18406 #[error("node incarnation identity generation failed: {0}")]
18407 IncarnationIdentity(String),
18408 #[error("node pre-authentication limiter is closed")]
18409 PreauthClosed,
18410 #[error("node authenticated connection limit was reached")]
18411 AuthenticatedConnectionLimit,
18412 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18413 SignalStreamClosed,
18414 #[error("node stop-all dispatch failed: {0:?}")]
18415 ShutdownDispatch(NodeFailure),
18416 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18417 ShutdownTimedOut { active_native_sessions: usize },
18418}
18419
18420impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18421 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18422 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18423 match error {
18424 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18425 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18426 E::InvalidEntry { line, message } => {
18427 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18428 }
18429 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18430 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18431 }
18432 }
18433}
18434
18435#[cfg(test)]
18436mod agent_stream_blocked_tests {
18437 use super::*;
18438 use gate4agent_types::{
18439 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18440 ProviderSource,
18441 };
18442
18443 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18444 ProviderSource {
18445 family,
18446 binding: AdapterBinding::new(
18447 AdapterId::new(adapter).unwrap(),
18448 "fixture/v1",
18449 AdapterVerification::SyntheticFixture,
18450 )
18451 .unwrap(),
18452 }
18453 }
18454
18455 fn provider_control_event_at(
18456 family: AdapterFamily,
18457 adapter: &str,
18458 source_sequence: u64,
18459 provider_event: ProviderEvent,
18460 ) -> ControlEvent {
18461 ControlEvent {
18462 sequence: 41,
18463 command_id: None,
18464 instance_id: AgentInstanceId(7),
18465 generation: SessionGeneration(3),
18466 event: ControlEventKind::ProviderEvent {
18467 sequence: 8,
18468 source: provider_source(family, adapter),
18469 source_sequence,
18470 event: provider_event,
18471 },
18472 }
18473 }
18474
18475 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18476 provider_control_event_at(family, "fixture", 9, provider_event)
18477 }
18478
18479 fn denied_host_request(
18480 by: ProviderHostDecisionAuthority,
18481 reason: Option<String>,
18482 ) -> ControlEvent {
18483 provider_control_event_at(
18484 AdapterFamily::Acp,
18485 "claude-code",
18486 2,
18487 ProviderEvent::HostRequestObserved {
18488 method: "terminal/create".to_owned(),
18489 params_json: String::new(),
18490 decision: ProviderHostRequestDecision::Denied { by },
18491 outcome: ProviderHostRequestOutcome::Executed,
18492 reason,
18493 },
18494 )
18495 }
18496
18497 fn blocked(
18498 event: &ControlEvent,
18499 ) -> (
18500 Option<String>,
18501 String,
18502 BlockAuthorityV1,
18503 Option<String>,
18504 String,
18505 Option<String>,
18506 ) {
18507 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18508 chunk.validate().expect("minted chunk must validate");
18509 let AgentStreamChunkKindV1::Blocked {
18510 correlation_id,
18511 tool_class,
18512 authority,
18513 reason_kind,
18514 reason,
18515 help,
18516 } = chunk.kind
18517 else {
18518 panic!("expected AgentStreamChunkKindV1::Blocked");
18519 };
18520 (correlation_id, tool_class, authority, reason_kind, reason, help)
18521 }
18522
18523 #[test]
18527 fn the_enabled_provider_matrix_is_exact() {
18528 let registry = active_registry().unwrap();
18529 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18530 assert_eq!(
18531 providers
18532 .iter()
18533 .map(|contract| contract.provider.as_str())
18534 .collect::<Vec<_>>(),
18535 vec!["shell", "claude", "codex", "grok", "kimi"],
18537 );
18538 assert!(adapters.iter().all(|contract| {
18539 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18540 }));
18541 }
18542
18543 #[test]
18546 fn gate_denied_host_request_mints_a_blocked_chunk() {
18547 let gate_text =
18548 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18549 let event = denied_host_request(
18550 ProviderHostDecisionAuthority::Gate,
18551 Some(gate_text.to_owned()),
18552 );
18553 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18554 assert_eq!(chunk.source_sequence, 2);
18555 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18556 assert_eq!(authority, BlockAuthorityV1::HostGate);
18557 assert_eq!(reason, gate_text);
18558 assert_eq!(correlation_id, None);
18559 assert_eq!(reason_kind, None);
18560 assert_eq!(help, None);
18561 assert_eq!(tool_class, "Write");
18562 }
18563
18564 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.";
18565
18566 #[test]
18572 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18573 let event = provider_control_event(
18574 AdapterFamily::Acp,
18575 ProviderEvent::ToolCompleted {
18576 id: "toolu_9".to_owned(),
18577 output: CLASSIFIER_SAMPLE.to_owned(),
18578 is_error: true,
18579 duration_ms: None,
18580 agent_id: None,
18581 non_execution_kind: Some("permission-rule".to_owned()),
18582 },
18583 );
18584 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18585 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18586 assert_eq!(reason, "Blocked by classifier");
18587 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18588 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18589 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18590 }
18591
18592 #[test]
18597 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18598 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.";
18599 let event = provider_control_event(
18600 AdapterFamily::Acp,
18601 ProviderEvent::SessionEnded {
18602 result: "JSON-RPC error -32603: Internal error".to_owned(),
18603 cost_usd: None,
18604 is_error: true,
18605 stop_reason: Some(ProviderStopReason::ProviderError {
18606 code: -32603,
18607 message: message.to_owned(),
18608 vendor_code: Some("usageLimitExceeded".to_owned()),
18609 }),
18610 },
18611 );
18612 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18613 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18614 assert_eq!(reason, message);
18615 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18616 assert_eq!(tool_class, "Session");
18617 }
18618
18619 #[test]
18622 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18623 let event = provider_control_event(
18624 AdapterFamily::Acp,
18625 ProviderEvent::SessionEnded {
18626 result: "refusal".to_owned(),
18627 cost_usd: None,
18628 is_error: true,
18629 stop_reason: Some(ProviderStopReason::Refusal),
18630 },
18631 );
18632 let (_, _, authority, _, _, _) = blocked(&event);
18633 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18634 }
18635
18636 #[test]
18640 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18641 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18642 let event = provider_control_event(
18643 AdapterFamily::Acp,
18644 ProviderEvent::ToolCompleted {
18645 id: "toolu_1".to_owned(),
18646 output: "command exited with status 1".to_owned(),
18647 is_error: true,
18648 duration_ms: Some(12),
18649 agent_id: None,
18650 non_execution_kind,
18651 },
18652 );
18653 assert!(agent_stream_chunk(&event).is_none());
18654 }
18655 let narrative = provider_control_event(
18656 AdapterFamily::Acp,
18657 ProviderEvent::ToolCompleted {
18658 id: "toolu_2".to_owned(),
18659 output: "the harness blocked the write, approval needed".to_owned(),
18660 is_error: false,
18661 duration_ms: Some(3),
18662 agent_id: None,
18663 non_execution_kind: None,
18664 },
18665 );
18666 assert!(agent_stream_chunk(&narrative).is_none());
18667 }
18668
18669 #[test]
18673 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18674 for (decision, outcome) in [
18675 (
18676 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18677 ProviderHostRequestOutcome::Executed,
18678 ),
18679 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18680 (
18681 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18682 ProviderHostRequestOutcome::Failed {
18683 error: "terminal/create spawn failed: os error 3".to_owned(),
18684 },
18685 ),
18686 ] {
18687 let event = provider_control_event_at(
18688 AdapterFamily::Acp,
18689 "claude-code",
18690 3,
18691 ProviderEvent::HostRequestObserved {
18692 method: "fs/read_text_file".to_owned(),
18693 params_json: String::new(),
18694 decision: decision.clone(),
18695 outcome,
18696 reason: None,
18697 },
18698 );
18699 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18700 }
18701 }
18702
18703 #[test]
18707 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18708 for (by, expected_authority, expected_reason) in [
18709 (
18710 ProviderHostDecisionAuthority::Operator,
18711 BlockAuthorityV1::Operator,
18712 "declined by the operator",
18713 ),
18714 (
18715 ProviderHostDecisionAuthority::DeadlinePolicy,
18716 BlockAuthorityV1::HostDeadline,
18717 "denied by host policy after the operator deadline expired",
18718 ),
18719 ] {
18720 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18721 assert_eq!(authority, expected_authority);
18722 assert_eq!(reason, expected_reason);
18723 }
18724 }
18725
18726 #[test]
18730 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18731 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18732 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18733 ProviderHostDecisionAuthority::Policy,
18734 Some(oversized),
18735 ));
18736 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18737 }
18738}
18739
18740#[cfg(test)]
18741mod standalone_capability_platform_tests {
18742 use super::*;
18743
18744 #[test]
18745 fn standalone_workspace_capability_requires_windows_identity_guards() {
18746 let capabilities = baseline_capabilities().unwrap();
18747 let advertised = capabilities.iter().any(|capability| {
18748 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18749 });
18750 assert_eq!(advertised, cfg!(windows));
18751
18752 let request = NodeRequest::CreateStandaloneWorkspace {
18753 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18754 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18755 initial_branch: None,
18756 };
18757 assert_eq!(
18758 request_uses_unnegotiated_capability(&request, &capabilities),
18759 !cfg!(windows),
18760 );
18761 }
18762}
18763
18764
18765#[cfg(test)]
18766mod station_knobs_resolve_tests {
18767 use super::*;
18768 use gate4agent_catalog::EnvMutation;
18769 use gate4agent_runtime_native::{
18770 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18771 NativeLaunchProfileId,
18772 };
18773 use std::ffi::OsString;
18774 use std::sync::Arc;
18775
18776 struct EmptyEnvironmentResolver;
18777
18778 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18779 fn resolve_child_environment(
18780 &self,
18781 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18782 Ok(vec![EnvMutation {
18783 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18784 value: None,
18785 }])
18786 }
18787 }
18788
18789 fn agent(value: &str) -> AgentId {
18790 AgentId::new(value).unwrap()
18791 }
18792
18793 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18794 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18795 let profile_revision =
18796 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18797 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18798 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18799 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18800 provider: agent("claude"),
18801 mode: SessionMode::Pty,
18802 terminal_size: gate4agent_types::TerminalSize {
18803 rows: 24,
18804 columns: 80,
18805 },
18806 prompt: None,
18807 bundle_id: None,
18808 context_id: None,
18809 environment_profile_id: Some(profile_id.clone()),
18810 };
18811 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18812 let catalog = active_registry().unwrap();
18813 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18814 let control = runtime.native_launch_profile_control();
18815 let node_profile = NodeEnvironmentProfile::new(
18816 profile_id.clone(),
18817 profile_revision,
18818 agent("claude"),
18819 [NativeLaunchProfile::new(
18820 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18821 agent("claude"),
18822 TransportKind::Pty,
18823 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18824 Arc::new(EmptyEnvironmentResolver),
18825 )
18826 .unwrap()],
18827 )
18828 .unwrap();
18829 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18830 for native_profile in native_profiles {
18831 runtime.upsert_native_launch_profile(native_profile).unwrap();
18832 }
18833 let workspace = WorkspaceConfig::new(
18834 WorkspaceId::new("primary").unwrap(),
18835 std::env::current_dir().unwrap(),
18836 )
18837 .unwrap();
18838 let shared = NodeShared::new_with_incarnation(
18839 handle,
18840 "fixture-token".to_owned(),
18841 NodeId::new("node-station-knobs-test").unwrap(),
18842 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18843 vec![workspace],
18844 vec![agent("claude")],
18845 ProviderRuntimeStatuses::default(),
18846 None,
18847 Vec::new(),
18848 Vec::new(),
18849 spawn_profiles,
18850 Some(control),
18851 None,
18852 None,
18853 None,
18854 Vec::new(),
18855 Vec::new(),
18856 Vec::new(),
18857 Vec::new(),
18858 None,
18859 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18860 );
18861 shared
18862 .environment_profiles
18863 .write()
18864 .unwrap_or_else(|poisoned| poisoned.into_inner())
18865 .insert(binding.id.clone(), binding);
18866 (shared, spawn_defaults)
18867 }
18868
18869 #[test]
18872 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18873 let (shared, defaults) = station_knobs_fixture();
18874 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18875 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18876 let spec = crate::protocol::SpawnSpec {
18877 target: crate::protocol::SpawnTarget {
18878 node_id: shared.node_id.clone(),
18879 workspace_id: WorkspaceId::new("primary").unwrap(),
18880 worktree_id: None,
18881 },
18882 profile_id: defaults.profile_id.clone(),
18883 expected_profile_revision: defaults.revision.clone(),
18884 overrides: crate::protocol::SpawnOverrides {
18885 network_allowlist: Some(unknown),
18886 browser_profile_id: Some(browser),
18887 ..crate::protocol::SpawnOverrides::default()
18888 },
18889 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18890 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18891 "station-knobs-unknown-allowlist",
18892 )
18893 .unwrap(),
18894 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18895 };
18896 let resolved = spec.resolve(&defaults).unwrap();
18897 let failure = shared
18898 .resolve_environment_profile(&resolved)
18899 .expect_err("unknown allowlist must refuse");
18900 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18901 }
18902
18903 #[test]
18909 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18910 let (shared, defaults) = station_knobs_fixture();
18911 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18912 #[cfg(not(feature = "dig2-station-probe"))]
18913 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18914 shared
18915 .network_allowlist_catalog
18916 .write()
18917 .unwrap_or_else(|poisoned| poisoned.into_inner())
18918 .insert_id(allowlist.clone())
18919 .unwrap();
18920 let spec = crate::protocol::SpawnSpec {
18921 target: crate::protocol::SpawnTarget {
18922 node_id: shared.node_id.clone(),
18923 workspace_id: WorkspaceId::new("primary").unwrap(),
18924 worktree_id: None,
18925 },
18926 profile_id: defaults.profile_id.clone(),
18927 expected_profile_revision: defaults.revision.clone(),
18928 overrides: crate::protocol::SpawnOverrides {
18929 network_allowlist: Some(allowlist.clone()),
18930 #[cfg(not(feature = "dig2-station-probe"))]
18931 browser_profile_id: Some(browser.clone()),
18932 ..crate::protocol::SpawnOverrides::default()
18933 },
18934 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18935 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18936 .unwrap(),
18937 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18938 };
18939 let resolved = spec.resolve(&defaults).unwrap();
18940 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18941 #[cfg(not(feature = "dig2-station-probe"))]
18942 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18943 let receipt = shared
18944 .resolve_environment_profile(&resolved)
18945 .expect("resolve ok")
18946 .expect("env profile present");
18947 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18948 #[cfg(not(feature = "dig2-station-probe"))]
18949 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18950 #[cfg(feature = "dig2-station-probe")]
18951 assert_eq!(receipt.browser_profile_id, None);
18952 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18953
18954 let omitted = crate::protocol::SpawnSpec {
18955 overrides: crate::protocol::SpawnOverrides::default(),
18956 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18957 .unwrap(),
18958 ..spec
18959 };
18960 let resolved_omit = omitted.resolve(&defaults).unwrap();
18961 let bare = shared
18962 .resolve_environment_profile(&resolved_omit)
18963 .expect("resolve ok")
18964 .expect("env profile present");
18965 assert_eq!(bare.network_allowlist, None);
18966 assert_eq!(bare.browser_profile_id, None);
18967 }
18968
18969 #[cfg(feature = "dig2-station-probe")]
18974 #[test]
18975 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18976 let (shared, defaults) = station_knobs_fixture();
18977 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18978 let spec = crate::protocol::SpawnSpec {
18979 target: crate::protocol::SpawnTarget {
18980 node_id: shared.node_id.clone(),
18981 workspace_id: WorkspaceId::new("primary").unwrap(),
18982 worktree_id: None,
18983 },
18984 profile_id: defaults.profile_id.clone(),
18985 expected_profile_revision: defaults.revision.clone(),
18986 overrides: crate::protocol::SpawnOverrides {
18987 browser_profile_id: Some(browser),
18988 ..crate::protocol::SpawnOverrides::default()
18989 },
18990 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18991 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18992 "station-knobs-probe-refuse",
18993 )
18994 .unwrap(),
18995 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18996 };
18997 let resolved = spec.resolve(&defaults).unwrap();
18998 let failure = shared
18999 .resolve_environment_profile(&resolved)
19000 .expect_err("browser_profile with failing probe must refuse");
19001 #[cfg(windows)]
19002 {
19003 assert!(
19004 matches!(
19005 failure.code,
19006 NodeFailureCode::BrowserStationUnreachable
19007 | NodeFailureCode::BrowserStationProbeUnavailable
19008 ),
19009 "unexpected code {:?}",
19010 failure.code
19011 );
19012 }
19013 #[cfg(not(windows))]
19014 {
19015 assert_eq!(
19016 failure.code,
19017 NodeFailureCode::BrowserStationProbeUnavailable
19018 );
19019 }
19020 }
19021
19022 #[cfg(feature = "dig2-station-probe")]
19024 #[test]
19025 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19026 let (shared, defaults) = station_knobs_fixture();
19027 let spec = crate::protocol::SpawnSpec {
19028 target: crate::protocol::SpawnTarget {
19029 node_id: shared.node_id.clone(),
19030 workspace_id: WorkspaceId::new("primary").unwrap(),
19031 worktree_id: None,
19032 },
19033 profile_id: defaults.profile_id.clone(),
19034 expected_profile_revision: defaults.revision.clone(),
19035 overrides: crate::protocol::SpawnOverrides::default(),
19036 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19037 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19038 "station-knobs-probe-omit",
19039 )
19040 .unwrap(),
19041 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19042 };
19043 let resolved = spec.resolve(&defaults).unwrap();
19044 let receipt = shared
19045 .resolve_environment_profile(&resolved)
19046 .expect("omit browser_profile must not probe")
19047 .expect("env profile present");
19048 assert_eq!(receipt.browser_profile_id, None);
19049 }
19050
19051 #[cfg(feature = "dig2-station-probe")]
19055 #[test]
19056 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19057 let (shared, _defaults) = station_knobs_fixture();
19058 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19059 let workspace_id = WorkspaceId::new("primary").unwrap();
19060 let address_a = SessionAddress {
19061 workspace_id: workspace_id.clone(),
19062 session: SessionKey {
19063 instance_id: AgentInstanceId(101),
19064 generation: SessionGeneration(1),
19065 },
19066 };
19067 let address_b = SessionAddress {
19068 workspace_id: workspace_id.clone(),
19069 session: SessionKey {
19070 instance_id: AgentInstanceId(102),
19071 generation: SessionGeneration(1),
19072 },
19073 };
19074 let guard_a = crate::dig2_station_lease::acquire_lease(
19075 &shared.browser_station_leases,
19076 profile.clone(),
19077 address_a.session.instance_id,
19078 1,
19079 )
19080 .expect("first lease");
19081 let busy = crate::dig2_station_lease::acquire_lease(
19082 &shared.browser_station_leases,
19083 profile.clone(),
19084 address_b.session.instance_id,
19085 2,
19086 )
19087 .expect_err("second holder must refuse");
19088 assert!(matches!(
19089 busy,
19090 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19091 ));
19092 shared.bind_session_with_policy(
19094 &address_a,
19095 ProviderRuntimePolicy::raw_pty(),
19096 None,
19097 );
19098 guard_a.retain();
19099 assert!(shared.remove_binding(&address_a).is_some());
19100 crate::dig2_station_lease::acquire_lease(
19101 &shared.browser_station_leases,
19102 profile,
19103 address_b.session.instance_id,
19104 3,
19105 )
19106 .expect("reacquire after clear")
19107 .retain();
19108 assert!(
19109 crate::dig2_station_lease::release_lease_for_holder(
19110 &shared.browser_station_leases,
19111 address_b.session.instance_id,
19112 )
19113 );
19114 }
19115
19116 #[cfg(not(feature = "dig2-station-probe"))]
19119 #[test]
19120 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19121 let (shared, defaults) = station_knobs_fixture();
19122 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19123 let spec = crate::protocol::SpawnSpec {
19124 target: crate::protocol::SpawnTarget {
19125 node_id: shared.node_id.clone(),
19126 workspace_id: WorkspaceId::new("primary").unwrap(),
19127 worktree_id: None,
19128 },
19129 profile_id: defaults.profile_id.clone(),
19130 expected_profile_revision: defaults.revision.clone(),
19131 overrides: crate::protocol::SpawnOverrides {
19132 browser_profile_id: Some(browser.clone()),
19133 ..crate::protocol::SpawnOverrides::default()
19134 },
19135 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19136 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19137 .unwrap(),
19138 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19139 };
19140 let resolved = spec.resolve(&defaults).unwrap();
19141 let receipt = shared
19142 .resolve_environment_profile(&resolved)
19143 .expect("stub resolve")
19144 .expect("env profile");
19145 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19146 }
19147
19148
19149 #[test]
19152 fn network_allowlist_catalog_loads_from_temp_file() {
19153 let unique = std::time::SystemTime::now()
19154 .duration_since(std::time::UNIX_EPOCH)
19155 .unwrap()
19156 .as_nanos();
19157 let path = std::env::temp_dir().join(format!(
19158 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19159 std::process::id(),
19160 ));
19161 std::fs::write(
19162 &path,
19163 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19164 )
19165 .unwrap();
19166 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19167 .expect("temp catalog file must load");
19168 let _ = std::fs::remove_file(&path);
19169 assert_eq!(loaded.len(), 2);
19170 let (shared, defaults) = station_knobs_fixture();
19171 {
19172 let mut catalog = shared
19173 .network_allowlist_catalog
19174 .write()
19175 .unwrap_or_else(|poisoned| poisoned.into_inner());
19176 *catalog = loaded;
19177 }
19178 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19179 let spec = crate::protocol::SpawnSpec {
19180 target: crate::protocol::SpawnTarget {
19181 node_id: shared.node_id.clone(),
19182 workspace_id: WorkspaceId::new("primary").unwrap(),
19183 worktree_id: None,
19184 },
19185 profile_id: defaults.profile_id.clone(),
19186 expected_profile_revision: defaults.revision.clone(),
19187 overrides: crate::protocol::SpawnOverrides {
19188 network_allowlist: Some(allowlist.clone()),
19189 ..crate::protocol::SpawnOverrides::default()
19190 },
19191 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19192 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19193 "station-knobs-catalog-file",
19194 )
19195 .unwrap(),
19196 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19197 };
19198 let resolved = spec.resolve(&defaults).unwrap();
19199 let receipt = shared
19200 .resolve_environment_profile(&resolved)
19201 .expect("registered via file load")
19202 .expect("env profile present");
19203 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19204 }
19205
19206 #[test]
19209 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19210 let (shared, _defaults) = station_knobs_fixture();
19211 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19212 shared
19213 .network_allowlist_catalog
19214 .write()
19215 .unwrap_or_else(|poisoned| poisoned.into_inner())
19216 .insert_id(allowlist.clone())
19217 .unwrap();
19218 let snapshot = shared.snapshot();
19219 let inventory = snapshot
19220 .launch_inventory
19221 .expect("launch inventory present");
19222 let ids = inventory
19223 .network_allowlists
19224 .expect("non-empty catalog surfaces ids");
19225 assert_eq!(ids, vec![allowlist]);
19226 }
19227
19228 #[test]
19232 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19233 let (shared, defaults) = station_knobs_fixture();
19234 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19235 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19236 id: allowlist.clone(),
19237 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19238 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19239 peer: "127.0.0.1:443".to_owned(),
19240 }],
19241 provider_native: Some(
19242 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19243 codex_network_access: Some(true),
19244 },
19245 ),
19246 };
19247 shared
19248 .network_allowlist_catalog
19249 .write()
19250 .unwrap_or_else(|poisoned| poisoned.into_inner())
19251 .insert(entry)
19252 .unwrap();
19253 let spec = crate::protocol::SpawnSpec {
19254 target: crate::protocol::SpawnTarget {
19255 node_id: shared.node_id.clone(),
19256 workspace_id: WorkspaceId::new("primary").unwrap(),
19257 worktree_id: None,
19258 },
19259 profile_id: defaults.profile_id.clone(),
19260 expected_profile_revision: defaults.revision.clone(),
19261 overrides: crate::protocol::SpawnOverrides {
19262 network_allowlist: Some(allowlist),
19263 ..crate::protocol::SpawnOverrides::default()
19264 },
19265 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19266 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19267 "station-knobs-unsupported-mapping",
19268 )
19269 .unwrap(),
19270 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19271 };
19272 let resolved = spec.resolve(&defaults).unwrap();
19273 assert_eq!(resolved.provider.as_str(), "claude");
19274 let failure = shared
19275 .resolve_environment_profile(&resolved)
19276 .expect_err("claude + codex_network_access must refuse");
19277 assert_eq!(
19278 failure.code,
19279 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19280 );
19281 }
19282
19283 #[test]
19287 fn resolve_codex_network_access_overlay_moderate_only() {
19288 let (shared, defaults) = station_knobs_fixture();
19289 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19290 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19291 id: allowlist.clone(),
19292 permits: Vec::new(),
19293 provider_native: Some(
19294 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19295 codex_network_access: Some(true),
19296 },
19297 ),
19298 };
19299 shared
19300 .network_allowlist_catalog
19301 .write()
19302 .unwrap_or_else(|poisoned| poisoned.into_inner())
19303 .insert(entry)
19304 .unwrap();
19305
19306 let overlay = shared
19307 .resolve_codex_network_access_overlay_args(
19308 &agent("codex"),
19309 SessionMode::Pty,
19310 ApprovalLevel::Moderate,
19311 Some(&allowlist),
19312 )
19313 .expect("Moderate Codex maps network_access");
19314 assert_eq!(
19315 overlay,
19316 vec![
19317 std::ffi::OsString::from("-c"),
19318 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19319 ]
19320 );
19321
19322 let acp = shared
19323 .resolve_codex_network_access_overlay_args(
19324 &agent("codex"),
19325 SessionMode::Acp,
19326 ApprovalLevel::Moderate,
19327 Some(&allowlist),
19328 )
19329 .unwrap();
19330 assert_eq!(acp, overlay);
19331
19332 let inline = shared
19333 .resolve_codex_network_access_overlay_args(
19334 &agent("codex"),
19335 SessionMode::Inline,
19336 ApprovalLevel::Moderate,
19337 Some(&allowlist),
19338 )
19339 .unwrap_err();
19340 assert_eq!(
19341 inline.code,
19342 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19343 );
19344
19345 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19346 let spec = crate::protocol::SpawnSpec {
19347 target: crate::protocol::SpawnTarget {
19348 node_id: shared.node_id.clone(),
19349 workspace_id: WorkspaceId::new("primary").unwrap(),
19350 worktree_id: None,
19351 },
19352 profile_id: defaults.profile_id.clone(),
19353 expected_profile_revision: defaults.revision.clone(),
19354 overrides: crate::protocol::SpawnOverrides {
19355 provider: crate::protocol::SpawnOverride::Set {
19356 value: agent("codex"),
19357 },
19358 approval_level: Some(level),
19359 network_allowlist: Some(allowlist.clone()),
19360 ..crate::protocol::SpawnOverrides::default()
19361 },
19362 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19363 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19364 "codex-net-refuse-{}",
19365 match level {
19366 ApprovalLevel::ReadOnly => "ro",
19367 ApprovalLevel::FullAuto => "fa",
19368 _ => "other",
19369 }
19370 ))
19371 .unwrap(),
19372 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19373 };
19374 let resolved = spec.resolve(&defaults).unwrap();
19375 let failure = shared
19376 .resolve_environment_profile(&resolved)
19377 .expect_err("RO/FullAuto must refuse");
19378 assert_eq!(
19379 failure.code,
19380 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19381 );
19382 }
19383 }
19384
19385 #[test]
19389 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19390 let (shared, _defaults) = station_knobs_fixture();
19391 let allowlist =
19392 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19393 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19394 id: allowlist.clone(),
19395 permits: vec![
19396 crate::network_allowlist_catalog::NetworkPermitSketch {
19397 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19398 peer: "github.com:443".to_owned(),
19399 },
19400 crate::network_allowlist_catalog::NetworkPermitSketch {
19401 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19402 peer: "*.npmjs.org".to_owned(),
19403 },
19404 ],
19405 provider_native: None,
19406 };
19407 shared
19408 .network_allowlist_catalog
19409 .write()
19410 .unwrap_or_else(|poisoned| poisoned.into_inner())
19411 .insert(entry)
19412 .unwrap();
19413
19414 let unique = std::time::SystemTime::now()
19415 .duration_since(std::time::UNIX_EPOCH)
19416 .unwrap()
19417 .as_nanos();
19418 let provider_home = std::env::temp_dir().join(format!(
19419 "gate4agent-claude-provider-home-{}-{unique}",
19420 std::process::id(),
19421 ));
19422 std::fs::create_dir_all(&provider_home).unwrap();
19423
19424 let overlay = shared
19425 .resolve_network_allowlist_overlay_args(
19426 &agent("claude"),
19427 SessionMode::Pty,
19428 ApprovalLevel::Moderate,
19429 Some(&allowlist),
19430 Some(provider_home.as_path()),
19431 )
19432 .expect("Claude PTY settings overlay on unix");
19433 let settings_path = provider_home.join(
19434 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19435 );
19436 assert_eq!(
19437 overlay,
19438 vec![
19439 std::ffi::OsString::from("--settings"),
19440 std::ffi::OsString::from(settings_path.as_os_str()),
19441 ]
19442 );
19443 let body = std::fs::read_to_string(&settings_path).unwrap();
19444 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19445 assert_eq!(value["sandbox"]["enabled"], true);
19446 assert_eq!(
19447 value["sandbox"]["network"]["allowedDomains"],
19448 serde_json::json!(["github.com", "*.npmjs.org"])
19449 );
19450 assert!(settings_path.starts_with(&provider_home));
19451
19452 let acp = shared
19453 .resolve_network_allowlist_overlay_args(
19454 &agent("claude"),
19455 SessionMode::Acp,
19456 ApprovalLevel::Moderate,
19457 Some(&allowlist),
19458 Some(provider_home.as_path()),
19459 )
19460 .expect_err("Claude ACP must refuse settings overlay");
19461 assert_eq!(
19462 acp.code,
19463 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19464 );
19465
19466 let no_home = shared
19467 .resolve_network_allowlist_overlay_args(
19468 &agent("claude"),
19469 SessionMode::Pty,
19470 ApprovalLevel::Moderate,
19471 Some(&allowlist),
19472 None,
19473 )
19474 .expect_err("Claude overlay requires ProviderHome");
19475 assert_eq!(
19476 no_home.code,
19477 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19478 );
19479
19480 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19481 shared
19482 .network_allowlist_catalog
19483 .write()
19484 .unwrap_or_else(|poisoned| poisoned.into_inner())
19485 .insert_id(bare.clone())
19486 .unwrap();
19487 let empty = shared
19488 .resolve_network_allowlist_overlay_args(
19489 &agent("claude"),
19490 SessionMode::Pty,
19491 ApprovalLevel::Moderate,
19492 Some(&bare),
19493 Some(provider_home.as_path()),
19494 )
19495 .unwrap();
19496 assert!(empty.is_empty());
19497
19498 let _ = std::fs::remove_dir_all(&provider_home);
19499 }
19500
19501 #[test]
19503 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19504 let (shared, _defaults) = station_knobs_fixture();
19505 let allowlist =
19506 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19507 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19508 id: allowlist.clone(),
19509 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19510 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19511 peer: "example.com:443".to_owned(),
19512 }],
19513 provider_native: None,
19514 };
19515 shared
19516 .network_allowlist_catalog
19517 .write()
19518 .unwrap_or_else(|poisoned| poisoned.into_inner())
19519 .insert(entry)
19520 .unwrap();
19521 for provider in ["kimi", "grok"] {
19522 let overlay = shared
19523 .resolve_network_allowlist_overlay_args(
19524 &agent(provider),
19525 SessionMode::Pty,
19526 ApprovalLevel::Moderate,
19527 Some(&allowlist),
19528 None,
19529 )
19530 .unwrap();
19531 assert!(
19532 overlay.is_empty(),
19533 "{provider} must not invent network argv from permits"
19534 );
19535 }
19536 }
19537
19538 #[test]
19540 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19541 let unique = std::time::SystemTime::now()
19542 .duration_since(std::time::UNIX_EPOCH)
19543 .unwrap()
19544 .as_nanos();
19545 let path = std::env::temp_dir().join(format!(
19546 "gate4agent-node-allowlist-json-{}-{unique}.json",
19547 std::process::id(),
19548 ));
19549 std::fs::write(
19550 &path,
19551 r#"{
19552 "schema_version": 2,
19553 "entries": [
19554 {
19555 "id": "egress-default",
19556 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19557 },
19558 { "id": "lab-egress" }
19559 ]
19560 }"#,
19561 )
19562 .unwrap();
19563 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19564 .expect("json v2 catalog must load");
19565 let _ = std::fs::remove_file(&path);
19566 assert_eq!(loaded.len(), 2);
19567 let entry = loaded
19568 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19569 .unwrap();
19570 assert_eq!(entry.permits.len(), 1);
19571 let (shared, defaults) = station_knobs_fixture();
19572 {
19573 let mut catalog = shared
19574 .network_allowlist_catalog
19575 .write()
19576 .unwrap_or_else(|poisoned| poisoned.into_inner());
19577 *catalog = loaded;
19578 }
19579 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19580 let spec = crate::protocol::SpawnSpec {
19581 target: crate::protocol::SpawnTarget {
19582 node_id: shared.node_id.clone(),
19583 workspace_id: WorkspaceId::new("primary").unwrap(),
19584 worktree_id: None,
19585 },
19586 profile_id: defaults.profile_id.clone(),
19587 expected_profile_revision: defaults.revision.clone(),
19588 overrides: crate::protocol::SpawnOverrides {
19589 network_allowlist: Some(allowlist.clone()),
19590 ..crate::protocol::SpawnOverrides::default()
19591 },
19592 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19593 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19594 "station-knobs-catalog-json",
19595 )
19596 .unwrap(),
19597 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19598 };
19599 let resolved = spec.resolve(&defaults).unwrap();
19600 let receipt = shared
19601 .resolve_environment_profile(&resolved)
19602 .expect("permits-only entry ok for claude")
19603 .expect("env profile present");
19604 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19605 let inventory = shared
19606 .snapshot()
19607 .launch_inventory
19608 .expect("launch inventory present");
19609 let ids = inventory.network_allowlists.expect("ids surface");
19610 assert_eq!(
19611 ids,
19612 vec![
19613 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19614 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19615 ]
19616 );
19617 }
19618
19619
19620}
19621
19622
19623#[cfg(test)]
19624mod workspace_environment_collision_tests {
19625 use super::*;
19626 use crate::session_environment::{
19627 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19628 };
19629 use gate4agent_catalog::EnvMutation;
19630 use gate4agent_runtime_native::{
19631 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19632 NativeLaunchProfileId,
19633 };
19634 use std::ffi::OsString;
19635 use std::sync::Arc;
19636
19637 struct EmptyEnvironmentResolver;
19638
19639 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19640 fn resolve_child_environment(
19641 &self,
19642 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19643 Ok(vec![EnvMutation {
19644 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19645 value: None,
19646 }])
19647 }
19648 }
19649
19650 fn agent(value: &str) -> AgentId {
19651 AgentId::new(value).unwrap()
19652 }
19653
19654 fn claude_config_dir_collision_fixture() -> (
19656 NodeShared,
19657 ResolvedEnvironmentProfileReceipt,
19658 ResolvedEnvironmentProfileReceipt,
19659 ) {
19660 let catalog = active_registry().unwrap();
19661 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19662 let control = runtime.native_launch_profile_control();
19663 let workspace = WorkspaceConfig::new(
19664 WorkspaceId::new("primary").unwrap(),
19665 std::env::current_dir().unwrap(),
19666 )
19667 .unwrap();
19668 let shared = NodeShared::new_with_incarnation(
19669 handle,
19670 "fixture-token".to_owned(),
19671 NodeId::new("node-env-collision-test").unwrap(),
19672 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19673 vec![workspace],
19674 vec![agent("claude")],
19675 ProviderRuntimeStatuses::default(),
19676 None,
19677 Vec::new(),
19678 Vec::new(),
19679 SpawnProfileRegistry::default(),
19680 Some(control),
19681 None,
19682 None,
19683 None,
19684 Vec::new(),
19685 Vec::new(),
19686 Vec::new(),
19687 Vec::new(),
19688 None,
19689 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19690 );
19691
19692 let mut receipts = Vec::new();
19693 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19694 let profile_id =
19695 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19696 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19697 "claude-config-dir-{suffix}-r1"
19698 ))
19699 .unwrap();
19700 let materialization = NodeSessionMaterializationProfile::new(
19701 Vec::new(),
19702 vec![NodeSessionPathBinding::new(
19703 "CLAUDE_CONFIG_DIR",
19704 NodeSessionPathClass::ProviderHome,
19705 )
19706 .unwrap()],
19707 Vec::new(),
19708 )
19709 .unwrap();
19710 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19711 profile_id.clone(),
19712 profile_revision.clone(),
19713 agent("claude"),
19714 [NativeLaunchProfile::new(
19715 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19716 .unwrap(),
19717 agent("claude"),
19718 TransportKind::Pty,
19719 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19720 Arc::new(EmptyEnvironmentResolver),
19721 )
19722 .unwrap()],
19723 Some(materialization),
19724 )
19725 .unwrap();
19726 let (binding, native_profiles, materialization) = node_profile.into_parts();
19727 for native_profile in native_profiles {
19728 runtime.upsert_native_launch_profile(native_profile).unwrap();
19729 }
19730 shared
19731 .environment_profiles
19732 .write()
19733 .unwrap_or_else(|poisoned| poisoned.into_inner())
19734 .insert(binding.id.clone(), binding);
19735 shared
19736 .environment_materialization_profiles
19737 .write()
19738 .unwrap_or_else(|poisoned| poisoned.into_inner())
19739 .insert(profile_id.clone(), materialization.unwrap());
19740 receipts.push(ResolvedEnvironmentProfileReceipt {
19741 profile_id,
19742 profile_revision,
19743 network_allowlist: None,
19744 browser_profile_id: None,
19745 });
19746 }
19747 let profile_b = receipts.pop().unwrap();
19748 let profile_a = receipts.pop().unwrap();
19749 (shared, profile_a, profile_b)
19750 }
19751
19752 #[test]
19753 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19754 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19755 let primary = WorkspaceId::new("primary").unwrap();
19756 let worktree = WorkspaceId::new("worktree-b").unwrap();
19757 let address = SessionAddress {
19758 workspace_id: primary.clone(),
19759 session: SessionKey {
19760 instance_id: AgentInstanceId(41),
19761 generation: SessionGeneration(1),
19762 },
19763 };
19764 shared.bind_session_with_policy(
19765 &address,
19766 ProviderRuntimePolicy::raw_pty(),
19767 Some(profile_a.clone()),
19768 );
19769
19770 let refused = shared
19771 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19772 .unwrap_err();
19773 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19774
19775 let refused_none = shared
19777 .ensure_workspace_environment_profile_compatible(&primary, None)
19778 .unwrap_err();
19779 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19780
19781 shared
19783 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19784 .unwrap();
19785
19786 shared
19788 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19789 .unwrap();
19790 }
19791
19792 #[tokio::test]
19793 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19794 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19795 let primary = WorkspaceId::new("primary").unwrap();
19796 let address = SessionAddress {
19797 workspace_id: primary.clone(),
19798 session: SessionKey {
19799 instance_id: AgentInstanceId(41),
19800 generation: SessionGeneration(1),
19801 },
19802 };
19803 shared.bind_session_with_policy(
19804 &address,
19805 ProviderRuntimePolicy::raw_pty(),
19806 Some(profile_a),
19807 );
19808
19809 let failure = shared
19810 .spawn_session_with_deadline(
19811 primary,
19812 agent("claude"),
19813 SessionMode::Pty,
19814 gate4agent_types::TerminalSize {
19815 rows: 24,
19816 columns: 80,
19817 },
19818 None,
19819 Some(profile_b),
19820 None,
19821 None,
19822 None,
19823 None,
19824 SpawnRecordPolicy::ProviderIdentityOnly,
19825 Some(Instant::now() + Duration::from_secs(30)),
19826 &[],
19827 None,
19828 ApprovalLevel::default(),
19829 None,
19830 None,
19831 )
19832 .await
19833 .unwrap_err();
19834 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19835 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19837 assert!(shared.handle.snapshot().sessions.is_empty());
19838 }
19839}
19840
19841#[cfg(test)]
19842mod spawn_characterization_tests {
19843 use super::*;
19844 use crate::session_environment::{
19845 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19846 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19847 NodeSessionPathClass,
19848 };
19849 use gate4agent_catalog::EnvMutation;
19850 use gate4agent_runtime_native::{
19851 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19852 NativeLaunchProfile, NativeLaunchProfileId,
19853 };
19854 use std::ffi::OsString;
19855 use std::sync::Arc;
19856
19857 struct EmptyEnvironmentResolver;
19858
19859 struct FixtureSecretResolver {
19860 deny: Arc<AtomicBool>,
19861 }
19862
19863 impl NodeSecretResolver for FixtureSecretResolver {
19864 fn resolve(
19865 &self,
19866 _reference: &NodeSecretReference,
19867 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19868 if self.deny.load(Ordering::Acquire) {
19869 Err(NodeSecretResolveError::Denied)
19870 } else {
19871 NodeSecretValue::text("fixture-secret")
19872 .map_err(|_| NodeSecretResolveError::Unavailable)
19873 }
19874 }
19875 }
19876
19877 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19878 fn resolve_child_environment(
19879 &self,
19880 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19881 Ok(vec![EnvMutation {
19882 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19883 value: None,
19884 }])
19885 }
19886 }
19887
19888 fn agent(value: &str) -> AgentId {
19889 AgentId::new(value).unwrap()
19890 }
19891
19892 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19893 let unique = std::time::SystemTime::now()
19894 .duration_since(std::time::UNIX_EPOCH)
19895 .unwrap()
19896 .as_nanos();
19897 std::env::temp_dir().join(format!(
19898 "gate4agent-node-{label}-{}-{unique}",
19899 std::process::id(),
19900 ))
19901 }
19902
19903 fn materialization_test_shared(
19904 deny_secret: bool,
19905 ) -> (
19906 NodeShared,
19907 NativeRuntime,
19908 ResolvedEnvironmentProfileReceipt,
19909 PathBuf,
19910 Arc<AtomicBool>,
19911 ) {
19912 let workspace = WorkspaceConfig::new(
19913 WorkspaceId::new("primary").unwrap(),
19914 std::env::current_dir().unwrap(),
19915 )
19916 .unwrap();
19917 materialization_test_shared_with_workspace(deny_secret, workspace)
19918 }
19919
19920 fn materialization_test_shared_with_workspace(
19921 deny_secret: bool,
19922 workspace: WorkspaceConfig,
19923 ) -> (
19924 NodeShared,
19925 NativeRuntime,
19926 ResolvedEnvironmentProfileReceipt,
19927 PathBuf,
19928 Arc<AtomicBool>,
19929 ) {
19930 let root = temporary_workspace_root(if deny_secret {
19931 "materialization-denied"
19932 } else {
19933 "materialization-ready"
19934 });
19935 std::fs::create_dir_all(&root).unwrap();
19936 let materialization_root = root.join("session-environments");
19937 let deny = Arc::new(AtomicBool::new(deny_secret));
19938 let resolver = Arc::new(FixtureSecretResolver {
19939 deny: Arc::clone(&deny),
19940 });
19941 let materializer = SessionEnvironmentMaterializer::new(
19942 materialization_root,
19943 resolver,
19944 )
19945 .unwrap();
19946 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19947 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19948 "materialized-claude-r1",
19949 )
19950 .unwrap();
19951 let materialization = NodeSessionMaterializationProfile::new(
19952 vec![NodeSessionEnvironmentMutation::SetSecret {
19953 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19954 reference: NodeSecretReference::new("fixture-token").unwrap(),
19955 }],
19956 vec![NodeSessionPathBinding::new(
19957 "GATE4AGENT_PROVIDER_HOME",
19958 NodeSessionPathClass::ProviderHome,
19959 )
19960 .unwrap()],
19961 vec![NodeSessionFile::secret(
19962 NodeSessionPathClass::Config,
19963 "auth/token",
19964 NodeSecretReference::new("fixture-token").unwrap(),
19965 )
19966 .unwrap()],
19967 )
19968 .unwrap();
19969 let catalog = active_registry().unwrap();
19970 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19971 let control = runtime.native_launch_profile_control();
19972 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19973 profile_id.clone(),
19974 profile_revision.clone(),
19975 agent("claude"),
19976 [NativeLaunchProfile::new(
19977 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19978 agent("claude"),
19979 TransportKind::Pty,
19980 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19981 Arc::new(EmptyEnvironmentResolver),
19982 )
19983 .unwrap()],
19984 Some(materialization),
19985 )
19986 .unwrap();
19987 let (binding, native_profiles, materialization) = node_profile.into_parts();
19988 for native_profile in native_profiles {
19989 runtime.upsert_native_launch_profile(native_profile).unwrap();
19990 }
19991 let shared = NodeShared::new_with_incarnation(
19992 handle,
19993 "fixture-token".to_owned(),
19994 NodeId::new("node-materialization-test").unwrap(),
19995 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19996 vec![workspace],
19997 vec![agent("claude")],
19998 ProviderRuntimeStatuses::default(),
19999 None,
20000 Vec::new(),
20001 Vec::new(),
20002 SpawnProfileRegistry::default(),
20003 Some(control),
20004 None,
20005 Some(materializer),
20006 None,
20007 Vec::new(),
20008 Vec::new(),
20009 Vec::new(),
20010 Vec::new(),
20011 None,
20012 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20013 );
20014 shared
20015 .environment_profiles
20016 .write()
20017 .unwrap_or_else(|poisoned| poisoned.into_inner())
20018 .insert(binding.id.clone(), binding);
20019 shared
20020 .environment_materialization_profiles
20021 .write()
20022 .unwrap_or_else(|poisoned| poisoned.into_inner())
20023 .insert(profile_id.clone(), materialization.unwrap());
20024 (
20025 shared,
20026 runtime,
20027 ResolvedEnvironmentProfileReceipt {
20028 profile_id,
20029 profile_revision,
20030 network_allowlist: None,
20031 browser_profile_id: None,
20032 },
20033 root,
20034 deny,
20035 )
20036 }
20037
20038
20039 async fn characterization_spawn(
20042 shared: &NodeShared,
20043 mode: SessionMode,
20044 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20045 required: &[ProviderRuntimeCapability],
20046 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20047 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20048 shared
20049 .spawn_session_with_deadline(
20050 WorkspaceId::new("primary").unwrap(),
20051 agent("claude"),
20052 mode,
20053 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20054 None,
20055 environment_profile,
20056 None,
20057 None,
20058 None,
20059 Some(ProviderRuntimePolicy::raw_pty()),
20060 SpawnRecordPolicy::ProviderIdentityOnly,
20061 Some(Instant::now() + Duration::from_secs(30)),
20062 required,
20063 harness_mcp,
20064 ApprovalLevel::default(),
20065 None,
20066 None,
20067 )
20068 .await
20069 }
20070
20071 fn assert_no_session_state(shared: &NodeShared) {
20072 assert!(shared.handle.snapshot().sessions.is_empty());
20073 assert!(shared
20074 .session_bindings
20075 .lock()
20076 .unwrap_or_else(|poisoned| poisoned.into_inner())
20077 .is_empty());
20078 assert!(shared
20079 .materializations
20080 .lock()
20081 .unwrap_or_else(|poisoned| poisoned.into_inner())
20082 .is_empty());
20083 }
20084
20085 #[tokio::test]
20086 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20087 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20088 let error = characterization_spawn(
20089 &shared,
20090 SessionMode::Pty,
20091 None,
20092 &[ProviderRuntimeCapability::SemanticReadiness],
20093 None,
20094 )
20095 .await
20096 .unwrap_err();
20097 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20098 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20099 assert_no_session_state(&shared);
20100 drop(shared);
20101 drop(runtime);
20102 std::fs::remove_dir_all(root).unwrap();
20103 }
20104
20105 #[tokio::test]
20106 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20107 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20108 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20109 .await
20110 .unwrap_err();
20111 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20112 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20113 assert_no_session_state(&shared);
20114 drop(shared);
20115 drop(runtime);
20116 std::fs::remove_dir_all(root).unwrap();
20117 }
20118
20119 #[test]
20120 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20121 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20122 let address = SessionAddress {
20123 workspace_id: WorkspaceId::new("primary").unwrap(),
20124 session: SessionKey {
20125 instance_id: AgentInstanceId(77),
20126 generation: SessionGeneration(1),
20127 },
20128 };
20129 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20130 let removed = shared.remove_binding(&address).expect("binding removed");
20131 assert!(removed.environment_profile.is_some());
20132 assert!(shared.remove_binding(&address).is_none());
20133 assert_no_session_state(&shared);
20134 drop(shared);
20135 drop(runtime);
20136 std::fs::remove_dir_all(root).unwrap();
20137 }
20138
20139 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20140 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20141 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20142 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20143 .expect("review current test executable as fixture helper");
20144 let registry = HarnessMcpProxyRegistry::new(helper);
20145 let spec = crate::protocol::SpawnSpec {
20146 target: crate::protocol::SpawnTarget {
20147 node_id: NodeId::new("node-characterization").unwrap(),
20148 workspace_id: WorkspaceId::new("primary").unwrap(),
20149 worktree_id: None,
20150 },
20151 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20152 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20153 overrides: crate::protocol::SpawnOverrides::default(),
20154 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20155 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20156 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20157 };
20158 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20159 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20160 let launch = HarnessMcpLaunchV1 {
20161 server_name: "fixture-harness".to_owned(),
20162 args: vec!["--session-proxy".to_owned()],
20163 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20164 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20165 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20166 trace: None,
20167 scrub_env: Vec::new(),
20168 };
20169 let expires = unix_time_ms().saturating_add(30_000);
20170 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20171 registry
20172 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20173 .unwrap()
20174 }
20175
20176 #[tokio::test]
20177 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20178 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20179 let prepared = characterization_prepared_harness("claude").await;
20180 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20181 .await
20182 .unwrap_err();
20183 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20185 let mismatched = characterization_prepared_harness("codex").await;
20186 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20187 .await
20188 .unwrap_err();
20189 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20190 assert_no_session_state(&shared);
20191 drop(shared);
20192 drop(runtime);
20193 std::fs::remove_dir_all(root).unwrap();
20194 }
20195
20196 #[tokio::test]
20197 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20198 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20199 shared.native_launch_profile_control = None;
20200 let prepared = characterization_prepared_harness("claude").await;
20201 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20202 .await
20203 .unwrap_err();
20204 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20205 assert_no_session_state(&shared);
20206 drop(shared);
20207 drop(runtime);
20208 std::fs::remove_dir_all(root).unwrap();
20209 }
20210}
20211
20212#[cfg(all(test, windows))]
20213mod tests {
20214 use super::*;
20215 use gate4agent_types::ProviderActivity;
20216 use crate::session_environment::{
20217 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20218 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20219 NodeSessionPathClass,
20220 };
20221 use gate4agent_catalog::EnvMutation;
20222 use gate4agent_runtime_native::{
20223 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20224 NativeLaunchProfile, NativeLaunchProfileId,
20225 };
20226 use std::ffi::OsString;
20227 use std::sync::Arc;
20228
20229 struct EmptyEnvironmentResolver;
20230
20231 struct FixtureSecretResolver {
20232 deny: Arc<AtomicBool>,
20233 }
20234
20235 impl NodeSecretResolver for FixtureSecretResolver {
20236 fn resolve(
20237 &self,
20238 _reference: &NodeSecretReference,
20239 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20240 if self.deny.load(Ordering::Acquire) {
20241 Err(NodeSecretResolveError::Denied)
20242 } else {
20243 NodeSecretValue::text("fixture-secret")
20244 .map_err(|_| NodeSecretResolveError::Unavailable)
20245 }
20246 }
20247 }
20248
20249 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20250 fn resolve_child_environment(
20251 &self,
20252 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20253 Ok(vec![EnvMutation {
20254 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20255 value: None,
20256 }])
20257 }
20258 }
20259
20260 fn agent(value: &str) -> AgentId {
20261 AgentId::new(value).unwrap()
20262 }
20263
20264 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20265 let catalog = active_registry().unwrap();
20266 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20267 let workspace = WorkspaceConfig::new(
20268 WorkspaceId::new("primary").unwrap(),
20269 std::env::current_dir().unwrap(),
20270 )
20271 .unwrap()
20272 .with_worktree_service_mode(mode);
20273 NodeShared::new(
20274 handle,
20275 "fixture-token".to_owned(),
20276 NodeId::new("node-terminal-test").unwrap(),
20277 vec![workspace],
20278 vec![agent("claude")],
20279 )
20280 }
20281
20282 fn terminal_test_shared() -> NodeShared {
20283 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20284 }
20285
20286 #[cfg(feature = "fixture")]
20287 #[test]
20288 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20289 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20290 assert_eq!(probe.latest_code(), None);
20291
20292 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20293 assert_eq!(
20294 probe.latest_code(),
20295 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20296 );
20297
20298 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20299 assert_eq!(
20300 probe.latest_code(),
20301 Some(NodeFailureCode::SpawnDeadlineExceeded),
20302 );
20303
20304 probe.record(None);
20305 assert_eq!(probe.latest_code(), None);
20306 }
20307
20308 #[test]
20309 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20310 let source = temporary_workspace_root("launch-inventory-source");
20311 let allocation = temporary_workspace_root("launch-inventory-allocation");
20312 std::fs::create_dir_all(&source).unwrap();
20313 std::fs::create_dir_all(&allocation).unwrap();
20314 let workspace_id = WorkspaceId::new("primary").unwrap();
20315 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20316 .unwrap()
20317 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20318 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20319 workspace = workspace
20320 .with_managed_worktree_profile(
20321 ManagedWorktreeProfile::new(
20322 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20323 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20324 &allocation,
20325 "gate4agent",
20326 "HEAD",
20327 ManagedWorktreeRetention::RemoveWhenReleased,
20328 )
20329 .unwrap(),
20330 )
20331 .unwrap();
20332 }
20333 let overflow = workspace.clone().with_managed_worktree_profile(
20334 ManagedWorktreeProfile::new(
20335 WorktreeProfileId::new("overflow").unwrap(),
20336 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20337 &allocation,
20338 "gate4agent",
20339 "HEAD",
20340 ManagedWorktreeRetention::Retain,
20341 )
20342 .unwrap(),
20343 );
20344 assert!(matches!(
20345 overflow,
20346 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20347 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20348 ..
20349 }),
20350 ));
20351
20352 let catalog = active_registry().unwrap();
20353 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20354 let shared = NodeShared::new(
20355 handle,
20356 "fixture-token".to_owned(),
20357 NodeId::new("node-launch-inventory").unwrap(),
20358 vec![workspace],
20359 vec![agent("claude")],
20360 );
20361 let snapshot = shared.snapshot();
20362 let launch = snapshot.launch_inventory.as_ref().unwrap();
20363 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20364 assert!(launch.bundles.as_ref().unwrap().is_empty());
20365 let profiles = snapshot.workspaces[0]
20366 .managed_worktree_profiles
20367 .as_ref()
20368 .unwrap();
20369 assert_eq!(
20370 snapshot.workspaces[0].worktree_service_mode,
20371 Some(WorktreeServiceMode::Managed),
20372 );
20373 assert_eq!(
20374 profiles.profiles.len(),
20375 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20376 );
20377 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20378 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20379 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20380 }
20381 let legacy = snapshot_for_wire(
20382 &shared,
20383 true,
20384 true,
20385 false,
20386 false,
20387 true,
20388 false,
20389 true,
20390 false,
20391 true,
20392 );
20393 assert!(legacy.launch_inventory.is_none());
20394 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20395
20396 drop(shared);
20397 std::fs::remove_dir_all(source).unwrap();
20398 std::fs::remove_dir_all(allocation).unwrap();
20399 }
20400
20401 #[test]
20402 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20403 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20404 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20405 }));
20406 let source = gate4agent_types::ProviderSource {
20407 family: AdapterFamily::PtySemantic,
20408 binding: AdapterBinding::new(
20409 gate4agent_types::AdapterId::new("claude").unwrap(),
20410 "fixture/v1",
20411 gate4agent_types::AdapterVerification::SyntheticFixture,
20412 )
20413 .unwrap(),
20414 };
20415 let provider = ProviderSnapshot {
20416 sequence: 17,
20417 session: Some(ProviderSessionIdentity {
20418 key: ProviderSessionKey::SessionId,
20419 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20420 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20421 }),
20422 model: Some("PRIVATE_MODEL".to_owned()),
20423 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20424 completed_turns: 3,
20425 usage: gate4agent_types::TokenUsage {
20426 input_tokens: 10,
20427 output_tokens: 20,
20428 cache_read_tokens: 30,
20429 cache_write_tokens: 40,
20430 reasoning_tokens: 50,
20431 context_window: Some(200_000),
20432 },
20433 lead_activity: ProviderActivity::WaitingForInput,
20434 activity: ProviderActivity::WaitingForInput,
20435 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20436 active_tools: (0..9)
20437 .map(|index| gate4agent_types::ActiveProviderTool {
20438 id: format!("private-tool-id-{index}"),
20439 name: if index == 0 {
20440 format!(" Read\u{0000}{}", "x".repeat(80))
20441 } else {
20442 format!("Tool-{index}")
20443 },
20444 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20445 })
20446 .collect(),
20447 interactions: vec![gate4agent_types::ProviderInteraction {
20448 id: gate4agent_types::ProviderInteractionId(17),
20449 source: source.clone(),
20450 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20451 interaction_kind: ProviderInteractionKind::Approval,
20452 tool_name: " Approve\u{0007}Tool ".to_owned(),
20453 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20454 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20455 resume_lead_activity: Some(ProviderActivity::Working),
20456 status: ProviderInteractionStatus::Pending,
20457 }],
20458 subagents: vec![gate4agent_types::ProviderSubagent {
20459 source: source.clone(),
20460 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20461 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20462 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20463 }],
20464 sources: vec![gate4agent_types::ProviderSourceCursor {
20465 source,
20466 sequence: 17,
20467 gap_count: 2,
20468 stale: true,
20469 }],
20470 last_event: Some(ProviderEvent::Error {
20471 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20472 }),
20473 gap_count: 2,
20474 stale: true,
20475 };
20476 let address = terminal_address(3);
20477 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20478 assert_eq!(entry.address, address);
20479 assert_eq!(entry.progress.provider_sequence, 17);
20480 assert_eq!(entry.progress.active_tool_count, 9);
20481 assert_eq!(
20482 entry.progress.active_tool_labels.len(),
20483 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20484 );
20485 assert_eq!(entry.progress.subagent_count, 1);
20486 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20487 assert!(entry.progress.truncated);
20488 let encoded = serde_json::to_string(&entry).unwrap();
20489 for forbidden in [
20490 "PRIVATE_PROVIDER_SESSION",
20491 "PRIVATE_PROVIDER_PATH",
20492 "PRIVATE_MODEL",
20493 "PRIVATE_TOOL_INVENTORY",
20494 "PRIVATE_CURRENT_PROMPT",
20495 "PRIVATE_TOOL_INPUT_JSON",
20496 "PRIVATE_PROVIDER_REQUEST",
20497 "PRIVATE_INTERACTION_PROMPT",
20498 "PRIVATE_INTERACTION_AGENT",
20499 "PRIVATE_SUBAGENT_ID",
20500 "PRIVATE_SUBAGENT_TYPE",
20501 "PRIVATE_SUBAGENT_DESCRIPTION",
20502 "PRIVATE_PROVIDER_ERROR",
20503 "input_json",
20504 "current_prompt",
20505 "session_id",
20506 ] {
20507 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20508 }
20509
20510 let snapshot = NodeSnapshot {
20511 node_id: NodeId::new("node-progress-test").unwrap(),
20512 enabled_providers: vec![agent("claude")],
20513 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20514 workspaces: Vec::new(),
20515 session_records: Vec::new(),
20516 managed_worktrees: Vec::new(),
20517 launch_inventory: None,
20518 agent_progress: vec![entry],
20519 };
20520 let mut legacy_reply = ResponseEnvelope {
20521 request_id: 1,
20522 result: Ok(NodeResponse::Snapshot {
20523 event_sequence: 0,
20524 controller: None,
20525 snapshot: snapshot.clone(),
20526 }),
20527 };
20528 project_response_without_agent_progress(&mut legacy_reply);
20529 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20530 panic!("agent progress projection changed response kind");
20531 };
20532 assert!(legacy.agent_progress.is_empty());
20533 assert_eq!(snapshot.agent_progress.len(), 1);
20534 }
20535
20536 fn terminal_address(generation: u64) -> SessionAddress {
20537 SessionAddress {
20538 workspace_id: WorkspaceId::new("primary").unwrap(),
20539 session: SessionKey {
20540 instance_id: AgentInstanceId(41),
20541 generation: SessionGeneration(generation),
20542 },
20543 }
20544 }
20545
20546 fn spawn_spec_fixture() -> SpawnSpec {
20547 SpawnSpec {
20548 target: crate::protocol::SpawnTarget {
20549 node_id: NodeId::new("node-terminal-test").unwrap(),
20550 workspace_id: WorkspaceId::new("primary").unwrap(),
20551 worktree_id: None,
20552 },
20553 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20554 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20555 .unwrap(),
20556 overrides: crate::protocol::SpawnOverrides::default(),
20557 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20558 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20559 required_capabilities: SpawnRequiredCapabilities::default(),
20560 }
20561 }
20562
20563 fn environment_profile_test_shared(
20564 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20565 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20566 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20567 "local-claude-r1",
20568 )
20569 .unwrap();
20570 let spawn_profiles = SpawnProfileRegistry::new([
20571 crate::protocol::SpawnProfileDefaults {
20572 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20573 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20574 provider: agent("claude"),
20575 mode: SessionMode::Pty,
20576 terminal_size: gate4agent_types::TerminalSize {
20577 rows: 24,
20578 columns: 80,
20579 },
20580 prompt: None,
20581 bundle_id: None,
20582 context_id: None,
20583 environment_profile_id: Some(profile_id.clone()),
20584 },
20585 ])
20586 .unwrap();
20587 let catalog = active_registry().unwrap();
20588 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20589 let control = runtime.native_launch_profile_control();
20590 let node_profile = NodeEnvironmentProfile::new(
20591 profile_id.clone(),
20592 profile_revision.clone(),
20593 agent("claude"),
20594 [NativeLaunchProfile::new(
20595 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20596 agent("claude"),
20597 TransportKind::Pty,
20598 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20599 Arc::new(EmptyEnvironmentResolver),
20600 )
20601 .unwrap()],
20602 )
20603 .unwrap();
20604 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20605 for native_profile in native_profiles {
20606 runtime.upsert_native_launch_profile(native_profile).unwrap();
20607 }
20608 let workspace = WorkspaceConfig::new(
20609 WorkspaceId::new("primary").unwrap(),
20610 std::env::current_dir().unwrap(),
20611 )
20612 .unwrap();
20613 let shared = NodeShared::new_with_incarnation(
20614 handle,
20615 "fixture-token".to_owned(),
20616 NodeId::new("node-terminal-test").unwrap(),
20617 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20618 vec![workspace],
20619 vec![agent("claude")],
20620 ProviderRuntimeStatuses::default(),
20621 None,
20622 Vec::new(),
20623 Vec::new(),
20624 spawn_profiles,
20625 Some(control),
20626 None,
20627 None,
20628 None,
20629 Vec::new(),
20630 Vec::new(),
20631 Vec::new(),
20632 Vec::new(),
20633 None,
20634 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20635 );
20636 shared
20637 .environment_profiles
20638 .write()
20639 .unwrap_or_else(|poisoned| poisoned.into_inner())
20640 .insert(binding.id.clone(), binding);
20641 (
20642 shared,
20643 runtime,
20644 ResolvedEnvironmentProfileReceipt {
20645 profile_id,
20646 profile_revision,
20647 network_allowlist: None,
20648 browser_profile_id: None,
20649 },
20650 )
20651 }
20652
20653 fn materialization_test_shared(
20654 deny_secret: bool,
20655 ) -> (
20656 NodeShared,
20657 NativeRuntime,
20658 ResolvedEnvironmentProfileReceipt,
20659 PathBuf,
20660 Arc<AtomicBool>,
20661 ) {
20662 let workspace = WorkspaceConfig::new(
20663 WorkspaceId::new("primary").unwrap(),
20664 std::env::current_dir().unwrap(),
20665 )
20666 .unwrap();
20667 materialization_test_shared_with_workspace(deny_secret, workspace)
20668 }
20669
20670 fn materialization_test_shared_with_workspace(
20671 deny_secret: bool,
20672 workspace: WorkspaceConfig,
20673 ) -> (
20674 NodeShared,
20675 NativeRuntime,
20676 ResolvedEnvironmentProfileReceipt,
20677 PathBuf,
20678 Arc<AtomicBool>,
20679 ) {
20680 let root = temporary_workspace_root(if deny_secret {
20681 "materialization-denied"
20682 } else {
20683 "materialization-ready"
20684 });
20685 std::fs::create_dir_all(&root).unwrap();
20686 let materialization_root = root.join("session-environments");
20687 let deny = Arc::new(AtomicBool::new(deny_secret));
20688 let resolver = Arc::new(FixtureSecretResolver {
20689 deny: Arc::clone(&deny),
20690 });
20691 let materializer = SessionEnvironmentMaterializer::new(
20692 materialization_root,
20693 resolver,
20694 )
20695 .unwrap();
20696 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20697 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20698 "materialized-claude-r1",
20699 )
20700 .unwrap();
20701 let materialization = NodeSessionMaterializationProfile::new(
20702 vec![NodeSessionEnvironmentMutation::SetSecret {
20703 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20704 reference: NodeSecretReference::new("fixture-token").unwrap(),
20705 }],
20706 vec![NodeSessionPathBinding::new(
20707 "GATE4AGENT_PROVIDER_HOME",
20708 NodeSessionPathClass::ProviderHome,
20709 )
20710 .unwrap()],
20711 vec![NodeSessionFile::secret(
20712 NodeSessionPathClass::Config,
20713 "auth/token",
20714 NodeSecretReference::new("fixture-token").unwrap(),
20715 )
20716 .unwrap()],
20717 )
20718 .unwrap();
20719 let catalog = active_registry().unwrap();
20720 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20721 let control = runtime.native_launch_profile_control();
20722 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20723 profile_id.clone(),
20724 profile_revision.clone(),
20725 agent("claude"),
20726 [NativeLaunchProfile::new(
20727 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20728 agent("claude"),
20729 TransportKind::Pty,
20730 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20731 Arc::new(EmptyEnvironmentResolver),
20732 )
20733 .unwrap()],
20734 Some(materialization),
20735 )
20736 .unwrap();
20737 let (binding, native_profiles, materialization) = node_profile.into_parts();
20738 for native_profile in native_profiles {
20739 runtime.upsert_native_launch_profile(native_profile).unwrap();
20740 }
20741 let shared = NodeShared::new_with_incarnation(
20742 handle,
20743 "fixture-token".to_owned(),
20744 NodeId::new("node-materialization-test").unwrap(),
20745 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20746 vec![workspace],
20747 vec![agent("claude")],
20748 ProviderRuntimeStatuses::default(),
20749 None,
20750 Vec::new(),
20751 Vec::new(),
20752 SpawnProfileRegistry::default(),
20753 Some(control),
20754 None,
20755 Some(materializer),
20756 None,
20757 Vec::new(),
20758 Vec::new(),
20759 Vec::new(),
20760 Vec::new(),
20761 None,
20762 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20763 );
20764 shared
20765 .environment_profiles
20766 .write()
20767 .unwrap_or_else(|poisoned| poisoned.into_inner())
20768 .insert(binding.id.clone(), binding);
20769 shared
20770 .environment_materialization_profiles
20771 .write()
20772 .unwrap_or_else(|poisoned| poisoned.into_inner())
20773 .insert(profile_id.clone(), materialization.unwrap());
20774 (
20775 shared,
20776 runtime,
20777 ResolvedEnvironmentProfileReceipt {
20778 profile_id,
20779 profile_revision,
20780 network_allowlist: None,
20781 browser_profile_id: None,
20782 },
20783 root,
20784 deny,
20785 )
20786 }
20787
20788 #[cfg(feature = "fixture")]
20789 #[derive(Clone, Copy)]
20790 enum CodexHomeProfile {
20791 Missing,
20792 WrongClass,
20793 Exact,
20794 }
20795
20796 #[cfg(feature = "fixture")]
20797 fn codex_bundle_test_shared(
20798 home_profile: CodexHomeProfile,
20799 ) -> (
20800 NodeShared,
20801 NativeRuntime,
20802 ResolvedEnvironmentProfileReceipt,
20803 NodeBundle,
20804 PathBuf,
20805 ) {
20806 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20807 const CLAUDE_MANIFEST: &[u8] =
20808 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20809 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20810 const DIGEST: &str =
20811 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20812
20813 let (shared, mut runtime, _claude_receipt, root, _deny) =
20814 materialization_test_shared(false);
20815 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20816 CodexHomeProfile::Missing => "codex-home-missing",
20817 CodexHomeProfile::WrongClass => "codex-home-wrong",
20818 CodexHomeProfile::Exact => "codex-home-exact",
20819 })
20820 .unwrap();
20821 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20822 "codex-home-r1",
20823 )
20824 .unwrap();
20825 let materialization = match home_profile {
20826 CodexHomeProfile::Missing => None,
20827 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20828 Some(NodeSessionMaterializationProfile::new(
20829 Vec::new(),
20830 vec![NodeSessionPathBinding::new(
20831 "CODEX_HOME",
20832 if matches!(home_profile, CodexHomeProfile::Exact) {
20833 NodeSessionPathClass::ProviderHome
20834 } else {
20835 NodeSessionPathClass::Config
20836 },
20837 )
20838 .unwrap()],
20839 Vec::new(),
20840 )
20841 .unwrap())
20842 }
20843 };
20844 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20845 profile_id.clone(),
20846 profile_revision.clone(),
20847 agent("codex"),
20848 [NativeLaunchProfile::new(
20849 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20850 agent("codex"),
20851 TransportKind::Pty,
20852 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20853 Arc::new(EmptyEnvironmentResolver),
20854 )
20855 .unwrap()],
20856 materialization,
20857 )
20858 .unwrap();
20859 let (binding, native_profiles, materialization) = node_profile.into_parts();
20860 for native_profile in native_profiles {
20861 runtime.upsert_native_launch_profile(native_profile).unwrap();
20862 }
20863 shared
20864 .environment_profiles
20865 .write()
20866 .unwrap_or_else(|poisoned| poisoned.into_inner())
20867 .insert(binding.id.clone(), binding);
20868 if let Some(materialization) = materialization {
20869 shared
20870 .environment_materialization_profiles
20871 .write()
20872 .unwrap_or_else(|poisoned| poisoned.into_inner())
20873 .insert(profile_id.clone(), materialization);
20874 }
20875
20876 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20877 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20878 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20879 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20880 std::fs::write(
20881 bundle_source.join(".claude-plugin/plugin.json"),
20882 CLAUDE_MANIFEST,
20883 )
20884 .unwrap();
20885 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20886 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20887 let bundle = NodeBundle::new(
20888 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20889 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20890 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20891 &bundle_source,
20892 )
20893 .unwrap();
20894 *shared
20895 .bundle_catalog
20896 .write()
20897 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20898 BundleCatalog::new([bundle.clone()]).unwrap();
20899 (
20900 shared,
20901 runtime,
20902 ResolvedEnvironmentProfileReceipt {
20903 profile_id,
20904 profile_revision,
20905 network_allowlist: None,
20906 browser_profile_id: None,
20907 },
20908 bundle,
20909 root,
20910 )
20911 }
20912
20913 #[cfg(feature = "fixture")]
20914 #[test]
20915 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20916 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20917 let (shared, runtime, receipt, bundle, root) =
20918 codex_bundle_test_shared(home_profile);
20919 let error = shared
20920 .bundle_layout(
20921 &agent("codex"),
20922 SessionMode::Pty,
20923 Some(&receipt),
20924 &bundle,
20925 )
20926 .unwrap_err();
20927 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20928 assert!(shared
20929 .materializations
20930 .lock()
20931 .unwrap_or_else(|poisoned| poisoned.into_inner())
20932 .is_empty());
20933 let materialization_root = root.join("session-environments");
20934 assert!(!std::fs::read_dir(&materialization_root)
20935 .unwrap()
20936 .filter_map(Result::ok)
20937 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20938 drop(shared);
20939 drop(runtime);
20940 let _ = std::fs::remove_dir_all(root);
20941 }
20942 }
20943
20944 #[cfg(feature = "fixture")]
20945 #[test]
20946 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20947 let (shared, runtime, receipt, bundle, root) =
20948 codex_bundle_test_shared(CodexHomeProfile::Exact);
20949 let address = SessionAddress {
20950 workspace_id: WorkspaceId::new("primary").unwrap(),
20951 session: SessionKey {
20952 instance_id: AgentInstanceId(310),
20953 generation: SessionGeneration(1),
20954 },
20955 };
20956 let (overlay, guard) = shared
20957 .prepare_session_materialization(
20958 &address,
20959 &agent("codex"),
20960 SessionMode::Pty,
20961 Some(&receipt),
20962 Some(&bundle.receipt()),
20963 None,
20964 None,
20965 )
20966 .unwrap()
20967 .unwrap();
20968 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20969 let ownership = shared
20970 .materializations
20971 .lock()
20972 .unwrap_or_else(|poisoned| poisoned.into_inner())
20973 .get(guard.id().unwrap())
20974 .unwrap()
20975 .clone();
20976 let (profile, _, _) = shared
20977 .materialization_profile(
20978 &agent("codex"),
20979 SessionMode::Pty,
20980 Some(&receipt),
20981 Some(&bundle.receipt()),
20982 None,
20983 )
20984 .unwrap()
20985 .unwrap();
20986 let environment = shared
20987 .session_environment_materializer
20988 .as_ref()
20989 .unwrap()
20990 .resolve_environment(&ownership, &profile)
20991 .unwrap();
20992 assert!(environment.iter().any(|mutation| {
20993 mutation.key == OsString::from("CODEX_HOME")
20994 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20995 }));
20996 assert_eq!(
20997 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20998 .unwrap(),
20999 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21000 );
21001 assert!(std::fs::read_dir(ownership.bundle_root())
21002 .unwrap()
21003 .next()
21004 .is_none());
21005 drop(guard);
21006 drop(shared);
21007 drop(runtime);
21008 let _ = std::fs::remove_dir_all(root);
21009 }
21010
21011 #[cfg(feature = "fixture")]
21012 #[test]
21013 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21014 let (shared, runtime, receipt, bundle, root) =
21015 codex_bundle_test_shared(CodexHomeProfile::Exact);
21016 let bundle_receipt = bundle.receipt();
21017 let address = SessionAddress {
21018 workspace_id: WorkspaceId::new("primary").unwrap(),
21019 session: SessionKey {
21020 instance_id: AgentInstanceId(311),
21021 generation: SessionGeneration(1),
21022 },
21023 };
21024 let (_overlay, guard) = shared
21025 .prepare_session_materialization(
21026 &address,
21027 &agent("codex"),
21028 SessionMode::Pty,
21029 Some(&receipt),
21030 Some(&bundle_receipt),
21031 None,
21032 None,
21033 )
21034 .unwrap()
21035 .unwrap();
21036 let materialization_id = guard.id().unwrap().clone();
21037 let skill_path = shared
21038 .materializations
21039 .lock()
21040 .unwrap_or_else(|poisoned| poisoned.into_inner())
21041 .get(&materialization_id)
21042 .unwrap()
21043 .provider_home()
21044 .join("skills/review-code/SKILL.md");
21045 let record_id = shared
21046 .bind_spawn_session_with_materialization(
21047 &address,
21048 agent("codex"),
21049 SessionMode::Pty,
21050 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21051 SpawnRecordPolicy::ProviderIdentityOnly,
21052 Some(receipt.clone()),
21053 Some(bundle_receipt.clone()),
21054 None,
21055 Some(materialization_id.clone()),
21056 )
21057 .unwrap()
21058 .unwrap();
21059 guard.retain();
21060 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21061
21062 let error = match shared.resolve_record_materialization(
21063 &record_id,
21064 &agent("codex"),
21065 SessionMode::Pty,
21066 Some(&receipt),
21067 Some(&bundle_receipt),
21068 None,
21069 ) {
21070 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21071 Err(error) => error,
21072 };
21073 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21074 assert_eq!(
21075 shared
21076 .materializations
21077 .lock()
21078 .unwrap_or_else(|poisoned| poisoned.into_inner())
21079 .get(&materialization_id)
21080 .unwrap()
21081 .state(),
21082 MaterializationState::RecoveryRequired,
21083 );
21084 drop(shared);
21085 drop(runtime);
21086 let _ = std::fs::remove_dir_all(root);
21087 }
21088
21089 fn bind_record_owned_materialization_fixture(
21090 shared: &NodeShared,
21091 receipt: &ResolvedEnvironmentProfileReceipt,
21092 instance_id: u64,
21093 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21094 let address = SessionAddress {
21095 workspace_id: WorkspaceId::new("primary").unwrap(),
21096 session: SessionKey {
21097 instance_id: AgentInstanceId(instance_id),
21098 generation: SessionGeneration(1),
21099 },
21100 };
21101 let (overlay, materialization_guard) = shared
21102 .prepare_session_materialization(
21103 &address,
21104 &agent("claude"),
21105 SessionMode::Pty,
21106 Some(receipt),
21107 None,
21108 None,
21109 None,
21110 )
21111 .unwrap()
21112 .unwrap();
21113 let materialization_id = materialization_guard.id().unwrap().clone();
21114 let materialization_root = shared
21115 .materializations
21116 .lock()
21117 .unwrap_or_else(|poisoned| poisoned.into_inner())
21118 .get(&materialization_id)
21119 .unwrap()
21120 .root()
21121 .to_path_buf();
21122 let selection = shared
21123 .select_environment_profile(
21124 address.session.instance_id,
21125 &agent("claude"),
21126 SessionMode::Pty,
21127 Some(receipt),
21128 )
21129 .unwrap()
21130 .unwrap();
21131 assert!(shared
21132 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21133 .unwrap()
21134 .is_none());
21135 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21136 let record_id = shared
21137 .bind_spawn_session_with_materialization(
21138 &address,
21139 agent("claude"),
21140 SessionMode::Pty,
21141 policy,
21142 SpawnRecordPolicy::ProviderIdentityOnly,
21143 Some(receipt.clone()),
21144 None,
21145 None,
21146 Some(materialization_id.clone()),
21147 )
21148 .unwrap()
21149 .unwrap();
21150 selection.retain();
21151 materialization_guard.retain();
21152 (address, record_id, materialization_id, materialization_root)
21153 }
21154
21155 #[test]
21156 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21157 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21158 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21159 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21160 }));
21161 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21162 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21163 let mut spec = spawn_spec_fixture();
21164 spec.expected_profile_revision =
21165 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21166 assert!(matches!(
21167 &spec.overrides.environment_profile_id,
21168 crate::protocol::SpawnOverride::Inherit,
21169 ));
21170 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21171 .requires_child_environment_profile_capability());
21172 assert!(request_requires_child_environment_profile(
21173 &shared,
21174 &NodeRequest::SpawnSpec { spec: spec.clone() },
21175 ));
21176
21177 let address = terminal_address(1);
21178 shared.bind_session_with_policy(
21179 &address,
21180 ProviderRuntimePolicy::raw_pty(),
21181 Some(environment_profile.clone()),
21182 );
21183 assert!(request_requires_child_environment_profile(
21184 &shared,
21185 &NodeRequest::Input {
21186 session: address.clone(),
21187 text: "x".to_owned(),
21188 },
21189 ));
21190
21191 let mut record = record("claude", "profile-record");
21192 record.active_session = Some(address.clone());
21193 record.environment_profile = Some(environment_profile.clone());
21194 let record_id = record.record_id.clone();
21195 shared.insert_record(record.clone()).unwrap();
21196 assert!(request_requires_child_environment_profile(
21197 &shared,
21198 &NodeRequest::ResumeSessionRecord {
21199 record_id,
21200 terminal_size: gate4agent_types::TerminalSize {
21201 rows: 24,
21202 columns: 80,
21203 },
21204 initial_prompt: None,
21205 },
21206 ));
21207
21208 let snapshot = snapshot_for_wire(
21209 &shared, false, true, true, true, false, false, false, false, true,
21210 );
21211 assert_eq!(snapshot.session_records.len(), 1);
21212 assert!(snapshot.session_records[0].environment_profile.is_none());
21213 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21214 sequence: 1,
21215 event: NodeEvent::SessionRecordUpserted { record },
21216 });
21217 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21218 panic!("record projection changed the event kind");
21219 };
21220 assert!(record.environment_profile.is_none());
21221
21222 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21223 let mut reply = ResponseEnvelope {
21224 request_id: 1,
21225 result: Ok(NodeResponse::SpawnSpecAccepted {
21226 receipt: resolved.receipt_with_environment(
21227 shared.incarnation_id,
21228 address,
21229 Some(environment_profile),
21230 ),
21231 }),
21232 };
21233 project_response_without_child_environment_profile(&mut reply);
21234 assert_eq!(
21235 reply.result.unwrap_err().code,
21236 NodeFailureCode::UnsupportedCapability,
21237 );
21238 }
21239
21240 #[test]
21241 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21242 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21243 let snapshot = shared.snapshot();
21244 let profiles = snapshot
21245 .launch_inventory
21246 .as_ref()
21247 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21248 .unwrap();
21249 assert_eq!(profiles.len(), 1);
21250 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21251 assert!(snapshot.requires_child_environment_profile_capability());
21252
21253 let mut legacy = snapshot.clone();
21254 clear_snapshot_child_environment_profiles(&mut legacy);
21255 assert!(legacy
21256 .launch_inventory
21257 .as_ref()
21258 .unwrap()
21259 .spawn_profiles
21260 .as_ref()
21261 .unwrap()[0]
21262 .environment_profile
21263 .is_none());
21264 assert!(!legacy.requires_child_environment_profile_capability());
21265
21266 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21267 let bindings = shared
21268 .environment_profiles
21269 .read()
21270 .unwrap_or_else(|poisoned| poisoned.into_inner())
21271 .clone();
21272 let mut provider_mismatch = bindings.clone();
21273 provider_mismatch
21274 .values_mut()
21275 .next()
21276 .unwrap()
21277 .provider = agent("codex");
21278 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21279 let mut mode_mismatch = profile;
21280 mode_mismatch.mode = SessionMode::Inline;
21281 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21282
21283 shared
21284 .environment_profiles
21285 .write()
21286 .unwrap_or_else(|poisoned| poisoned.into_inner())
21287 .clear();
21288 assert!(shared
21289 .snapshot()
21290 .launch_inventory
21291 .unwrap()
21292 .spawn_profiles
21293 .unwrap()
21294 .is_empty());
21295 }
21296
21297 #[test]
21298 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21299 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21300 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21301 }));
21302 let shared = terminal_test_shared();
21303 let bundle = ResolvedBundleReceipt {
21304 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21305 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21306 digest: crate::protocol::SpawnBundleDigest::new(format!(
21307 "sha256:{}",
21308 "0".repeat(64),
21309 ))
21310 .unwrap(),
21311 };
21312 let mut bundled = record("claude", "bundle-record");
21313 bundled.bundle = Some(bundle.clone());
21314 let record_id = bundled.record_id.clone();
21315 shared.insert_record(bundled.clone()).unwrap();
21316 assert!(request_requires_session_bundle(
21317 &shared,
21318 &NodeRequest::ForgetSessionRecord {
21319 record_id: record_id.clone(),
21320 },
21321 ));
21322
21323 let snapshot = snapshot_for_wire(
21324 &shared, false, true, true, true, true, false, false, false, true,
21325 );
21326 assert!(snapshot.session_records[0].bundle.is_none());
21327 let launch = snapshot.launch_inventory.as_ref().unwrap();
21328 assert!(launch.spawn_profiles.is_some());
21329 assert!(launch.bundles.is_none());
21330 let event = project_event_without_session_bundle(NodeEventEnvelope {
21331 sequence: 1,
21332 event: NodeEvent::SessionRecordUpserted { record: bundled },
21333 });
21334 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21335 panic!("record projection changed the event kind");
21336 };
21337 assert!(record.bundle.is_none());
21338
21339 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21340 let mut reply = ResponseEnvelope {
21341 request_id: 1,
21342 result: Ok(NodeResponse::SpawnSpecAccepted {
21343 receipt: resolved.receipt_with_materialization(
21344 shared.incarnation_id,
21345 terminal_address(17),
21346 None,
21347 Some(bundle),
21348 None,
21349 ),
21350 }),
21351 };
21352 project_response_without_session_bundle(&mut reply);
21353 assert_eq!(
21354 reply.result.unwrap_err().code,
21355 NodeFailureCode::UnsupportedCapability,
21356 );
21357 }
21358
21359 #[test]
21360 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21361 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21362 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21363 }));
21364 let context_id = SpawnContextId::new("context-review").unwrap();
21365 let context = ResolvedContextPackReceipt {
21366 id: context_id.clone(),
21367 digest: crate::protocol::SpawnContextDigest::new(format!(
21368 "sha256:{}",
21369 "c".repeat(64),
21370 ))
21371 .unwrap(),
21372 lineage: ContextPackLineageReceipt {
21373 source_node_id: NodeId::new("node-source").unwrap(),
21374 source_session: terminal_address(1),
21375 source_provider: agent("kimi"),
21376 },
21377 source_message_count: 2,
21378 retained_message_count: 2,
21379 byte_len: 256,
21380 truncated: false,
21381 };
21382 assert!(context.is_valid());
21383 let mut bound_record = record("codex", "context-record");
21384 bound_record.context_id = Some(context_id);
21385 bound_record.context = Some(context.clone());
21386 let session = gate4agent_types::SessionSnapshot {
21387 instance_id: AgentInstanceId(91),
21388 agent_id: agent("kimi"),
21389 transport: TransportKind::Pty,
21390 generation: SessionGeneration(3),
21391 status: SessionStatus::Running,
21392 pending_operation: None,
21393 pending_input: None,
21394 process_id: Some(9001),
21395 terminal_size: None,
21396 terminal_frame: None,
21397 terminal_stale: None,
21398 session_options: None,
21399 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21400 history: gate4agent_types::HistorySnapshot {
21401 pending: None,
21402 candidates: vec![HistoryCandidateSummary {
21403 id: "candidate-opaque".to_owned(),
21404 session_id_hint: "session-hint".to_owned(),
21405 modified_at_unix_ms: Some(1),
21406 }],
21407 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21408 loaded: Some(HistorySessionRecord {
21409 session_id: "private-vendor-session".to_owned(),
21410 title: Some("private title".to_owned()),
21411 cwd: Some(r"C:\private\history-root".to_owned()),
21412 model: Some("private-model".to_owned()),
21413 message_count: 2,
21414 completed_turn_count: None,
21415 total_tokens: 19,
21416 messages: vec![
21417 gate4agent_types::HistoryMessageRecord {
21418 role: gate4agent_types::HistoryMessageRole::User,
21419 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21420 },
21421 gate4agent_types::HistoryMessageRecord {
21422 role: gate4agent_types::HistoryMessageRole::Assistant,
21423 text: "private answer".to_owned(),
21424 },
21425 ],
21426 }),
21427 last_error: None,
21428 },
21429 resume: gate4agent_types::ResumeSnapshot::default(),
21430 foreground: gate4agent_types::ForegroundSnapshot::default(),
21431 provider: gate4agent_types::ProviderSnapshot::default(),
21432 screen_state: gate4agent_types::PtyScreenState::default(),
21433 };
21434 let original = NodeSnapshot {
21435 node_id: NodeId::new("node-terminal-test").unwrap(),
21436 enabled_providers: vec![agent("kimi"), agent("codex")],
21437 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21438 workspaces: vec![WorkspaceSnapshot {
21439 workspace_id: WorkspaceId::new("primary").unwrap(),
21440 canonical_root: opaque_windows_path(
21441 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21442 ),
21443 sessions: vec![session],
21444 worktree_service_mode: None,
21445 managed_worktree_profiles: None,
21446 }],
21447 session_records: vec![bound_record],
21448 managed_worktrees: Vec::new(),
21449 launch_inventory: None,
21450 agent_progress: Vec::new(),
21451 };
21452
21453 let mut negotiated = original.clone();
21454 project_snapshot_history_for_wire(&mut negotiated, true);
21455 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21456 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21457 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21458 assert!(!serde_json::to_string(&negotiated)
21459 .unwrap()
21460 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21461
21462 let mut legacy = original;
21463 project_snapshot_history_for_wire(&mut legacy, false);
21464 clear_snapshot_context_packs(&mut legacy);
21465 assert_eq!(
21466 legacy.workspaces[0].sessions[0].history,
21467 gate4agent_types::HistorySnapshot::default(),
21468 );
21469 assert!(legacy.session_records[0].context_id.is_none());
21470 assert!(legacy.session_records[0].context.is_none());
21471 let encoded = serde_json::to_string(&legacy).unwrap();
21472 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21473 assert!(!encoded.contains("context-review"));
21474
21475 let mut reply = ResponseEnvelope {
21476 request_id: 7,
21477 result: Ok(NodeResponse::ContextPackExported { context }),
21478 };
21479 project_response_without_context_pack(&mut reply);
21480 assert_eq!(
21481 reply.result.unwrap_err().code,
21482 NodeFailureCode::UnsupportedCapability,
21483 );
21484 }
21485
21486 #[test]
21487 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21488 let shared = terminal_test_shared();
21489 let history = HistorySessionRecord {
21490 session_id: "fixture-history".to_owned(),
21491 title: None,
21492 cwd: None,
21493 model: None,
21494 message_count: 1,
21495 completed_turn_count: None,
21496 total_tokens: 0,
21497 messages: vec![gate4agent_types::HistoryMessageRecord {
21498 role: gate4agent_types::HistoryMessageRole::User,
21499 text: "bounded handoff".to_owned(),
21500 }],
21501 };
21502 let pack = NodeContextPack::export(
21503 ContextPackLineageReceipt {
21504 source_node_id: shared.node_id.clone(),
21505 source_session: terminal_address(1),
21506 source_provider: agent("claude"),
21507 },
21508 &history,
21509 )
21510 .unwrap();
21511 let context = shared
21512 .context_catalog
21513 .write()
21514 .unwrap_or_else(|poisoned| poisoned.into_inner())
21515 .insert(pack)
21516 .unwrap();
21517 let mut retained = record("codex", "context-retained-record");
21518 retained.context_id = Some(context.id.clone());
21519 retained.context = Some(context.clone());
21520 let record_id = retained.record_id.clone();
21521 shared.insert_record(retained).unwrap();
21522 assert_eq!(
21523 shared.forget_context_pack(&context.id).unwrap_err().code,
21524 NodeFailureCode::ContextPackBusy,
21525 );
21526 assert!(shared
21527 .context_catalog
21528 .read()
21529 .unwrap_or_else(|poisoned| poisoned.into_inner())
21530 .get(&context.id)
21531 .is_some());
21532 shared
21533 .session_records
21534 .lock()
21535 .unwrap_or_else(|poisoned| poisoned.into_inner())
21536 .records
21537 .remove(&record_id);
21538
21539 let mut spec = spawn_spec_fixture();
21540 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21541 value: context.id.clone(),
21542 };
21543 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21544 let receipt = resolved.receipt_with_materialization(
21545 shared.incarnation_id,
21546 terminal_address(2),
21547 None,
21548 None,
21549 Some(context.clone()),
21550 );
21551 assert!(receipt.context_binding_is_valid());
21552 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21553
21554 shared.forget_context_pack(&context.id).unwrap();
21555 assert!(shared
21556 .context_catalog
21557 .read()
21558 .unwrap_or_else(|poisoned| poisoned.into_inner())
21559 .get(&context.id)
21560 .is_none());
21561 assert!(shared
21562 .replay_spawn_spec(&spec, Instant::now())
21563 .unwrap()
21564 .is_none());
21565 }
21566
21567 #[test]
21568 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21569 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21570 let address = terminal_address(1);
21571 let selection = shared
21572 .select_environment_profile(
21573 address.session.instance_id,
21574 &agent("claude"),
21575 SessionMode::Pty,
21576 Some(&environment_profile),
21577 )
21578 .unwrap()
21579 .unwrap();
21580 shared.bind_session_with_policy(
21581 &address,
21582 ProviderRuntimePolicy::raw_pty(),
21583 Some(environment_profile.clone()),
21584 );
21585 selection.retain();
21586 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21587 assert!(matches!(
21588 runtime.remove_native_launch_profile(&native_id),
21589 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21590 ));
21591 assert!(shared.remove_binding(&address).is_some());
21592 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21593
21594 let mismatched = ResolvedEnvironmentProfileReceipt {
21595 profile_id: environment_profile.profile_id,
21596 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21597 "local-claude-r2",
21598 )
21599 .unwrap(),
21600 network_allowlist: None,
21601 browser_profile_id: None,
21602 };
21603 let error = match shared.select_environment_profile(
21604 AgentInstanceId(42),
21605 &agent("claude"),
21606 SessionMode::Pty,
21607 Some(&mismatched),
21608 ) {
21609 Ok(_) => panic!("mismatched environment profile revision was selected"),
21610 Err(error) => error,
21611 };
21612 assert_eq!(
21613 error.code,
21614 NodeFailureCode::EnvironmentProfileBindingMismatch,
21615 );
21616 assert!(!shared
21617 .session_bindings
21618 .lock()
21619 .unwrap_or_else(|poisoned| poisoned.into_inner())
21620 .contains_key(&AgentInstanceId(42)));
21621 }
21622
21623 #[test]
21624 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21625 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21626 let address = terminal_address(1);
21627 let selection = shared
21628 .select_environment_profile(
21629 address.session.instance_id,
21630 &agent("claude"),
21631 SessionMode::Pty,
21632 Some(&environment_profile),
21633 )
21634 .unwrap()
21635 .unwrap();
21636 let overlay = NativeInstanceLaunchOverlay::new(
21637 agent("claude"),
21638 TransportKind::Pty,
21639 vec![EnvMutation {
21640 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21641 value: Some(OsString::from("fixture-secret")),
21642 }],
21643 vec![
21644 OsString::from("--plugin-dir"),
21645 std::env::current_dir().unwrap().into_os_string(),
21646 ],
21647 )
21648 .unwrap();
21649 let instance_overlay = shared
21650 .install_prepared_launch_overlay(
21651 address.session.instance_id,
21652 PreparedNativeLaunchOverlay::Instance(overlay),
21653 )
21654 .unwrap()
21655 .unwrap();
21656 let bundle = ResolvedBundleReceipt {
21657 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21658 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21659 digest: crate::protocol::SpawnBundleDigest::new(format!(
21660 "sha256:{}",
21661 "0".repeat(64),
21662 ))
21663 .unwrap(),
21664 };
21665 shared.bind_session_with_materialization(
21666 &address,
21667 ProviderRuntimePolicy::raw_pty(),
21668 Some(environment_profile),
21669 Some(bundle),
21670 None,
21671 None,
21672 );
21673 selection.retain();
21674 instance_overlay.retain();
21675
21676 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21677 assert!(matches!(
21678 runtime.remove_native_launch_profile(&native_id),
21679 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21680 ));
21681 assert!(shared.remove_binding(&address).is_some());
21682 assert!(!shared
21683 .native_launch_profile_control
21684 .as_ref()
21685 .unwrap()
21686 .clear_native_instance_launch_overlay(address.session.instance_id));
21687 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21688 }
21689
21690 #[test]
21691 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21692 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21693 let address = SessionAddress {
21694 workspace_id: WorkspaceId::new("primary").unwrap(),
21695 session: SessionKey {
21696 instance_id: AgentInstanceId(201),
21697 generation: SessionGeneration(1),
21698 },
21699 };
21700 let error = match shared.prepare_session_materialization(
21701 &address,
21702 &agent("claude"),
21703 SessionMode::Pty,
21704 Some(&receipt),
21705 None,
21706 None,
21707 None,
21708 ) {
21709 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21710 Err(error) => error,
21711 };
21712 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21713 assert!(shared.handle.snapshot().sessions.is_empty());
21714 assert!(shared
21715 .session_bindings
21716 .lock()
21717 .unwrap_or_else(|poisoned| poisoned.into_inner())
21718 .is_empty());
21719 assert!(shared
21720 .materializations
21721 .lock()
21722 .unwrap_or_else(|poisoned| poisoned.into_inner())
21723 .is_empty());
21724 drop(shared);
21725 drop(runtime);
21726 std::fs::remove_dir_all(root).unwrap();
21727 }
21728
21729 #[test]
21730 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21731 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21732 let address = SessionAddress {
21733 workspace_id: WorkspaceId::new("primary").unwrap(),
21734 session: SessionKey {
21735 instance_id: AgentInstanceId(202),
21736 generation: SessionGeneration(1),
21737 },
21738 };
21739 let (overlay, materialization_guard) = shared
21740 .prepare_session_materialization(
21741 &address,
21742 &agent("claude"),
21743 SessionMode::Pty,
21744 Some(&receipt),
21745 None,
21746 None,
21747 None,
21748 )
21749 .unwrap()
21750 .unwrap();
21751 let materialization_id = materialization_guard.id().unwrap().clone();
21752 let materialization_root = shared
21753 .materializations
21754 .lock()
21755 .unwrap_or_else(|poisoned| poisoned.into_inner())
21756 .get(&materialization_id)
21757 .unwrap()
21758 .root()
21759 .to_path_buf();
21760 let selection = shared
21761 .select_environment_profile(
21762 address.session.instance_id,
21763 &agent("claude"),
21764 SessionMode::Pty,
21765 Some(&receipt),
21766 )
21767 .unwrap()
21768 .unwrap();
21769 assert!(shared
21770 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21771 .unwrap()
21772 .is_none());
21773 shared.bind_session_with_materialization(
21774 &address,
21775 ProviderRuntimePolicy::raw_pty(),
21776 Some(receipt),
21777 None,
21778 None,
21779 Some(materialization_id),
21780 );
21781 selection.retain();
21782 materialization_guard.retain();
21783
21784 assert!(materialization_root.is_dir());
21785 assert!(shared
21786 .session_bindings
21787 .lock()
21788 .unwrap_or_else(|poisoned| poisoned.into_inner())
21789 .contains_key(&address.session.instance_id));
21790
21791 let removed = shared.remove_binding(&address).unwrap();
21792 shared
21793 .cleanup_session_owned_materialization(&address, &removed)
21794 .unwrap();
21795 assert!(!materialization_root.exists());
21796 assert!(shared
21797 .materializations
21798 .lock()
21799 .unwrap_or_else(|poisoned| poisoned.into_inner())
21800 .is_empty());
21801 drop(shared);
21802 drop(runtime);
21803 std::fs::remove_dir_all(root).unwrap();
21804 }
21805
21806 #[tokio::test]
21807 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21808 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21809 let address = SessionAddress {
21810 workspace_id: WorkspaceId::new("primary").unwrap(),
21811 session: SessionKey {
21812 instance_id: AgentInstanceId(203),
21813 generation: SessionGeneration(1),
21814 },
21815 };
21816 let (overlay, materialization_guard) = shared
21817 .prepare_session_materialization(
21818 &address,
21819 &agent("claude"),
21820 SessionMode::Pty,
21821 Some(&receipt),
21822 None,
21823 None,
21824 None,
21825 )
21826 .unwrap()
21827 .unwrap();
21828 let materialization_id = materialization_guard.id().unwrap().clone();
21829 let materialization_root = shared
21830 .materializations
21831 .lock()
21832 .unwrap_or_else(|poisoned| poisoned.into_inner())
21833 .get(&materialization_id)
21834 .unwrap()
21835 .root()
21836 .to_path_buf();
21837 let selection = shared
21838 .select_environment_profile(
21839 address.session.instance_id,
21840 &agent("claude"),
21841 SessionMode::Pty,
21842 Some(&receipt),
21843 )
21844 .unwrap()
21845 .unwrap();
21846 assert!(shared
21847 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21848 .unwrap()
21849 .is_none());
21850 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21851 let record_id = shared
21852 .bind_spawn_session_with_materialization(
21853 &address,
21854 agent("claude"),
21855 SessionMode::Pty,
21856 policy,
21857 SpawnRecordPolicy::ProviderIdentityOnly,
21858 Some(receipt.clone()),
21859 None,
21860 None,
21861 Some(materialization_id.clone()),
21862 )
21863 .unwrap()
21864 .unwrap();
21865 selection.retain();
21866 materialization_guard.retain();
21867 assert_eq!(
21868 shared
21869 .materializations
21870 .lock()
21871 .unwrap_or_else(|poisoned| poisoned.into_inner())
21872 .get(&materialization_id)
21873 .unwrap()
21874 .owner(),
21875 &MaterializationOwner::Record {
21876 record_id: record_id.clone(),
21877 },
21878 );
21879
21880 let removed = shared.remove_binding(&address).unwrap();
21881 shared
21882 .cleanup_session_owned_materialization(&address, &removed)
21883 .unwrap();
21884 assert!(materialization_root.is_dir());
21885 assert!(shared
21886 .resolve_record_materialization(
21887 &record_id,
21888 &agent("claude"),
21889 SessionMode::Pty,
21890 Some(&receipt),
21891 None,
21892 None,
21893 )
21894 .unwrap()
21895 .is_some());
21896 {
21897 let mut records = shared
21898 .session_records
21899 .lock()
21900 .unwrap_or_else(|poisoned| poisoned.into_inner());
21901 let record = records.records.get_mut(&record_id).unwrap();
21902 record.active_session = None;
21903 record.state = ManagedSessionState::Unavailable;
21904 }
21905 shared.forget_session_record(&record_id).await.unwrap();
21906 assert!(!materialization_root.exists());
21907 assert!(shared
21908 .materializations
21909 .lock()
21910 .unwrap_or_else(|poisoned| poisoned.into_inner())
21911 .is_empty());
21912 drop(shared);
21913 drop(runtime);
21914 std::fs::remove_dir_all(root).unwrap();
21915 }
21916
21917 #[test]
21918 fn record_materialization_revalidation_failure_persists_recovery_required() {
21919 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21920 let state_path = root.join("node-state-v6.json");
21921 shared.state_path = Some(state_path.clone());
21922 let (_address, _record_id, materialization_id, materialization_root) =
21923 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21924 std::fs::write(
21925 materialization_root.join(".gate4agent-materialization-owner"),
21926 b"tampered",
21927 )
21928 .unwrap();
21929
21930 let error = match shared.resolve_record_materialization(
21931 &_record_id,
21932 &agent("claude"),
21933 SessionMode::Pty,
21934 Some(&receipt),
21935 None,
21936 None,
21937 ) {
21938 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21939 Err(error) => error,
21940 };
21941 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21942 assert_eq!(
21943 shared
21944 .materializations
21945 .lock()
21946 .unwrap_or_else(|poisoned| poisoned.into_inner())
21947 .get(&materialization_id)
21948 .unwrap()
21949 .state(),
21950 MaterializationState::RecoveryRequired,
21951 );
21952 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21953 assert_eq!(loaded.materializations.len(), 1);
21954 assert_eq!(
21955 loaded.materializations[0].state(),
21956 MaterializationState::RecoveryRequired,
21957 );
21958 drop(shared);
21959 drop(runtime);
21960 std::fs::remove_dir_all(root).unwrap();
21961 }
21962
21963 #[test]
21964 fn transient_record_materialization_resolution_failure_remains_ready() {
21965 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21966 let (_address, record_id, materialization_id, _materialization_root) =
21967 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21968 deny.store(true, Ordering::Release);
21969
21970 let error = match shared.resolve_record_materialization(
21971 &record_id,
21972 &agent("claude"),
21973 SessionMode::Pty,
21974 Some(&receipt),
21975 None,
21976 None,
21977 ) {
21978 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21979 Err(error) => error,
21980 };
21981 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21982 assert_eq!(
21983 shared
21984 .materializations
21985 .lock()
21986 .unwrap_or_else(|poisoned| poisoned.into_inner())
21987 .get(&materialization_id)
21988 .unwrap()
21989 .state(),
21990 MaterializationState::Ready,
21991 );
21992 drop(shared);
21993 drop(runtime);
21994 std::fs::remove_dir_all(root).unwrap();
21995 }
21996
21997 #[test]
21998 fn startup_missing_record_materialization_is_not_resumable() {
21999 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22000 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22001 shared
22002 .insert_record(ManagedSessionRecord {
22003 record_id: record_id.clone(),
22004 display_name: "missing materialization".to_owned(),
22005 provider: agent("claude"),
22006 mode: SessionMode::Pty,
22007 state: ManagedSessionState::Dormant,
22008 workspace_id: WorkspaceId::new("primary").unwrap(),
22009 canonical_root: opaque_windows_path(
22010 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22011 ),
22012 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22013 key: gate4agent_types::ProviderSessionKey::SessionId,
22014 id: "provider-session-missing-materialization".to_owned(),
22015 transcript_path: None,
22016 }),
22017 active_session: None,
22018 environment_profile: Some(receipt),
22019 bundle: None,
22020 context_id: None,
22021 context: None,
22022 exported_context: None,
22023 task_binding: None,
22024 created_at_unix_ms: 1,
22025 updated_at_unix_ms: 1,
22026 last_error: None,
22027 })
22028 .unwrap();
22029
22030 shared.reconcile_materializations();
22031 let record = shared.record(&record_id).unwrap();
22032 assert_eq!(record.state, ManagedSessionState::Unavailable);
22033 assert!(record.provider_session.is_none());
22034 assert_eq!(
22035 record.last_error.as_deref(),
22036 Some("environment-profile-unavailable"),
22037 );
22038 drop(shared);
22039 drop(runtime);
22040 std::fs::remove_dir_all(root).unwrap();
22041 }
22042
22043 #[tokio::test]
22044 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22045 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22046 let lease = install_managed_lease(&shared);
22047 let address = SessionAddress {
22048 workspace_id: WorkspaceId::new("primary").unwrap(),
22049 session: SessionKey {
22050 instance_id: AgentInstanceId(204),
22051 generation: SessionGeneration(1),
22052 },
22053 };
22054 let (overlay, materialization_guard) = shared
22055 .prepare_session_materialization(
22056 &address,
22057 &agent("claude"),
22058 SessionMode::Pty,
22059 Some(&receipt),
22060 None,
22061 None,
22062 Some(lease.lease_id.clone()),
22063 )
22064 .unwrap()
22065 .unwrap();
22066 let materialization_id = materialization_guard.id().unwrap().clone();
22067 let materialization_root = shared
22068 .materializations
22069 .lock()
22070 .unwrap_or_else(|poisoned| poisoned.into_inner())
22071 .get(&materialization_id)
22072 .unwrap()
22073 .root()
22074 .to_path_buf();
22075 let selection = shared
22076 .select_environment_profile(
22077 address.session.instance_id,
22078 &agent("claude"),
22079 SessionMode::Pty,
22080 Some(&receipt),
22081 )
22082 .unwrap()
22083 .unwrap();
22084 assert!(shared
22085 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22086 .unwrap()
22087 .is_none());
22088 shared.bind_session_with_materialization(
22089 &address,
22090 ProviderRuntimePolicy::raw_pty(),
22091 Some(receipt),
22092 None,
22093 None,
22094 Some(materialization_id),
22095 );
22096 selection.retain();
22097 materialization_guard.retain();
22098
22099 let blocked = shared
22100 .cleanup_managed_worktree(&lease.lease_id, false)
22101 .await
22102 .unwrap_err();
22103 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22104 assert!(materialization_root.is_dir());
22105 let removed = shared.remove_binding(&address).unwrap();
22106 shared
22107 .cleanup_session_owned_materialization(&address, &removed)
22108 .unwrap();
22109 assert!(!materialization_root.exists());
22110 drop(shared);
22111 drop(runtime);
22112 std::fs::remove_dir_all(root).unwrap();
22113 }
22114
22115 #[tokio::test]
22116 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22117 let git_root = temporary_workspace_root("raw-managed-inventory");
22118 let source = git_root.join("source");
22119 let allocation = git_root.join("allocation");
22120 std::fs::create_dir_all(&source).unwrap();
22121 std::fs::create_dir_all(&allocation).unwrap();
22122 run_test_git(&source, &["init"]);
22123 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22124 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22125 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22126 run_test_git(&source, &["add", "seed.txt"]);
22127 run_test_git(&source, &["commit", "-m", "seed"]);
22128 let profile = ManagedWorktreeProfile::new(
22129 WorktreeProfileId::new("default").unwrap(),
22130 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22131 &allocation,
22132 "gate4agent",
22133 "HEAD",
22134 ManagedWorktreeRetention::RemoveWhenReleased,
22135 )
22136 .unwrap();
22137 let workspace = WorkspaceConfig::new(
22138 WorkspaceId::new("primary").unwrap(),
22139 &source,
22140 )
22141 .unwrap()
22142 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22143 .with_managed_worktree_profile(profile.clone())
22144 .unwrap();
22145 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22146 materialization_test_shared_with_workspace(false, workspace);
22147 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22148 let source_text = source.to_string_lossy().into_owned();
22149 let target_text = target.to_string_lossy().into_owned();
22150 let created = create_git_worktree(
22151 &source_text,
22152 &target_text,
22153 "gate4agent/mw-raw-inventory",
22154 Some("HEAD"),
22155 )
22156 .await
22157 .unwrap();
22158 let lease = ManagedWorktreeLeaseRecord {
22159 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22160 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22161 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22162 profile_id: profile.profile_id().clone(),
22163 profile_revision: profile.revision().clone(),
22164 target_root: created.path.clone(),
22165 branch: "gate4agent/mw-raw-inventory".to_owned(),
22166 base_commit: created.head.clone(),
22167 expected_head: Some(created.head.clone()),
22168 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22169 state: ManagedWorktreeLeaseState::Ready,
22170 session_holders: Vec::new(),
22171 record_holders: Vec::new(),
22172 cleanup_failure: None,
22173 created_at_unix_ms: 1,
22174 updated_at_unix_ms: 1,
22175 };
22176 shared
22177 .workspaces
22178 .write()
22179 .unwrap_or_else(|poisoned| poisoned.into_inner())
22180 .insert(lease.workspace_id.clone(), created.path.clone());
22181 *shared
22182 .managed_worktrees
22183 .lock()
22184 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22185 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22186 let address = SessionAddress {
22187 workspace_id: lease.workspace_id.clone(),
22188 session: SessionKey {
22189 instance_id: AgentInstanceId(205),
22190 generation: SessionGeneration(1),
22191 },
22192 };
22193 let (overlay, materialization_guard) = shared
22194 .prepare_session_materialization(
22195 &address,
22196 &agent("claude"),
22197 SessionMode::Pty,
22198 Some(&receipt),
22199 None,
22200 None,
22201 Some(lease.lease_id.clone()),
22202 )
22203 .unwrap()
22204 .unwrap();
22205 let materialization_id = materialization_guard.id().unwrap().clone();
22206 let materialization_root = shared
22207 .materializations
22208 .lock()
22209 .unwrap_or_else(|poisoned| poisoned.into_inner())
22210 .get(&materialization_id)
22211 .unwrap()
22212 .root()
22213 .to_path_buf();
22214 let selection = shared
22215 .select_environment_profile(
22216 address.session.instance_id,
22217 &agent("claude"),
22218 SessionMode::Pty,
22219 Some(&receipt),
22220 )
22221 .unwrap()
22222 .unwrap();
22223 assert!(shared
22224 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22225 .unwrap()
22226 .is_none());
22227 let record_id = shared
22228 .bind_spawn_session_with_materialization(
22229 &address,
22230 agent("claude"),
22231 SessionMode::Pty,
22232 ProviderRuntimePolicy::raw_pty(),
22233 SpawnRecordPolicy::Always,
22234 Some(receipt.clone()),
22235 None,
22236 None,
22237 Some(materialization_id.clone()),
22238 )
22239 .unwrap()
22240 .unwrap();
22241 selection.retain();
22242 materialization_guard.retain();
22243 let record = shared.record(&record_id).unwrap();
22244 assert_eq!(record.state, ManagedSessionState::Live);
22245 assert!(record.provider_session.is_none());
22246 assert!(record.environment_profile.is_none());
22247 assert!(record.bundle.is_none());
22248 assert!(record.context_id.is_none());
22249 assert!(record.context.is_none());
22250 assert_eq!(record.active_session.as_ref(), Some(&address));
22251 assert_eq!(
22252 shared
22253 .materializations
22254 .lock()
22255 .unwrap_or_else(|poisoned| poisoned.into_inner())
22256 .get(&materialization_id)
22257 .unwrap()
22258 .owner(),
22259 &MaterializationOwner::Session {
22260 incarnation_id: shared.incarnation_id,
22261 instance_id: address.session.instance_id,
22262 generation: address.session.generation,
22263 },
22264 );
22265 {
22266 let mut bindings = shared
22267 .session_bindings
22268 .lock()
22269 .unwrap_or_else(|poisoned| poisoned.into_inner());
22270 bindings
22271 .get_mut(&address.session.instance_id)
22272 .unwrap()
22273 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22274 }
22275 let lease_snapshot = shared
22276 .managed_worktrees
22277 .lock()
22278 .unwrap_or_else(|poisoned| poisoned.into_inner())
22279 .bind_session(
22280 &lease.lease_id,
22281 ManagedWorktreeSessionHolder {
22282 incarnation_id: shared.incarnation_id,
22283 instance_id: address.session.instance_id,
22284 generation: address.session.generation,
22285 },
22286 None,
22287 unix_time_ms(),
22288 )
22289 .unwrap();
22290 assert_eq!(lease_snapshot.active_session_count, 1);
22291 assert_eq!(lease_snapshot.managed_record_count, 0);
22292 shared.persist_state().unwrap();
22293 assert!(shared
22294 .managed_worktrees
22295 .lock()
22296 .unwrap_or_else(|poisoned| poisoned.into_inner())
22297 .get(&lease.lease_id)
22298 .unwrap()
22299 .record_holders
22300 .is_empty());
22301 shared
22302 .handle
22303 .dispatch(shared.prepare_command(ControlCommand::Register {
22304 instance_id: address.session.instance_id,
22305 agent_id: agent("claude"),
22306 transport: TransportKind::Pty,
22307 }))
22308 .unwrap();
22309 runtime.tick().await;
22310 shared.reconcile_managed_record(
22311 &record_id,
22312 &address,
22313 &ControlEvent {
22314 sequence: 1,
22315 command_id: None,
22316 instance_id: address.session.instance_id,
22317 generation: address.session.generation,
22318 event: ControlEventKind::Removed,
22319 },
22320 false,
22321 false,
22322 );
22323 let remove = shared.remove_session(&address);
22324 let drive_runtime = async {
22325 runtime.tick().await;
22326 };
22327 let (removed, ()) = tokio::join!(remove, drive_runtime);
22328 removed.unwrap();
22329
22330 assert!(!materialization_root.exists());
22331 assert!(!Path::new(&created.path).exists());
22332 assert_eq!(
22333 shared
22334 .managed_worktrees
22335 .lock()
22336 .unwrap_or_else(|poisoned| poisoned.into_inner())
22337 .get(&lease.lease_id)
22338 .unwrap()
22339 .state,
22340 ManagedWorktreeLeaseState::Removed,
22341 );
22342 let renamed = shared
22343 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22344 .unwrap();
22345 assert_eq!(renamed.record_id, record_id);
22346 assert_eq!(renamed.display_name, "raw managed inventory");
22347 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22348 assert!(renamed.active_session.is_none());
22349
22350 drop(shared);
22351 drop(runtime);
22352 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22353 std::fs::remove_dir_all(git_root).unwrap();
22354 }
22355
22356 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22357 let root = std::env::current_dir().unwrap()
22358 .join("managed-test-target")
22359 .to_string_lossy()
22360 .into_owned();
22361 let lease = ManagedWorktreeLeaseRecord {
22362 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22363 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22364 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22365 profile_id: WorktreeProfileId::new("default").unwrap(),
22366 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22367 target_root: root.clone(),
22368 branch: "gate4agent/mw-test".to_owned(),
22369 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22370 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22371 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22372 state: ManagedWorktreeLeaseState::Ready,
22373 session_holders: Vec::new(),
22374 record_holders: Vec::new(),
22375 cleanup_failure: None,
22376 created_at_unix_ms: 1,
22377 updated_at_unix_ms: 1,
22378 };
22379 shared.workspaces.write()
22380 .unwrap_or_else(|poisoned| poisoned.into_inner())
22381 .insert(lease.workspace_id.clone(), root);
22382 *shared.managed_worktrees.lock()
22383 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22384 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22385 lease
22386 }
22387
22388 #[cfg(feature = "fixture")]
22389 #[test]
22390 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22391 let mut shared = terminal_test_shared();
22392 shared.fixture_semantic_hook_policy = true;
22393 let lease = install_managed_lease(&shared);
22394 let address = SessionAddress {
22395 workspace_id: lease.workspace_id.clone(),
22396 session: SessionKey {
22397 instance_id: AgentInstanceId(206),
22398 generation: SessionGeneration(1),
22399 },
22400 };
22401 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22402 let effective_runtime_policy =
22403 shared.effective_spawn_runtime_policy(pre_admission_policy);
22404 assert_ne!(effective_runtime_policy, pre_admission_policy);
22405 assert!(effective_runtime_policy.provider_session_identity);
22406
22407 let record_id = shared
22408 .bind_spawn_session(
22409 &address,
22410 agent("claude"),
22411 SessionMode::Pty,
22412 effective_runtime_policy,
22413 SpawnRecordPolicy::Always,
22414 None,
22415 )
22416 .unwrap()
22417 .unwrap();
22418 assert_eq!(
22419 shared.record(&record_id).unwrap().state,
22420 ManagedSessionState::IdentityPending,
22421 );
22422 let (record_holder, raw_inventory_record_id) = {
22423 let mut bindings = shared
22424 .session_bindings
22425 .lock()
22426 .unwrap_or_else(|poisoned| poisoned.into_inner());
22427 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22428 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22429 managed_spawn_record_ownership(binding, effective_runtime_policy)
22430 };
22431 assert_eq!(record_holder.as_ref(), Some(&record_id));
22432 assert!(raw_inventory_record_id.is_none());
22433 let snapshot = shared
22434 .managed_worktrees
22435 .lock()
22436 .unwrap_or_else(|poisoned| poisoned.into_inner())
22437 .bind_session(
22438 &lease.lease_id,
22439 ManagedWorktreeSessionHolder {
22440 incarnation_id: shared.incarnation_id,
22441 instance_id: address.session.instance_id,
22442 generation: address.session.generation,
22443 },
22444 record_holder,
22445 unix_time_ms(),
22446 )
22447 .unwrap();
22448 assert_eq!(snapshot.managed_record_count, 1);
22449 assert_eq!(
22450 shared
22451 .managed_worktrees
22452 .lock()
22453 .unwrap_or_else(|poisoned| poisoned.into_inner())
22454 .get(&lease.lease_id)
22455 .unwrap()
22456 .record_holders,
22457 vec![record_id.clone()],
22458 );
22459
22460 let identity = ProviderSessionIdentity {
22461 key: ProviderSessionKey::SessionId,
22462 id: "monitoring-fixture-managed-session".to_owned(),
22463 transcript_path: None,
22464 };
22465 shared.publish_control(ControlEvent {
22466 sequence: 1,
22467 command_id: None,
22468 instance_id: address.session.instance_id,
22469 generation: address.session.generation,
22470 event: ControlEventKind::ProviderEvent {
22471 sequence: 1,
22472 source: gate4agent_types::ProviderSource {
22473 family: AdapterFamily::Hook,
22474 binding: AdapterBinding::new(
22475 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22476 "fixture/v1",
22477 gate4agent_types::AdapterVerification::SyntheticFixture,
22478 )
22479 .unwrap(),
22480 },
22481 source_sequence: 1,
22482 event: ProviderEvent::SessionIdentityObserved {
22483 identity: identity.clone(),
22484 },
22485 },
22486 });
22487
22488 let live = shared.record(&record_id).unwrap();
22489 assert_eq!(live.state, ManagedSessionState::Live);
22490 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22491 assert_eq!(live.active_session.as_ref(), Some(&address));
22492 assert_eq!(
22493 shared
22494 .managed_worktrees
22495 .lock()
22496 .unwrap_or_else(|poisoned| poisoned.into_inner())
22497 .get(&lease.lease_id)
22498 .unwrap()
22499 .record_holders,
22500 vec![record_id],
22501 );
22502 }
22503
22504 fn managed_v2_test_shared(
22505 allocation_root: &Path,
22506 state_path: Option<PathBuf>,
22507 profile_revision: &str,
22508 ) -> NodeShared {
22509 let profile = ManagedWorktreeProfile::new(
22510 WorktreeProfileId::new("default").unwrap(),
22511 WorktreeProfileRevision::new(profile_revision).unwrap(),
22512 allocation_root,
22513 "gate4agent",
22514 "HEAD",
22515 ManagedWorktreeRetention::RemoveWhenReleased,
22516 )
22517 .unwrap();
22518 let workspace = WorkspaceConfig::new(
22519 WorkspaceId::new("primary").unwrap(),
22520 std::env::current_dir().unwrap(),
22521 )
22522 .unwrap()
22523 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22524 .with_managed_worktree_profile(profile)
22525 .unwrap();
22526 let catalog = active_registry().unwrap();
22527 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22528 let mut shared = NodeShared::new(
22529 handle,
22530 "fixture-token".to_owned(),
22531 NodeId::new("node-terminal-test").unwrap(),
22532 vec![workspace],
22533 vec![agent("claude")],
22534 );
22535 shared.state_path = state_path;
22536 shared
22537 }
22538
22539 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22540 let mut spawn_spec = spawn_spec_fixture();
22541 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22542 ManagedWorktreeSpawnRequestV2 {
22543 spawn_spec,
22544 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22545 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22546 }
22547 }
22548
22549 #[test]
22550 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22551 let shared = terminal_test_shared();
22552 let mut lease = install_managed_lease(&shared);
22553 let session = SessionAddress {
22554 workspace_id: lease.workspace_id.clone(),
22555 session: SessionKey {
22556 instance_id: AgentInstanceId(73),
22557 generation: SessionGeneration(4),
22558 },
22559 };
22560 lease.state = ManagedWorktreeLeaseState::InUse;
22561 lease.session_holders.push(ManagedWorktreeSessionHolder {
22562 incarnation_id: shared.incarnation_id,
22563 instance_id: session.session.instance_id,
22564 generation: session.session.generation,
22565 });
22566 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22567 assert!(resolved.target.worktree_id.is_none());
22568
22569 let receipt = managed_spawn_receipt(
22570 &resolved,
22571 shared.incarnation_id,
22572 session.clone(),
22573 lease.snapshot(),
22574 None,
22575 None,
22576 None,
22577 );
22578 assert!(resolved.target.worktree_id.is_none());
22579 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22580 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22581 assert_eq!(receipt.spawn.session, session);
22582 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22583 let encoded = serde_json::to_vec(&receipt).unwrap();
22584 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22585 assert_eq!(decoded, receipt);
22586 }
22587
22588 #[tokio::test]
22589 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22590 let root = temporary_workspace_root("managed-v2-revision");
22591 let allocation = root.join("allocation");
22592 std::fs::create_dir_all(&allocation).unwrap();
22593 let shared = managed_v2_test_shared(
22594 &allocation,
22595 Some(root.join("node-state.json")),
22596 "v1",
22597 );
22598 let mut request = managed_v2_request("managed-v2-revision");
22599 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22600
22601 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22602
22603 assert_eq!(
22604 failure.code,
22605 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22606 );
22607 assert!(shared
22608 .managed_worktrees
22609 .lock()
22610 .unwrap_or_else(|poisoned| poisoned.into_inner())
22611 .records()
22612 .is_empty());
22613 assert!(shared
22614 .managed_spawn_replays
22615 .lock()
22616 .unwrap_or_else(|poisoned| poisoned.into_inner())
22617 .is_empty());
22618 std::fs::remove_dir_all(root).unwrap();
22619 }
22620
22621 #[test]
22622 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22623 let root = temporary_workspace_root("managed-v2-concurrent");
22624 let allocation = root.join("allocation");
22625 std::fs::create_dir_all(&allocation).unwrap();
22626 let shared = Arc::new(managed_v2_test_shared(
22627 &allocation,
22628 Some(root.join("node-state.json")),
22629 "v1",
22630 ));
22631 let request = managed_v2_request("managed-v2-concurrent");
22632 let barrier = Arc::new(std::sync::Barrier::new(3));
22633 let mut joins = Vec::new();
22634 for _ in 0..2 {
22635 let shared = Arc::clone(&shared);
22636 let request = request.clone();
22637 let barrier = Arc::clone(&barrier);
22638 joins.push(std::thread::spawn(move || {
22639 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22640 barrier.wait();
22641 shared.acquire_durable_managed_spawn_v2(&request, digest)
22642 }));
22643 }
22644 barrier.wait();
22645 let results = joins
22646 .into_iter()
22647 .map(|join| join.join().unwrap())
22648 .collect::<Vec<_>>();
22649 assert_eq!(
22650 results
22651 .iter()
22652 .filter(|result| {
22653 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22654 })
22655 .count(),
22656 1,
22657 );
22658 assert_eq!(
22659 results
22660 .iter()
22661 .filter(|result| {
22662 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22663 })
22664 .count(),
22665 1,
22666 );
22667 assert_eq!(
22668 shared
22669 .managed_spawn_replays
22670 .lock()
22671 .unwrap_or_else(|poisoned| poisoned.into_inner())
22672 .len(),
22673 1,
22674 );
22675 assert!(shared
22676 .managed_worktrees
22677 .lock()
22678 .unwrap_or_else(|poisoned| poisoned.into_inner())
22679 .records()
22680 .is_empty());
22681 drop(shared);
22682 std::fs::remove_dir_all(root).unwrap();
22683 }
22684
22685 #[tokio::test]
22686 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22687 let root = temporary_workspace_root("managed-v2-linked-pending");
22688 let allocation = root.join("allocation");
22689 std::fs::create_dir_all(&allocation).unwrap();
22690 let state_path = root.join("node-state.json");
22691 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22692 let lease = install_managed_lease(&shared);
22693 let request = managed_v2_request("managed-v2-linked-pending");
22694 assert!(matches!(
22695 shared
22696 .acquire_durable_managed_spawn_v2(
22697 &request,
22698 managed_spawn_request_digest_v2(&request).unwrap(),
22699 )
22700 .unwrap(),
22701 DurableManagedSpawnReplayDecision::Reserved,
22702 ));
22703 {
22704 let _transaction = shared
22705 .state_transaction
22706 .lock()
22707 .unwrap_or_else(|poisoned| poisoned.into_inner());
22708 shared
22709 .link_durable_managed_spawn_v2_lease_locked(
22710 &request.spawn_spec.idempotency_key,
22711 &lease.lease_id,
22712 )
22713 .unwrap();
22714 shared.persist_state_locked().unwrap();
22715 }
22716 drop(shared);
22717
22718 let mut loaded = session_registry::load(
22719 Some(&state_path),
22720 &NodeId::new("node-terminal-test").unwrap(),
22721 )
22722 .unwrap();
22723 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22724 *reopened
22725 .managed_worktrees
22726 .lock()
22727 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22728 ManagedWorktreeRegistry::from_records(
22729 std::mem::take(&mut loaded.managed_worktrees),
22730 std::mem::take(&mut loaded.managed_worktree_tombstones),
22731 )
22732 .unwrap();
22733 *reopened
22734 .managed_spawn_replays
22735 .lock()
22736 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22737 .managed_spawn_replays
22738 .into_iter()
22739 .map(|record| (record.idempotency_key.clone(), record))
22740 .collect();
22741 let lease_count = reopened
22742 .managed_worktrees
22743 .lock()
22744 .unwrap_or_else(|poisoned| poisoned.into_inner())
22745 .records()
22746 .len();
22747
22748 let failure = reopened
22749 .spawn_managed_worktree_v2(request)
22750 .await
22751 .unwrap_err();
22752
22753 assert_eq!(
22754 failure.code,
22755 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22756 );
22757 assert_eq!(
22758 reopened
22759 .managed_worktrees
22760 .lock()
22761 .unwrap_or_else(|poisoned| poisoned.into_inner())
22762 .records()
22763 .len(),
22764 lease_count,
22765 );
22766 std::fs::remove_dir_all(root).unwrap();
22767 }
22768
22769 #[tokio::test]
22770 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22771 let root = temporary_workspace_root("managed-v2-reopen");
22772 let allocation = root.join("allocation");
22773 std::fs::create_dir_all(&allocation).unwrap();
22774 let state_path = root.join("node-state.json");
22775 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22776 let lease = install_managed_lease(&shared);
22777 let request = managed_v2_request("managed-v2-reopen");
22778 let session = SessionAddress {
22779 workspace_id: lease.workspace_id.clone(),
22780 session: SessionKey {
22781 instance_id: AgentInstanceId(91),
22782 generation: SessionGeneration(1),
22783 },
22784 };
22785 let snapshot = {
22786 let mut registry = shared
22787 .managed_worktrees
22788 .lock()
22789 .unwrap_or_else(|poisoned| poisoned.into_inner());
22790 let current = registry.get_mut(&lease.lease_id).unwrap();
22791 current.state = ManagedWorktreeLeaseState::InUse;
22792 current.session_holders.push(ManagedWorktreeSessionHolder {
22793 incarnation_id: shared.incarnation_id,
22794 instance_id: session.session.instance_id,
22795 generation: session.session.generation,
22796 });
22797 current.snapshot()
22798 };
22799 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22800 let receipt = managed_spawn_receipt(
22801 &resolved,
22802 shared.incarnation_id,
22803 session,
22804 snapshot,
22805 None,
22806 None,
22807 None,
22808 );
22809 assert!(matches!(
22810 shared
22811 .acquire_durable_managed_spawn_v2(
22812 &request,
22813 managed_spawn_request_digest_v2(&request).unwrap(),
22814 )
22815 .unwrap(),
22816 DurableManagedSpawnReplayDecision::Reserved,
22817 ));
22818 {
22819 let _transaction = shared
22820 .state_transaction
22821 .lock()
22822 .unwrap_or_else(|poisoned| poisoned.into_inner());
22823 shared
22824 .link_durable_managed_spawn_v2_lease_locked(
22825 &request.spawn_spec.idempotency_key,
22826 &lease.lease_id,
22827 )
22828 .unwrap();
22829 shared.persist_state_locked().unwrap();
22830 }
22831 shared
22832 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22833 .unwrap();
22834 let legacy_same_key = ManagedWorktreeSpawnRequest {
22835 spawn_spec: request.spawn_spec.clone(),
22836 worktree_profile_id: request.worktree_profile_id.clone(),
22837 };
22838 shared.remember_managed_spawn_attempt(
22839 Some(&request.spawn_spec.idempotency_key),
22840 legacy_same_key.clone(),
22841 Ok(receipt.clone()),
22842 Instant::now(),
22843 );
22844 assert!(shared
22845 .replay_managed_spawn(&legacy_same_key, Instant::now())
22846 .unwrap()
22847 .is_none());
22848 drop(shared);
22849
22850 let mut loaded = session_registry::load(
22851 Some(&state_path),
22852 &NodeId::new("node-terminal-test").unwrap(),
22853 )
22854 .unwrap();
22855 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22856 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22857 *reopened
22858 .managed_worktrees
22859 .lock()
22860 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22861 ManagedWorktreeRegistry::from_records(
22862 std::mem::take(&mut loaded.managed_worktrees),
22863 std::mem::take(&mut loaded.managed_worktree_tombstones),
22864 )
22865 .unwrap();
22866 *reopened
22867 .managed_spawn_replays
22868 .lock()
22869 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22870 .managed_spawn_replays
22871 .into_iter()
22872 .map(|record| (record.idempotency_key.clone(), record))
22873 .collect();
22874 let lease_count_before = reopened
22875 .managed_worktrees
22876 .lock()
22877 .unwrap_or_else(|poisoned| poisoned.into_inner())
22878 .records()
22879 .len();
22880
22881 assert_eq!(
22882 reopened
22883 .spawn_managed_worktree_v2(request.clone())
22884 .await
22885 .unwrap(),
22886 receipt,
22887 );
22888 assert_eq!(
22889 reopened
22890 .managed_worktrees
22891 .lock()
22892 .unwrap_or_else(|poisoned| poisoned.into_inner())
22893 .records()
22894 .len(),
22895 lease_count_before,
22896 );
22897 let mut changed = request;
22898 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22899 let failure = reopened
22900 .spawn_managed_worktree_v2(changed)
22901 .await
22902 .unwrap_err();
22903 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22904 assert_eq!(
22905 reopened
22906 .managed_worktrees
22907 .lock()
22908 .unwrap_or_else(|poisoned| poisoned.into_inner())
22909 .records()
22910 .len(),
22911 lease_count_before,
22912 );
22913 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22914 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22915 let failure = reopened
22916 .spawn_managed_worktree_v2(new_key)
22917 .await
22918 .unwrap_err();
22919 assert_eq!(
22920 failure.code,
22921 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22922 );
22923 assert_eq!(
22924 reopened
22925 .managed_worktrees
22926 .lock()
22927 .unwrap_or_else(|poisoned| poisoned.into_inner())
22928 .records()
22929 .len(),
22930 lease_count_before,
22931 );
22932 assert_eq!(
22933 reopened
22934 .managed_spawn_replays
22935 .lock()
22936 .unwrap_or_else(|poisoned| poisoned.into_inner())
22937 .len(),
22938 1,
22939 );
22940 std::fs::remove_dir_all(root).unwrap();
22941 }
22942
22943 #[test]
22944 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22945 let shared = terminal_test_shared();
22946 let request = ManagedWorktreeSpawnRequest {
22947 spawn_spec: spawn_spec_fixture(),
22948 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22949 };
22950 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22951 shared.remember_managed_spawn(
22952 request.clone(),
22953 Err(expected.clone()),
22954 Instant::now(),
22955 );
22956
22957 assert_eq!(
22958 shared
22959 .replay_managed_spawn(&request, Instant::now())
22960 .unwrap()
22961 .unwrap(),
22962 Err(expected),
22963 );
22964 assert!(shared
22965 .managed_spawn_replays
22966 .lock()
22967 .unwrap_or_else(|poisoned| poisoned.into_inner())
22968 .is_empty());
22969 }
22970
22971 #[tokio::test]
22972 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22973 let shared = terminal_test_shared();
22974 let lease = install_managed_lease(&shared);
22975 let raw = shared.spawn_session(
22976 lease.workspace_id.clone(),
22977 agent("claude"),
22978 SessionMode::Pty,
22979 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22980 None,
22981 ).await.unwrap_err();
22982 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22983 let mut spec = spawn_spec_fixture();
22984 spec.target.workspace_id = lease.workspace_id.clone();
22985 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22986 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22987 let mut selected = spawn_spec_fixture();
22988 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22989 selected.target.worktree_id = Some(lease.workspace_id);
22990 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22991 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22992 assert!(shared.handle.snapshot().sessions.is_empty());
22993 }
22994
22995 #[test]
22996 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22997 let shared = terminal_test_shared();
22998 let lease = install_managed_lease(&shared);
22999 let snapshot = snapshot_for_wire(
23000 &shared, true, true, true, false, true, true, false, false, true,
23001 );
23002 assert!(snapshot.managed_worktrees.is_empty());
23003 assert!(snapshot.workspaces.iter().all(|workspace| {
23004 workspace.managed_worktree_profiles.is_none()
23005 }));
23006 let mut reply = ResponseEnvelope {
23007 request_id: 1,
23008 result: Ok(NodeResponse::Resync {
23009 event_sequence: 1,
23010 oldest_available_sequence: 1,
23011 snapshot: shared.snapshot(),
23012 events: vec![NodeEventEnvelope {
23013 sequence: 1,
23014 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23015 }],
23016 }),
23017 };
23018 project_response_without_managed_worktrees(&mut reply);
23019 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23020 panic!("projection changed the response kind");
23021 };
23022 assert!(snapshot.managed_worktrees.is_empty());
23023 assert!(events.is_empty());
23024 let mut git = git_snapshot_for_parser();
23025 git.branch = Some(lease.branch.clone());
23026 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23027 lease_id: lease.lease_id.clone(),
23028 source_workspace_id: lease.source_workspace_id.clone(),
23029 branch: lease.branch.clone(),
23030 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23031 active_session_count: 1,
23032 managed_record_count: 0,
23033 });
23034 let mut inspection_reply = ResponseEnvelope {
23035 request_id: 2,
23036 result: Ok(NodeResponse::WorkspaceInspected {
23037 inspection: WorkspaceInspection {
23038 workspace_id: lease.workspace_id.clone(),
23039 entries: Vec::new(),
23040 tree_truncated: false,
23041 git,
23042 truncation: None,
23043 },
23044 }),
23045 };
23046 project_response_without_managed_worktrees(&mut inspection_reply);
23047 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23048 panic!("projection changed the inspection response kind");
23049 };
23050 assert!(inspection.git.managed_worktree.is_none());
23051 let failure = managed_git_worktree_failure(GitWorktreeError {
23052 kind: GitWorktreeErrorKind::Failed,
23053 message: r"git failed at C:\private\managed-target".to_owned(),
23054 });
23055 assert!(!failure.message.contains("private"));
23056 assert!(!failure.message.contains("managed-target"));
23057 }
23058
23059 #[tokio::test]
23060 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23061 let root = temporary_workspace_root("managed-restart");
23062 let source = root.join("source");
23063 let allocation = root.join("allocation");
23064 std::fs::create_dir_all(&source).unwrap();
23065 std::fs::create_dir_all(&allocation).unwrap();
23066 run_test_git(&source, &["init"]);
23067 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23068 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23069 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23070 run_test_git(&source, &["add", "seed.txt"]);
23071 run_test_git(&source, &["commit", "-m", "seed"]);
23072 let profile = ManagedWorktreeProfile::new(
23073 WorktreeProfileId::new("default").unwrap(),
23074 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23075 &allocation,
23076 "gate4agent",
23077 "HEAD",
23078 ManagedWorktreeRetention::Retain,
23079 ).unwrap();
23080 let workspace = WorkspaceConfig::new(
23081 WorkspaceId::new("primary").unwrap(),
23082 &source,
23083 ).unwrap()
23084 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23085 .with_managed_worktree_profile(profile.clone()).unwrap();
23086 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23087 let source_text = workspace.canonical_root().to_owned();
23088 let target_text = target.to_string_lossy().into_owned();
23089 let created = create_git_worktree(
23090 &source_text,
23091 &target_text,
23092 "gate4agent/mw-restart",
23093 Some("HEAD"),
23094 ).await.unwrap();
23095 let lease = ManagedWorktreeLeaseRecord {
23096 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23097 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23098 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23099 profile_id: profile.profile_id().clone(),
23100 profile_revision: profile.revision().clone(),
23101 target_root: created.path.clone(),
23102 branch: "gate4agent/mw-restart".to_owned(),
23103 base_commit: created.head.clone(),
23104 expected_head: None,
23105 retention: ManagedWorktreeRetention::Retain,
23106 state: ManagedWorktreeLeaseState::Allocating,
23107 session_holders: Vec::new(),
23108 record_holders: Vec::new(),
23109 cleanup_failure: None,
23110 created_at_unix_ms: 1,
23111 updated_at_unix_ms: 1,
23112 };
23113 let catalog = active_registry().unwrap();
23114 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23115 let shared = NodeShared::new_with_incarnation(
23116 handle,
23117 "fixture-token".to_owned(),
23118 NodeId::new("node-managed-restart").unwrap(),
23119 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23120 vec![workspace],
23121 vec![agent("claude")],
23122 ProviderRuntimeStatuses::default(),
23123 None,
23124 Vec::new(),
23125 Vec::new(),
23126 SpawnProfileRegistry::default(),
23127 None,
23128 None,
23129 None,
23130 None,
23131 Vec::new(),
23132 vec![lease.clone()],
23133 Vec::new(),
23134 Vec::new(),
23135 None,
23136 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23137 );
23138 shared.reconcile_managed_worktrees().await;
23139 let retained = shared.managed_worktrees.lock()
23140 .unwrap_or_else(|poisoned| poisoned.into_inner())
23141 .get(&lease.lease_id).unwrap().clone();
23142 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23143 assert!(shared.workspaces.read()
23144 .unwrap_or_else(|poisoned| poisoned.into_inner())
23145 .contains_key(&lease.workspace_id));
23146 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23147 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23148 assert!(!Path::new(&created.path).exists());
23149 std::fs::remove_dir_all(root).unwrap();
23150 }
23151
23152 fn run_test_git(root: &Path, arguments: &[&str]) {
23153 let output = std::process::Command::new("git")
23154 .arg("-C").arg(root).args(arguments).output().unwrap();
23155 assert!(
23156 output.status.success(),
23157 "git {:?} failed: {}",
23158 arguments,
23159 String::from_utf8_lossy(&output.stderr),
23160 );
23161 }
23162
23163 #[tokio::test]
23164 async fn spawn_spec_validation_fails_before_session_mutation() {
23165 let shared = terminal_test_shared();
23166 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23167 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23168 }));
23169 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23170 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23171 }));
23172 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23173 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23174 }));
23175
23176 let mut target_mismatch = spawn_spec_fixture();
23177 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23178 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23179 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23180
23181 let mut unknown_profile = spawn_spec_fixture();
23182 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23183 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23184 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23185 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23186
23187 let mut revision_mismatch = spawn_spec_fixture();
23188 revision_mismatch.idempotency_key =
23189 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23190 revision_mismatch.expected_profile_revision =
23191 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23192 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23193 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23194
23195 let mut unsupported_materializer = spawn_spec_fixture();
23196 unsupported_materializer.idempotency_key =
23197 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23198 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23199 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23200 };
23201 let failure = shared
23202 .spawn_from_spec(unsupported_materializer)
23203 .await
23204 .unwrap_err();
23205 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23206
23207 let failure = shared
23208 .spawn_session_with_deadline(
23209 WorkspaceId::new("primary").unwrap(),
23210 agent("claude"),
23211 SessionMode::Pty,
23212 gate4agent_types::TerminalSize {
23213 rows: 24,
23214 columns: 80,
23215 },
23216 None,
23217 None,
23218 None,
23219 None,
23220 None,
23221 None,
23222 SpawnRecordPolicy::ProviderIdentityOnly,
23223 Some(Instant::now()),
23224 &[],
23225 None,
23226 ApprovalLevel::default(),
23227 None,
23228 None,
23229 )
23230 .await
23231 .unwrap_err();
23232 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23233 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23234 assert!(shared.handle.snapshot().sessions.is_empty());
23235 }
23236
23237 #[test]
23238 fn worktree_selection_requires_its_separate_negotiated_capability() {
23239 let mut spec = spawn_spec_fixture();
23240 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23241 let request = NodeRequest::SpawnSpec { spec };
23242 let spawn_spec_capability =
23243 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23244 assert!(request_uses_unnegotiated_capability(
23245 &request,
23246 &[spawn_spec_capability.clone()],
23247 ));
23248 let worktree_capability =
23249 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23250 assert!(request_uses_unnegotiated_capability(
23251 &request,
23252 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23253 ));
23254 let profile_revision_capability =
23255 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23256 assert!(request_uses_unnegotiated_capability(
23257 &request,
23258 &[
23259 spawn_spec_capability.clone(),
23260 worktree_capability.clone(),
23261 profile_revision_capability.clone(),
23262 ],
23263 ));
23264 let bundle_materialization_capability =
23271 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23272 assert!(request_uses_unnegotiated_capability(
23273 &request,
23274 &[
23275 spawn_spec_capability.clone(),
23276 worktree_capability.clone(),
23277 profile_revision_capability.clone(),
23278 bundle_materialization_capability.clone(),
23279 ],
23280 ));
23281 let history_context_pack_capability =
23282 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23283 assert!(!request_uses_unnegotiated_capability(
23284 &request,
23285 &[
23286 spawn_spec_capability,
23287 worktree_capability,
23288 profile_revision_capability,
23289 bundle_materialization_capability,
23290 history_context_pack_capability,
23291 ],
23292 ));
23293 }
23294
23295 #[tokio::test]
23296 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23297 let mut shared = terminal_test_shared();
23298 let spec = spawn_spec_fixture();
23299 let receipt = shared
23300 .resolve_spawn_spec(&spec)
23301 .unwrap()
23302 .receipt(shared.incarnation_id, terminal_address(1));
23303 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23304
23305 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23306 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23307 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23308
23309 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23310
23311 let mut revision_conflicting = spec.clone();
23312 revision_conflicting.expected_profile_revision =
23313 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23314 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23315 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23316
23317 let mut stale_cache_miss = spec.clone();
23318 stale_cache_miss.idempotency_key =
23319 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23320 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23321 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23322
23323 let mut conflicting = spec;
23324 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23325 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23326 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23327 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23328 assert!(shared.handle.snapshot().sessions.is_empty());
23329 }
23330
23331 #[tokio::test]
23332 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23333 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23334 let mut spec = spawn_spec_fixture();
23335 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23336 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23337 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23338
23339 let failure = shared
23340 .create_worktree(
23341 WorkspaceId::new("primary").unwrap(),
23342 WorkspaceId::new("new-worktree").unwrap(),
23343 "not-an-absolute-path".to_owned(),
23344 "invalid branch".to_owned(),
23345 None,
23346 )
23347 .await
23348 .unwrap_err();
23349 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23350
23351 let failure = shared
23352 .remove_worktree(
23353 WorkspaceId::new("primary").unwrap(),
23354 "not-an-absolute-path".to_owned(),
23355 )
23356 .await
23357 .unwrap_err();
23358 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23359 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23360 assert!(shared.handle.snapshot().sessions.is_empty());
23361 }
23362
23363 #[tokio::test]
23364 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23365 let root = temporary_workspace_root("manual-worktree-compensation");
23366 let source = root.join("source");
23367 let target = root.join("target");
23368 let invalid_state_parent = root.join("state-parent-is-a-file");
23369 let invalid_state_path = invalid_state_parent.join("state.json");
23370 std::fs::create_dir_all(&source).unwrap();
23371 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23372 run_test_git(&source, &["init"]);
23373 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23374 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23375 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23376 run_test_git(&source, &["add", "seed.txt"]);
23377 run_test_git(&source, &["commit", "-m", "seed"]);
23378
23379 let catalog = active_registry().unwrap();
23380 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23381 let workspace = WorkspaceConfig::new(
23382 WorkspaceId::new("primary").unwrap(),
23383 &source,
23384 ).unwrap();
23385 let mut shared = NodeShared::new(
23386 handle,
23387 "fixture-token".to_owned(),
23388 NodeId::new("node-worktree-compensation").unwrap(),
23389 vec![workspace],
23390 vec![agent("claude")],
23391 );
23392 shared.state_path = Some(invalid_state_path);
23393
23394 let error = shared.create_worktree(
23395 WorkspaceId::new("primary").unwrap(),
23396 WorkspaceId::new("compensated").unwrap(),
23397 target.to_string_lossy().into_owned(),
23398 "gate4agent/compensated".to_owned(),
23399 Some("HEAD".to_owned()),
23400 ).await.unwrap_err();
23401
23402 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23403 assert!(!target.exists());
23404 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23405 .iter()
23406 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23407 std::fs::remove_dir_all(root).unwrap();
23408 }
23409
23410 #[test]
23411 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23412 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23413 NativeGitWorktreeSnapshot {
23414 path: path.to_owned(),
23415 head: "0123456789abcdef".to_owned(),
23416 branch: Some("refs/heads/test".to_owned()),
23417 is_bare,
23418 is_main,
23419 locked: false,
23420 lock_reason: None,
23421 prunable: false,
23422 prunable_reason: None,
23423 workspace_id: None,
23424 }
23425 }
23426
23427 let source = r"C:\repo";
23428 let selected = r"C:\repo-worktree";
23429 let listed = vec![
23430 worktree(source, true, false),
23431 worktree(selected, false, false),
23432 ];
23433 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23434
23435 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23436 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23437
23438 let bare = vec![
23439 worktree(source, true, false),
23440 worktree(selected, false, true),
23441 ];
23442 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23443 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23444
23445 let foreign_failure =
23446 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23447 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23448 }
23449
23450 fn terminal_frame(sequence: u64) -> TerminalFrame {
23451 TerminalFrame {
23452 sequence,
23453 size: gate4agent_types::TerminalSize {
23454 rows: 24,
23455 columns: 80,
23456 },
23457 cursor_row: 2,
23458 cursor_column: 3,
23459 contents: format!("frame-{sequence}"),
23460 formatted: vec![1, 2, 3],
23461 scrollback_formatted: Vec::new(),
23462 alternate_screen: false,
23463 mouse_protocol_enabled: false,
23464 mouse_protocol_encoding: Default::default(),
23465 produced_at_unix_ms: 0,
23466 screen_state: gate4agent_types::PtyScreenState::default(),
23467 bracketed_paste: None,
23468 }
23469 }
23470
23471 #[test]
23472 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23473 let shared = terminal_test_shared();
23474 let mut receiver = shared.terminal_event_tx.subscribe();
23475 let original = terminal_address(1);
23476
23477 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23478 let first = receiver.try_recv().unwrap();
23479 assert_eq!(first.len(), 1);
23480 assert!(matches!(
23481 &first[0].event,
23482 NodeEvent::TerminalFrame { address, frame }
23483 if address == &original && frame.sequence == 7
23484 ));
23485
23486 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23487 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23488 assert!(matches!(
23489 receiver.try_recv(),
23490 Err(broadcast::error::TryRecvError::Empty)
23491 ));
23492
23493 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23494 let advanced = receiver.try_recv().unwrap();
23495 assert_eq!(advanced[0].sequence, 2);
23496
23497 let resumed = terminal_address(2);
23498 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23499 let reset = receiver.try_recv().unwrap();
23500 assert_eq!(reset[0].sequence, 3);
23501 assert!(matches!(
23502 &reset[0].event,
23503 NodeEvent::TerminalFrame { address, frame }
23504 if address == &resumed && frame.sequence == 1
23505 ));
23506 shared.clear_terminal_frame_watermark(&resumed);
23507 assert!(!shared
23508 .terminal_frame_watermarks
23509 .lock()
23510 .unwrap_or_else(|poisoned| poisoned.into_inner())
23511 .contains_key(&resumed.session.instance_id));
23512 }
23513
23514 #[test]
23515 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23516 let shared = terminal_test_shared();
23517 let mut receiver = shared.terminal_event_tx.subscribe();
23518 let address = terminal_address(1);
23519 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23520
23521 for sequence in 1..=count {
23522 shared.publish_terminal_frame_candidates(vec![(
23523 address.clone(),
23524 terminal_frame(sequence),
23525 )]);
23526 }
23527
23528 let history = shared
23529 .history
23530 .lock()
23531 .unwrap_or_else(|poisoned| poisoned.into_inner());
23532 assert!(history.events.is_empty());
23533 assert_eq!(history.last_sequence, count);
23534 assert_eq!(history.replay_floor_sequence, 1);
23535 drop(history);
23536 let NodeResponse::Resync {
23537 event_sequence,
23538 oldest_available_sequence,
23539 events,
23540 ..
23541 } = shared.resync(0) else {
23542 unreachable!("resync helper returned another response")
23543 };
23544 assert_eq!(event_sequence, count);
23545 assert_eq!(oldest_available_sequence, 1);
23546 assert!(events.is_empty());
23547 assert!(matches!(
23548 resync_required_event(&shared).event,
23549 NodeEvent::ResyncRequired {
23550 oldest_available_sequence: 1,
23551 }
23552 ));
23553 assert!(matches!(
23554 receiver.try_recv(),
23555 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23556 ));
23557 let latest = receiver.try_recv().unwrap();
23558 assert_eq!(latest.len(), 1);
23559 assert_eq!(latest[0].sequence, count);
23560 }
23561
23562 #[test]
23563 fn real_durable_eviction_advances_authoritative_replay_floor() {
23564 let shared = terminal_test_shared();
23565 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23566 shared.publish(NodeEvent::ControllerChanged { controller: None });
23567 }
23568
23569 let NodeResponse::Resync {
23570 event_sequence,
23571 oldest_available_sequence,
23572 events,
23573 ..
23574 } = shared.resync(0) else {
23575 unreachable!("resync helper returned another response")
23576 };
23577 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23578 assert_eq!(oldest_available_sequence, 2);
23579 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23580 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23581 assert!(matches!(
23582 resync_required_event(&shared).event,
23583 NodeEvent::ResyncRequired {
23584 oldest_available_sequence: 2,
23585 }
23586 ));
23587 }
23588
23589 #[test]
23590 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23591 let shared = terminal_test_shared();
23592 let without_capability = terminal_event_subscription(&shared, false);
23593 let with_capability = terminal_event_subscription(&shared, true);
23594 assert!(without_capability.is_none());
23595 assert!(with_capability.is_some());
23596 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23597 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23598 }));
23599 }
23600
23601 #[test]
23602 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23603 let mut pending = Vec::new();
23604 let mut event_sequence = 0_u64;
23605 for frame_sequence in 1..=4 {
23606 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23607 event_sequence += 1;
23608 let mut address = terminal_address(1);
23609 address.session.instance_id = AgentInstanceId(instance);
23610 queue_connection_event(
23611 &mut pending,
23612 NodeEventEnvelope {
23613 sequence: event_sequence,
23614 event: NodeEvent::TerminalFrame {
23615 address,
23616 frame: terminal_frame(frame_sequence),
23617 },
23618 },
23619 0,
23620 );
23621 }
23622 }
23623 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23624 pending.sort_unstable_by_key(|event| event.sequence);
23625
23626 let mut uninterrupted = pending.clone();
23627 let first_burst = connection_event_burst_len(uninterrupted.len());
23628 uninterrupted.drain(..first_burst);
23629 let second_burst = connection_event_burst_len(uninterrupted.len());
23630 uninterrupted.drain(..second_burst);
23631 assert!(uninterrupted.is_empty());
23632
23633 let (frame_tx, mut frame_rx) =
23634 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23635 frame_tx
23636 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23637 request_id: 77,
23638 request: NodeRequest::Snapshot,
23639 })))
23640 .unwrap();
23641 let burst_len = connection_event_burst_len(pending.len());
23642 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23643 pending.drain(..burst_len);
23644 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23645 assert!(matches!(
23646 frame_rx.try_recv(),
23647 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23648 request_id: 77,
23649 request: NodeRequest::Snapshot,
23650 })))
23651 ));
23652 }
23653
23654 #[test]
23655 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23656 let mut pending = Vec::new();
23657 for sequence in [7_u64, 8] {
23658 let mut address = terminal_address(1);
23659 address.session.instance_id = AgentInstanceId(sequence);
23660 queue_connection_event(
23661 &mut pending,
23662 NodeEventEnvelope {
23663 sequence,
23664 event: NodeEvent::TerminalFrame {
23665 address,
23666 frame: terminal_frame(sequence),
23667 },
23668 },
23669 0,
23670 );
23671 }
23672 let mut discard_through = 0;
23673 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23674 assert!(pending.is_empty());
23675
23676 let replayed = NodeEventEnvelope {
23677 sequence: 8,
23678 event: NodeEvent::TerminalFrame {
23679 address: terminal_address(1),
23680 frame: terminal_frame(8),
23681 },
23682 };
23683 let advanced = NodeEventEnvelope {
23684 sequence: 9,
23685 event: NodeEvent::TerminalFrame {
23686 address: terminal_address(2),
23687 frame: terminal_frame(1),
23688 },
23689 };
23690 queue_connection_event_batch(
23691 &mut pending,
23692 &[replayed, advanced.clone()],
23693 discard_through,
23694 );
23695 assert_eq!(pending, vec![advanced]);
23696 }
23697
23698 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23699 ManagedSessionRecord {
23700 record_id: SessionRecordId::new(record_id).unwrap(),
23701 display_name: format!("{provider} record"),
23702 provider: agent(provider),
23703 mode: SessionMode::Pty,
23704 state: ManagedSessionState::Unavailable,
23705 workspace_id: WorkspaceId::new("primary").unwrap(),
23706 canonical_root: opaque_windows_path(
23707 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23708 ),
23709 provider_session: None,
23710 active_session: None,
23711 environment_profile: None,
23712 bundle: None,
23713 context_id: None,
23714 context: None,
23715 exported_context: None,
23716 task_binding: None,
23717 created_at_unix_ms: 1,
23718 updated_at_unix_ms: 1,
23719 last_error: None,
23720 }
23721 }
23722
23723 fn retention_test_record(
23724 workspace_id: &str,
23725 record_id: &str,
23726 state: ManagedSessionState,
23727 updated_at_unix_ms: u64,
23728 ) -> ManagedSessionRecord {
23729 ManagedSessionRecord {
23730 record_id: SessionRecordId::new(record_id).unwrap(),
23731 display_name: format!("{record_id} display"),
23732 provider: agent("claude"),
23733 mode: SessionMode::Pty,
23734 state,
23735 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23736 canonical_root: opaque_windows_path(
23737 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23738 ),
23739 provider_session: None,
23740 active_session: None,
23741 environment_profile: None,
23742 bundle: None,
23743 context_id: None,
23744 context: None,
23745 exported_context: None,
23746 task_binding: None,
23747 created_at_unix_ms: updated_at_unix_ms,
23748 updated_at_unix_ms,
23749 last_error: None,
23750 }
23751 }
23752
23753 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23761 vec![
23762 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23763 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23764 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23765 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23766 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23767 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23768 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23769 retention_test_record(
23770 "workspace-a",
23771 "decoy-pending",
23772 ManagedSessionState::IdentityPending,
23773 now - 500_000,
23774 ),
23775 retention_test_record(
23776 "workspace-a",
23777 "decoy-dormant",
23778 ManagedSessionState::Dormant,
23779 now - 500_000,
23780 ),
23781 ]
23782 }
23783
23784 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23785 let root = temporary_workspace_root("session-record-retention");
23786 std::fs::create_dir_all(&root).unwrap();
23787 let catalog = active_registry().unwrap();
23788 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23789 let workspace_a =
23790 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23791 let workspace_b =
23792 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23793 let mut shared = NodeShared::new(
23794 handle,
23795 "fixture-token".to_owned(),
23796 NodeId::new("node-retention-test").unwrap(),
23797 vec![workspace_a, workspace_b],
23798 vec![agent("claude")],
23799 );
23800 shared.state_path = Some(root.join("node-state-v10.json"));
23801 let now = unix_time_ms();
23802 for record in retention_fixture_records(now) {
23803 shared.insert_record(record).unwrap();
23804 }
23805 shared
23806 .persist_state_locked()
23807 .expect("fixture-only, empty-history persist against a fresh temp state path");
23808 (shared, runtime, root, now)
23809 }
23810
23811 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23812 shared
23813 .history
23814 .lock()
23815 .unwrap_or_else(|poisoned| poisoned.into_inner())
23816 .events
23817 .iter()
23818 .filter_map(|envelope| match &envelope.event {
23819 NodeEvent::SessionRecordRemoved { record_id } => {
23820 Some(record_id.as_str().to_owned())
23821 }
23822 _ => None,
23823 })
23824 .collect()
23825 }
23826
23827 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23828 shared
23829 .session_records
23830 .lock()
23831 .unwrap_or_else(|poisoned| poisoned.into_inner())
23832 .records
23833 .keys()
23834 .map(|record_id| record_id.as_str().to_owned())
23835 .collect()
23836 }
23837
23838 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23839 values.iter().map(|value| (*value).to_owned()).collect()
23840 }
23841
23842 #[tokio::test]
23843 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23844 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23845 shared.session_record_retention = SessionRecordRetentionConfig {
23846 age_ms: 0,
23847 keep_per_workspace: 1,
23848 };
23849 shared.retire_unavailable_session_records().await;
23850
23851 assert_eq!(
23852 retention_remaining_record_ids(&shared),
23853 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23854 );
23855 assert_eq!(
23856 retention_removed_record_ids(&shared),
23857 owned(&["a1", "a2", "b1", "b2"]),
23858 );
23859 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23860
23861 drop(shared);
23862 drop(runtime);
23863 std::fs::remove_dir_all(root).unwrap();
23864 }
23865
23866 #[tokio::test]
23872 async fn session_record_retention_both_set_retires_only_the_intersection() {
23873 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23874 shared.session_record_retention = SessionRecordRetentionConfig {
23875 age_ms: 250_000,
23876 keep_per_workspace: 1,
23877 };
23878 shared.retire_unavailable_session_records().await;
23879
23880 assert_eq!(
23881 retention_remaining_record_ids(&shared),
23882 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23883 );
23884 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23885 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23886
23887 drop(shared);
23888 drop(runtime);
23889 std::fs::remove_dir_all(root).unwrap();
23890 }
23891
23892 #[tokio::test]
23893 async fn session_record_retention_both_disabled_is_a_no_op() {
23894 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23895 shared.session_record_retention = SessionRecordRetentionConfig::default();
23896 shared.retire_unavailable_session_records().await;
23897
23898 assert_eq!(
23899 retention_remaining_record_ids(&shared),
23900 owned(&[
23901 "a1", "a2", "a3", "b1", "b2", "b3",
23902 "decoy-live", "decoy-pending", "decoy-dormant",
23903 ]),
23904 );
23905 assert!(retention_removed_record_ids(&shared).is_empty());
23906 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23907
23908 drop(shared);
23909 drop(runtime);
23910 std::fs::remove_dir_all(root).unwrap();
23911 }
23912
23913 #[test]
23914 fn session_record_context_export_selects_one_exact_history_identity() {
23915 let candidates = vec![
23916 HistoryCandidateSummary {
23917 id: "candidate-other".to_owned(),
23918 session_id_hint: "provider-other".to_owned(),
23919 modified_at_unix_ms: Some(1),
23920 },
23921 HistoryCandidateSummary {
23922 id: "candidate-exact".to_owned(),
23923 session_id_hint: "provider-exact".to_owned(),
23924 modified_at_unix_ms: Some(2),
23925 },
23926 ];
23927 assert_eq!(
23928 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23929 "candidate-exact",
23930 );
23931 }
23932
23933 #[test]
23934 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23935 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23936 id: id.to_owned(),
23937 session_id_hint: hint.to_owned(),
23938 modified_at_unix_ms: None,
23939 };
23940 let missing = select_history_candidate(
23941 &[candidate("candidate-other", "provider-other")],
23942 Some("provider-exact"),
23943 )
23944 .unwrap_err();
23945 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23946 let ambiguous = select_history_candidate(
23947 &[
23948 candidate("candidate-a", "provider-exact"),
23949 candidate("candidate-b", "provider-exact"),
23950 ],
23951 Some("provider-exact"),
23952 )
23953 .unwrap_err();
23954 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23955 }
23956
23957 #[test]
23965 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23966 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23967 id: id.to_owned(),
23968 session_id_hint: hint.to_owned(),
23969 modified_at_unix_ms: None,
23970 };
23971 assert_eq!(
23972 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23973 .unwrap(),
23974 "candidate-sole",
23975 );
23976 let missing = select_history_candidate(&[], None).unwrap_err();
23977 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23978 let ambiguous = select_history_candidate(
23979 &[
23980 candidate("candidate-a", "sidecar-session-a"),
23981 candidate("candidate-b", "sidecar-session-b"),
23982 ],
23983 None,
23984 )
23985 .unwrap_err();
23986 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23987 }
23988
23989 #[test]
23990 fn session_record_context_export_revalidation_rejects_record_change() {
23991 let session = terminal_address(1);
23992 let identity = ProviderSessionIdentity {
23993 key: ProviderSessionKey::SessionId,
23994 id: "provider-exact".to_owned(),
23995 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23996 };
23997 let mut expected = record("claude", "record-context-export");
23998 expected.state = ManagedSessionState::Live;
23999 expected.provider_session = Some(identity.clone());
24000 expected.active_session = Some(session.clone());
24001 let binding = SessionBinding {
24002 workspace_id: session.workspace_id.clone(),
24003 generation: session.session.generation,
24004 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24005 pending_resume: None,
24006 record_id: Some(expected.record_id.clone()),
24007 managed_worktree_lease_id: None,
24008 environment_profile: None,
24009 bundle: None,
24010 context: None,
24011 materialization_id: None,
24012 };
24013 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24014 assert!(session_record_context_export_binding_is_exact(
24015 &expected,
24016 &expected,
24017 Some(&identity),
24018 &session,
24019 &binding,
24020 &expected.provider,
24021 ¤t_root,
24022 false,
24023 ));
24024 let mut changed = expected.clone();
24025 changed.updated_at_unix_ms += 1;
24026 assert!(!session_record_context_export_binding_is_exact(
24027 &expected,
24028 &changed,
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(
24041 &expected,
24042 &changed,
24043 Some(&identity),
24044 &session,
24045 &binding,
24046 &expected.provider,
24047 ¤t_root,
24048 true,
24049 ));
24050 let mut detached = expected.clone();
24051 detached.state = ManagedSessionState::Dormant;
24052 detached.active_session = None;
24053 detached.updated_at_unix_ms += 1;
24054 assert!(!session_record_context_export_binding_is_exact(
24055 &expected,
24056 &detached,
24057 Some(&identity),
24058 &session,
24059 &binding,
24060 &expected.provider,
24061 ¤t_root,
24062 false,
24063 ));
24064 assert!(session_record_context_export_binding_is_exact(
24065 &expected,
24066 &detached,
24067 Some(&identity),
24068 &session,
24069 &binding,
24070 &expected.provider,
24071 ¤t_root,
24072 true,
24073 ));
24074 }
24075
24076 #[test]
24083 fn session_record_context_export_binding_is_exact_without_identity() {
24084 let session = terminal_address(1);
24085 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24086 expected.state = ManagedSessionState::Live;
24087 expected.active_session = Some(session.clone());
24088 let binding = SessionBinding {
24089 workspace_id: session.workspace_id.clone(),
24090 generation: session.session.generation,
24091 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24092 pending_resume: None,
24093 record_id: Some(expected.record_id.clone()),
24094 managed_worktree_lease_id: None,
24095 environment_profile: None,
24096 bundle: None,
24097 context: None,
24098 materialization_id: None,
24099 };
24100 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24101 assert!(session_record_context_export_binding_is_exact(
24102 &expected,
24103 &expected,
24104 None,
24105 &session,
24106 &binding,
24107 &expected.provider,
24108 ¤t_root,
24109 false,
24110 ));
24111 let mut drifted = expected.clone();
24115 drifted.provider_session = Some(ProviderSessionIdentity {
24116 key: ProviderSessionKey::SessionId,
24117 id: "unexpected-identity".to_owned(),
24118 transcript_path: None,
24119 });
24120 assert!(!session_record_context_export_binding_is_exact(
24121 &expected,
24122 &drifted,
24123 None,
24124 &session,
24125 &binding,
24126 &expected.provider,
24127 ¤t_root,
24128 false,
24129 ));
24130 }
24131
24132 #[test]
24133 fn session_record_context_export_preflight_requires_live_usable_source() {
24134 let mut source = record("claude", "record-context-export-preflight");
24135 source.state = ManagedSessionState::Live;
24136 assert!(session_record_context_export_source_is_usable(
24137 &source,
24138 &SessionStatus::Running,
24139 false,
24140 ));
24141 assert!(session_record_context_export_source_is_usable(
24142 &source,
24143 &SessionStatus::Exited { exit_code: Some(0) },
24144 false,
24145 ));
24146
24147 source.state = ManagedSessionState::Dormant;
24148 assert!(!session_record_context_export_source_is_usable(
24149 &source,
24150 &SessionStatus::Running,
24151 false,
24152 ));
24153 assert!(session_record_context_export_source_is_usable(
24157 &source,
24158 &SessionStatus::Exited { exit_code: Some(0) },
24159 true,
24160 ));
24161 assert!(!session_record_context_export_source_is_usable(
24162 &source,
24163 &SessionStatus::Exited { exit_code: Some(0) },
24164 false,
24165 ));
24166 source.state = ManagedSessionState::Live;
24167 assert!(!session_record_context_export_source_is_usable(
24168 &source,
24169 &SessionStatus::Starting,
24170 false,
24171 ));
24172 }
24173
24174 #[test]
24175 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24176 let catalog = active_registry().unwrap();
24177 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24178 let workspace = WorkspaceConfig::new(
24179 WorkspaceId::new("primary").unwrap(),
24180 std::env::current_dir().unwrap(),
24181 )
24182 .unwrap();
24183 let shared = NodeShared::new(
24184 handle,
24185 "fixture-token".to_owned(),
24186 NodeId::new("node-context-export-conflict").unwrap(),
24187 vec![workspace],
24188 vec![agent("claude")],
24189 );
24190 let history = HistorySessionRecord {
24191 session_id: "provider-exact".to_owned(),
24192 title: None,
24193 cwd: None,
24194 model: None,
24195 message_count: 1,
24196 completed_turn_count: None,
24197 total_tokens: 0,
24198 messages: vec![gate4agent_types::HistoryMessageRecord {
24199 role: gate4agent_types::HistoryMessageRole::User,
24200 text: "bounded context".to_owned(),
24201 }],
24202 };
24203 let pack = NodeContextPack::export(
24204 ContextPackLineageReceipt {
24205 source_node_id: shared.node_id.clone(),
24206 source_session: terminal_address(1),
24207 source_provider: agent("claude"),
24208 },
24209 &history,
24210 )
24211 .unwrap();
24212 let context_id = pack.receipt().id.clone();
24213 let session = terminal_address(1);
24214 let identity = ProviderSessionIdentity {
24215 key: ProviderSessionKey::SessionId,
24216 id: history.session_id.clone(),
24217 transcript_path: None,
24218 };
24219 let mut expected = record("claude", "record-context-export-final-conflict");
24220 expected.state = ManagedSessionState::Live;
24221 expected.provider_session = Some(identity.clone());
24222 expected.active_session = Some(session.clone());
24223 let mut changed = expected.clone();
24224 changed.updated_at_unix_ms += 1;
24225 shared.insert_record(changed).unwrap();
24226
24227 let error = shared
24228 .commit_context_pack_for_session_record(
24229 &expected,
24230 Some(&identity),
24231 &session,
24232 "candidate-exact",
24233 &history,
24234 pack,
24235 false,
24236 )
24237 .unwrap_err();
24238
24239 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24240 assert!(shared
24241 .context_catalog
24242 .read()
24243 .unwrap_or_else(|poisoned| poisoned.into_inner())
24244 .get(&context_id)
24245 .is_none());
24246 }
24247
24248 #[tokio::test]
24249 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24250 let root = temporary_workspace_root("session-task-binding");
24251 std::fs::create_dir_all(&root).unwrap();
24252 let state_path = root.join("node-state.json");
24253 let catalog = active_registry().unwrap();
24254 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24255 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24256 let node_id = NodeId::new("node-1").unwrap();
24257 let mut shared = NodeShared::new(
24258 handle,
24259 "fixture-token".to_owned(),
24260 node_id.clone(),
24261 vec![workspace],
24262 vec![agent("claude")],
24263 );
24264 shared.state_path = Some(state_path.clone());
24265 let record = record("claude", "sr-task");
24266 let record_id = record.record_id.clone();
24267 shared.insert_record(record).unwrap();
24268 shared.persist_state().unwrap();
24269 shared
24270 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24271 .unwrap();
24272
24273 let minted = process_request_inner(
24274 &shared,
24275 77,
24276 ClientRole::Operator,
24277 NodeRequest::SetSessionTask {
24278 record_id: record_id.clone(),
24279 expected_revision: 0,
24280 target: SessionTaskTargetV1::New,
24281 },
24282 )
24283 .await
24284 .unwrap();
24285 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24286 panic!("session task mutation returned another response");
24287 };
24288 let minted_binding = minted.task_binding.clone().unwrap();
24289 let minted_task_id = minted_binding.task_id.clone().unwrap();
24290 assert_eq!(minted_binding.revision, 1);
24291 assert!(minted_task_id.as_str().starts_with("task-"));
24292 assert_eq!(minted_task_id.as_str().len(), 29);
24293
24294 let event_count = shared.current_sequence();
24295 let repeated = process_request_inner(
24296 &shared,
24297 77,
24298 ClientRole::Operator,
24299 NodeRequest::SetSessionTask {
24300 record_id: record_id.clone(),
24301 expected_revision: 1,
24302 target: SessionTaskTargetV1::Existing {
24303 task_id: minted_task_id.clone(),
24304 },
24305 },
24306 )
24307 .await
24308 .unwrap();
24309 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24310 assert_eq!(shared.current_sequence(), event_count);
24311
24312 let stale = process_request_inner(
24313 &shared,
24314 77,
24315 ClientRole::Operator,
24316 NodeRequest::SetSessionTask {
24317 record_id: record_id.clone(),
24318 expected_revision: 0,
24319 target: SessionTaskTargetV1::Existing {
24320 task_id: minted_task_id,
24321 },
24322 },
24323 )
24324 .await
24325 .unwrap_err();
24326 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24327
24328 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24329 let assigned = process_request_inner(
24330 &shared,
24331 77,
24332 ClientRole::Operator,
24333 NodeRequest::SetSessionTask {
24334 record_id: record_id.clone(),
24335 expected_revision: 1,
24336 target: SessionTaskTargetV1::Existing {
24337 task_id: assigned_id.clone(),
24338 },
24339 },
24340 )
24341 .await
24342 .unwrap();
24343 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24344 panic!("session task assignment returned another response");
24345 };
24346 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24347 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24348
24349 let cleared = process_request_inner(
24350 &shared,
24351 77,
24352 ClientRole::Operator,
24353 NodeRequest::SetSessionTask {
24354 record_id: record_id.clone(),
24355 expected_revision: 2,
24356 target: SessionTaskTargetV1::Clear,
24357 },
24358 )
24359 .await
24360 .unwrap();
24361 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24362 panic!("session task clear returned another response");
24363 };
24364 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24365 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24366 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24367 assert_eq!(
24368 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24369 cleared.updated_at_unix_ms,
24370 );
24371
24372 let persisted = std::fs::read(&state_path).unwrap();
24373 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24374 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24375 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24376 std::fs::remove_dir_all(root).unwrap();
24377 }
24378
24379 #[test]
24380 fn session_task_capability_strips_snapshots_events_and_replies() {
24381 let mut bound = record("claude", "sr-task-projection");
24382 bound.updated_at_unix_ms = 2;
24383 bound.task_binding = Some(SessionTaskBindingV1 {
24384 revision: 1,
24385 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24386 changed_at_unix_ms: 2,
24387 });
24388 let request = NodeRequest::SetSessionTask {
24389 record_id: bound.record_id.clone(),
24390 expected_revision: 1,
24391 target: SessionTaskTargetV1::Clear,
24392 };
24393 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24394 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24395 }));
24396 assert!(!request_uses_unnegotiated_capability(
24397 &request,
24398 &baseline_capabilities().unwrap(),
24399 ));
24400 assert!(request_uses_unnegotiated_capability(&request, &[]));
24401
24402 let mut snapshot = terminal_test_shared().snapshot();
24403 snapshot.session_records.push(bound.clone());
24404 let mut reply = ResponseEnvelope {
24405 request_id: 1,
24406 result: Ok(NodeResponse::Resync {
24407 event_sequence: 1,
24408 oldest_available_sequence: 1,
24409 snapshot,
24410 events: vec![NodeEventEnvelope {
24411 sequence: 1,
24412 event: NodeEvent::SessionRecordUpserted {
24413 record: bound.clone(),
24414 },
24415 }],
24416 }),
24417 };
24418 project_response_without_session_task_binding(&mut reply);
24419 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24420 panic!("task projection changed the response kind");
24421 };
24422 assert!(snapshot.session_records[0].task_binding.is_none());
24423 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24424 panic!("task projection changed the event kind");
24425 };
24426 assert!(record.task_binding.is_none());
24427
24428 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24429 sequence: 2,
24430 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24431 });
24432 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24433 panic!("task event projection changed the event kind");
24434 };
24435 assert!(record.task_binding.is_none());
24436
24437 let mut direct = ResponseEnvelope {
24438 request_id: 2,
24439 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24440 };
24441 project_response_without_session_task_binding(&mut direct);
24442 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24443 panic!("task reply projection changed the response kind");
24444 };
24445 assert!(record.task_binding.is_none());
24446 }
24447
24448 #[test]
24449 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24450 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24451 let workspace = WorkspaceConfig::new(
24452 WorkspaceId::new("primary").unwrap(),
24453 std::env::current_dir().unwrap(),
24454 )
24455 .unwrap();
24456 let config = NodeServerConfig::new(
24457 DEFAULT_NODE_ENDPOINT,
24458 "fixture-token",
24459 NodeId::new("node-1").unwrap(),
24460 [workspace],
24461 )
24462 .unwrap();
24463 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24464 }
24465
24466 #[cfg(all(feature = "fixture", windows))]
24467 #[test]
24468 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24469 let root = temporary_workspace_root("provider-bundle-argv-hold");
24470 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24471 std::fs::create_dir_all(&root).unwrap();
24472 std::fs::create_dir_all(&outside).unwrap();
24473 let proof_path = root.join("provider.proof");
24474 let release_signal = root.join("release.signal");
24475 let config = NodeServerConfig::new(
24476 format!(
24477 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24478 std::process::id(),
24479 ),
24480 "fixture-token",
24481 NodeId::new("provider-bundle-hold-node").unwrap(),
24482 [WorkspaceConfig::new(
24483 WorkspaceId::new("primary").unwrap(),
24484 std::env::current_dir().unwrap(),
24485 )
24486 .unwrap()],
24487 )
24488 .unwrap()
24489 .with_spawn_profiles(SpawnProfileRegistry::new([
24490 crate::protocol::SpawnProfileDefaults {
24491 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24492 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24493 .unwrap(),
24494 provider: agent("kimi"),
24495 mode: SessionMode::Pty,
24496 terminal_size: gate4agent_types::TerminalSize {
24497 rows: 24,
24498 columns: 80,
24499 },
24500 prompt: None,
24501 bundle_id: None,
24502 context_id: None,
24503 environment_profile_id: None,
24504 },
24505 ])
24506 .unwrap());
24507 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24508 config,
24509 agent("kimi"),
24510 proof_path.clone(),
24511 release_signal.clone(),
24512 )
24513 .unwrap();
24514 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24515
24516 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24517 agent("kimi"),
24518 proof_path.clone(),
24519 Some(release_signal.clone()),
24520 )
24521 .unwrap();
24522 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24523 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24524 let release_wait = wrapper
24525 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24526 .unwrap();
24527 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24528 assert!(proof_write < release_wait);
24529 assert!(release_wait < fixture_ready);
24530 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24531 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24532 assert_eq!(
24533 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24534 &[
24535 proof_path.to_string_lossy().into_owned(),
24536 release_signal.to_string_lossy().into_owned(),
24537 ],
24538 );
24539
24540 let error = NodeServer::provider_bundle_argv_fixture_spec(
24541 agent("kimi"),
24542 proof_path,
24543 Some(outside.join("release.signal")),
24544 )
24545 .unwrap_err();
24546 assert!(matches!(
24547 error,
24548 NodeServerError::Registry(message)
24549 if message == "provider bundle argv release path must share the proof parent"
24550 ));
24551 drop(server);
24552 std::fs::remove_dir_all(root).unwrap();
24553 std::fs::remove_dir_all(outside).unwrap();
24554 }
24555
24556 #[cfg(feature = "fixture")]
24557 #[test]
24558 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24559 let root = temporary_workspace_root("context-fixture-registry");
24560 std::fs::create_dir_all(&root).unwrap();
24561 let proof_path = root.join("context.proof");
24562 let config = NodeServerConfig::new(
24563 format!(
24564 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24565 std::process::id(),
24566 ),
24567 "fixture-token",
24568 NodeId::new("context-fixture-node").unwrap(),
24569 [WorkspaceConfig::new(
24570 WorkspaceId::new("primary").unwrap(),
24571 std::env::current_dir().unwrap(),
24572 )
24573 .unwrap()],
24574 )
24575 .unwrap();
24576 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24577 let mut enabled = server
24578 .shared
24579 .enabled_providers
24580 .iter()
24581 .map(AgentId::as_str)
24582 .collect::<Vec<_>>();
24583 enabled.sort_unstable();
24584 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24585 for provider in &server.shared.enabled_providers {
24586 assert!(server
24587 .shared
24588 .provider_adapter_contracts
24589 .iter()
24590 .any(|contract| {
24591 &contract.provider == provider
24592 && contract.family == AdapterFamily::History
24593 }));
24594 }
24595 drop(server);
24596 std::fs::remove_dir_all(root).unwrap();
24597 }
24598
24599 #[cfg(feature = "fixture")]
24600 #[test]
24601 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24602 let root = temporary_workspace_root("monitoring-context-fixture");
24603 std::fs::create_dir_all(&root).unwrap();
24604 let proof_path = root.join("context.proof");
24605 let standard = NodeServer::context_pack_fixture_catalog(
24606 proof_path.clone(),
24607 None,
24608 false,
24609 ).unwrap();
24610 let monitoring = NodeServer::context_pack_fixture_catalog(
24611 proof_path.clone(),
24612 None,
24613 true,
24614 ).unwrap();
24615 let claude = AgentId::new("claude").unwrap();
24616 let standard_claude = standard.get(&claude).unwrap();
24617 let monitoring_claude = monitoring.get(&claude).unwrap();
24618 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24622 assert_eq!(
24623 monitoring_claude.capabilities.adapters.history,
24624 standard_claude.capabilities.adapters.history,
24625 );
24626 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24627 let codex = AgentId::new("codex").unwrap();
24628 let standard_codex = standard.get(&codex).unwrap();
24629 let monitoring_codex = monitoring.get(&codex).unwrap();
24630 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24631 assert_eq!(
24632 monitoring_codex.capabilities.adapters.history,
24633 standard_codex.capabilities.adapters.history,
24634 );
24635 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24636 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24637 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24638 .unwrap();
24639 for required in [
24640 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24641 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24642 "@('role', 'text')",
24643 "g4a-private-provider-session-canary",
24644 "private-provider-login-identity",
24645 "$rawProof",
24646 "$env:GATE4AGENT_HOOK_URL",
24647 "g4a-private-provider-session-canary",
24648 ] {
24649 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24650 }
24651 for provider in ["grok", "kimi"] {
24652 let provider = AgentId::new(provider).unwrap();
24653 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24654 }
24655
24656 let config = NodeServerConfig::new(
24657 format!(
24658 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24659 std::process::id(),
24660 ),
24661 "fixture-token",
24662 NodeId::new("monitoring-context-fixture-node").unwrap(),
24663 [WorkspaceConfig::new(
24664 WorkspaceId::new("primary").unwrap(),
24665 std::env::current_dir().unwrap(),
24666 )
24667 .unwrap()],
24668 )
24669 .unwrap();
24670 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24671 assert!(server.shared.fixture_semantic_hook_policy);
24672 drop(server);
24673 std::fs::remove_dir_all(root).unwrap();
24674 }
24675
24676 #[cfg(feature = "fixture")]
24677 #[test]
24678 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24679 let root = temporary_workspace_root("kimi-context-only-fixture");
24680 std::fs::create_dir_all(&root).unwrap();
24681 let proof_path = root.join("context.proof");
24682 let standard = NodeServer::context_pack_fixture_catalog(
24683 proof_path.clone(),
24684 None,
24685 false,
24686 ).unwrap();
24687 let kimi = AgentId::new("kimi").unwrap();
24688 let context_only = NodeServer::context_pack_fixture_catalog(
24689 proof_path,
24690 Some(&kimi),
24691 false,
24692 )
24693 .unwrap();
24694
24695 let codex = AgentId::new("codex").unwrap();
24696 assert_eq!(
24697 standard.get(&codex).unwrap().launch.fixed_args,
24698 context_only.get(&codex).unwrap().launch.fixed_args,
24699 );
24700 let script = context_only
24701 .get(&kimi)
24702 .unwrap()
24703 .launch
24704 .fixed_args
24705 .iter()
24706 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24707 .expect("Kimi context-only validation script is missing");
24708 for required in [
24709 "$bundleArgs.Count -ne 0",
24710 "GATE4AGENT_CONTEXT_ROOT",
24711 "context-pack.json",
24712 "$entries.Count -ne 1",
24713 "g4a-context-pack-v1",
24714 "$document.PSObject.Properties['cwd']",
24715 "'claude', 'codex', 'grok', 'kimi'",
24716 "$hasUser",
24717 "$hasAssistant",
24718 "SHA256",
24719 "context-only",
24720 ] {
24721 assert!(script.contains(required), "missing validation: {required}");
24722 }
24723 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24724 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24725 }
24726 assert!(!standard
24727 .get(&kimi)
24728 .unwrap()
24729 .launch
24730 .fixed_args
24731 .iter()
24732 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24733
24734 std::fs::remove_dir_all(root).unwrap();
24735 }
24736
24737 #[cfg(all(feature = "fixture", windows))]
24738 #[tokio::test]
24739 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24740 let root = temporary_workspace_root("kimi-context-only-spawn");
24741 let workspace_root = root.join("workspace");
24742 std::fs::create_dir_all(&workspace_root).unwrap();
24743 let materialization_root = root.join("private-materializations");
24744 let proof_path = root.join("context.proof");
24745 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24746 let workspace_id = WorkspaceId::new("primary").unwrap();
24747 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24748 let endpoint = format!(
24749 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24750 std::process::id(),
24751 unix_time_ms(),
24752 );
24753 let spawn_profiles = SpawnProfileRegistry::new([
24754 crate::protocol::SpawnProfileDefaults {
24755 profile_id: profile_id.clone(),
24756 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24757 provider: agent("kimi"),
24758 mode: SessionMode::Pty,
24759 terminal_size: gate4agent_types::TerminalSize {
24760 rows: 30,
24761 columns: 120,
24762 },
24763 prompt: None,
24764 bundle_id: None,
24765 context_id: None,
24766 environment_profile_id: None,
24767 },
24768 ])
24769 .unwrap();
24770 let mut config = NodeServerConfig::new(
24771 &endpoint,
24772 "fixture-token",
24773 node_id.clone(),
24774 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24775 )
24776 .unwrap()
24777 .with_spawn_profiles(spawn_profiles)
24778 .with_session_environment_materialization(
24779 materialization_root,
24780 Arc::new(FixtureSecretResolver {
24781 deny: Arc::new(AtomicBool::new(false)),
24782 }),
24783 )
24784 .unwrap();
24785 config.fixture_raw_pty_runtime = true;
24786 let server = NodeServer::new_context_only_proof_fixture(
24787 config,
24788 agent("kimi"),
24789 proof_path.clone(),
24790 )
24791 .unwrap();
24792 let shared = Arc::clone(&server.shared);
24793 let shutdown = server.shutdown_handle();
24794
24795 let history = HistorySessionRecord {
24796 session_id: "context-only-source-history".to_owned(),
24797 title: None,
24798 cwd: None,
24799 model: None,
24800 message_count: 2,
24801 completed_turn_count: None,
24802 total_tokens: 0,
24803 messages: vec![
24804 gate4agent_types::HistoryMessageRecord {
24805 role: gate4agent_types::HistoryMessageRole::User,
24806 text: "continue the bounded parent run through native C2".to_owned(),
24807 },
24808 gate4agent_types::HistoryMessageRecord {
24809 role: gate4agent_types::HistoryMessageRole::Assistant,
24810 text: "the exact parent context is ready for the child run".to_owned(),
24811 },
24812 ],
24813 };
24814 let pack = NodeContextPack::export(
24815 ContextPackLineageReceipt {
24816 source_node_id: node_id.clone(),
24817 source_session: SessionAddress {
24818 workspace_id: workspace_id.clone(),
24819 session: SessionKey {
24820 instance_id: AgentInstanceId(71),
24821 generation: SessionGeneration(1),
24822 },
24823 },
24824 source_provider: agent("codex"),
24825 },
24826 &history,
24827 )
24828 .unwrap();
24829 let expected_context_hash = digest(&SHA256, pack.bytes())
24830 .as_ref()
24831 .iter()
24832 .map(|byte| format!("{byte:02x}"))
24833 .collect::<String>();
24834 let context = shared
24835 .context_catalog
24836 .write()
24837 .unwrap_or_else(|poisoned| poisoned.into_inner())
24838 .insert(pack)
24839 .unwrap();
24840 let spec = SpawnSpec {
24841 target: crate::protocol::SpawnTarget {
24842 node_id,
24843 workspace_id: workspace_id.clone(),
24844 worktree_id: None,
24845 },
24846 profile_id,
24847 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24848 "target-kimi-r1",
24849 ).unwrap(),
24850 overrides: crate::protocol::SpawnOverrides {
24851 context_id: crate::protocol::SpawnOverride::Set {
24852 value: context.id.clone(),
24853 },
24854 ..crate::protocol::SpawnOverrides::default()
24855 },
24856 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24857 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24858 required_capabilities: SpawnRequiredCapabilities::default(),
24859 };
24860 let resolved = match shared.resolve_spawn_spec(&spec) {
24861 Ok(resolved) => resolved,
24862 Err(error) => panic!(
24863 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24864 error.code,
24865 error.message,
24866 ),
24867 };
24868 let resolved_context = match shared.resolve_context(&resolved) {
24869 Ok(Some(resolved_context)) => resolved_context,
24870 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24871 Err(error) => panic!(
24872 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24873 error.code,
24874 error.message,
24875 ),
24876 };
24877 assert_eq!(resolved_context, context);
24878 let runtime_policy = match shared
24879 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24880 .await
24881 {
24882 Ok(runtime_policy) => runtime_policy,
24883 Err(error) => panic!(
24884 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24885 error.code,
24886 error.message,
24887 ),
24888 };
24889 assert!(runtime_policy.raw_pty_lifecycle);
24890
24891 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24892 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24893 agent("kimi"),
24894 TransportKind::Pty,
24895 vec![EnvMutation {
24896 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24897 value: Some(OsString::from("must-remain-rejected")),
24898 }],
24899 )
24900 .unwrap();
24901 assert_eq!(
24902 launch_control
24903 .install_native_launch_environment_overlay(
24904 AgentInstanceId(72),
24905 generic_overlay,
24906 )
24907 .unwrap_err(),
24908 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24909 );
24910
24911 let diagnostic_address = SessionAddress {
24912 workspace_id: workspace_id.clone(),
24913 session: SessionKey {
24914 instance_id: AgentInstanceId(73),
24915 generation: SessionGeneration(1),
24916 },
24917 };
24918 let (diagnostic_overlay, diagnostic_materialization) = match shared
24919 .prepare_session_materialization(
24920 &diagnostic_address,
24921 &agent("kimi"),
24922 SessionMode::Pty,
24923 None,
24924 None,
24925 Some(&resolved_context),
24926 None,
24927 )
24928 {
24929 Ok(Some(prepared)) => prepared,
24930 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24931 Err(error) => panic!(
24932 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24933 error.code,
24934 error.message,
24935 ),
24936 };
24937 let diagnostic_overlay = diagnostic_overlay
24938 .expect("Kimi context-only materialization omitted its environment overlay");
24939 assert!(matches!(
24940 &diagnostic_overlay,
24941 PreparedNativeLaunchOverlay::Environment(_),
24942 ));
24943 match shared.install_prepared_launch_overlay(
24944 diagnostic_address.session.instance_id,
24945 diagnostic_overlay,
24946 ) {
24947 Ok(None) => {}
24948 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24949 Err(error) => panic!(
24950 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24951 error.code,
24952 error.message,
24953 ),
24954 }
24955 assert!(launch_control.clear_native_instance_launch_overlay(
24956 diagnostic_address.session.instance_id,
24957 ));
24958 drop(diagnostic_materialization);
24959 assert!(shared
24960 .materializations
24961 .lock()
24962 .unwrap_or_else(|poisoned| poisoned.into_inner())
24963 .is_empty());
24964
24965 let node_task = tokio::spawn(server.run());
24966 let ready_client = timeout(Duration::from_secs(5), async {
24967 loop {
24968 match gate4agent_node_wire::LocalNodeClient::connect(
24969 &endpoint,
24970 &shared.node_id,
24971 ClientRole::Observer,
24972 "fixture-token",
24973 )
24974 .await
24975 {
24976 Ok(client) => break client,
24977 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24978 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24979 }
24980 }
24981 })
24982 .await
24983 .expect("Kimi context-only Node did not become ready");
24984 assert_eq!(
24985 ready_client
24986 .hello()
24987 .snapshot
24988 .provider_runtime_statuses
24989 .iter()
24990 .find(|status| status.provider().as_str() == "kimi")
24991 .expect("Kimi runtime status is missing")
24992 .mode(),
24993 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24994 );
24995 drop(ready_client);
24996
24997 let receipt = match shared.spawn_from_spec(spec).await {
24998 Ok(receipt) => receipt,
24999 Err(error) => panic!(
25000 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25001 error.code,
25002 error.message,
25003 ),
25004 };
25005 assert_eq!(receipt.provider, agent("kimi"));
25006 assert_eq!(receipt.context.as_ref(), Some(&context));
25007 assert!(receipt.context_binding_is_valid());
25008 timeout(Duration::from_secs(5), async {
25009 while !proof_path.is_file() {
25010 sleep(Duration::from_millis(10)).await;
25011 }
25012 })
25013 .await
25014 .expect("Kimi context-only fixture did not write its proof");
25015
25016 let proof = std::fs::read_to_string(&proof_path).unwrap();
25017 let lines = proof.lines().collect::<Vec<_>>();
25018 assert_eq!(lines.len(), 7);
25019 assert_eq!(lines[0], "context-only");
25020 assert!(Path::new(lines[1]).is_absolute());
25021 assert_ne!(
25022 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25023 std::fs::canonicalize(&workspace_root).unwrap(),
25024 );
25025 assert_eq!(
25026 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25027 std::fs::canonicalize(&workspace_root).unwrap(),
25028 );
25029 assert_eq!(lines[3], expected_context_hash);
25030 assert_eq!(lines[4], "g4a-context-pack-v1");
25031 assert_eq!(lines[5], "codex");
25032 assert_eq!(lines[6], "2");
25033 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25034 session.instance_id == receipt.session.session.instance_id
25035 && session.agent_id.as_str() == "kimi"
25036 }));
25037
25038 shutdown.request_shutdown().await.unwrap();
25039 timeout(Duration::from_secs(5), node_task)
25040 .await
25041 .unwrap()
25042 .unwrap()
25043 .unwrap();
25044 drop(shutdown);
25045 drop(shared);
25046 std::fs::remove_dir_all(root).unwrap();
25047 }
25048
25049 #[cfg(feature = "fixture")]
25050 #[test]
25051 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25052 let root = temporary_workspace_root("clean-exit-constructor");
25053 std::fs::create_dir_all(&root).unwrap();
25054 let config = construction_test_config("clean-exit-constructor")
25055 .with_state_path(root.join("node-state.json"))
25056 .unwrap();
25057 let server = NodeServer::new_clean_exit_fixture(
25058 config,
25059 root.clone(),
25060 root.join("started.marker"),
25061 root.join("release.signal"),
25062 )
25063 .unwrap();
25064 assert_eq!(
25065 server
25066 .shared
25067 .enabled_providers
25068 .iter()
25069 .map(AgentId::as_str)
25070 .collect::<Vec<_>>(),
25071 ["claude"],
25072 );
25073 drop(server);
25074 std::fs::remove_dir_all(root).unwrap();
25075 }
25076
25077 #[test]
25078 fn production_registry_provider_contract_manifest_is_exact() {
25079 let registry = active_registry().unwrap();
25080 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25081 assert_eq!(
25082 providers
25083 .iter()
25084 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25085 .collect::<Vec<_>>(),
25086 vec![
25087 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25088 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25089 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25090 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25091 ]
25092 );
25093 assert_eq!(
25094 adapters
25095 .iter()
25096 .map(|contract| (
25097 contract.provider.as_str(),
25098 contract.family,
25099 contract.adapter_id.as_str(),
25100 contract.revision.as_str(),
25101 ))
25102 .collect::<Vec<_>>(),
25103 vec![
25104 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25105 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25106 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25107 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25108 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25109 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25110 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25111 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25112 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25113 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25114 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25115 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25116 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25117 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25118 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25119 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25120 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25121 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25122 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25123 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25124 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25125 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25126 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25127 ]
25128 );
25129 }
25130
25131 fn construction_test_config(name: &str) -> NodeServerConfig {
25132 let workspace = WorkspaceConfig::new(
25133 WorkspaceId::new("primary").unwrap(),
25134 std::env::current_dir().unwrap(),
25135 )
25136 .unwrap();
25137 NodeServerConfig::new(
25138 format!(r"\\.\pipe\gate4agent-{name}"),
25139 "fixture-token",
25140 NodeId::new(name).unwrap(),
25141 [workspace],
25142 )
25143 .unwrap()
25144 }
25145
25146 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25147 let adapter = AdapterBinding::new(
25148 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25149 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25150 gate4agent_types::AdapterVerification::SyntheticFixture,
25151 )
25152 .unwrap();
25153 let agent_ids = ["claude", "codex", "kimi"]
25154 .into_iter()
25155 .map(|agent_id| AgentId::new(agent_id).unwrap())
25156 .collect::<Vec<_>>();
25157 let adapters = gate4agent_catalog::AdapterRegistry::new(
25158 [
25159 AdapterFamily::PtySemantic,
25160 AdapterFamily::Pipe,
25161 AdapterFamily::OneShot,
25162 AdapterFamily::Acp,
25163 AdapterFamily::Hook,
25164 AdapterFamily::ManagedHook,
25165 AdapterFamily::History,
25166 AdapterFamily::Resume,
25167 AdapterFamily::SessionOptions,
25168 AdapterFamily::CapabilityProbe,
25169 ]
25170 .into_iter()
25171 .map(|family| gate4agent_catalog::AdapterDescriptor {
25172 family,
25173 binding: adapter.clone(),
25174 agents: agent_ids.clone(),
25175 }),
25176 )
25177 .unwrap();
25178 let specs = agent_ids
25179 .iter()
25180 .map(|agent_id| {
25181 let agent_id = agent_id.as_str();
25182 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25183 spec.revision =
25184 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25185 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25186 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25187 adapter: adapter.clone(),
25188 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25189 launch_override: None,
25190 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25191 });
25192 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25193 adapter: adapter.clone(),
25194 launch_override: None,
25195 });
25196 spec.capabilities.adapters.hook = Some(adapter.clone());
25197 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25198 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25199 spec.capabilities.adapters.history = Some(adapter.clone());
25200 spec.capabilities.adapters.resume = Some(adapter.clone());
25201 spec.capabilities.adapters.session_options = Some(adapter.clone());
25202 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25203 spec
25204 })
25205 .collect::<Vec<_>>();
25206 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25207 }
25208
25209 #[test]
25217 fn node_server_construction_accepts_the_production_manifest() {
25218 let registry = active_registry().unwrap();
25219 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25220 assert_eq!(providers.len(), registry.iter().count());
25221 assert!(!providers.is_empty());
25222 let server = NodeServer::new_with_registry(
25223 construction_test_config("manifest-production-bound"),
25224 registry,
25225 )
25226 .unwrap();
25227 drop(server);
25228 }
25229
25230 #[test]
25231 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25232 let registry = maximum_size_provider_manifest_registry();
25233 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25234 assert_eq!(providers.len(), 3);
25235 assert_eq!(adapters.len(), 30);
25236 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25237 let error = match NodeServer::new_with_registry(
25238 construction_test_config("manifest-handshake-overflow"),
25239 registry,
25240 ) {
25241 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25242 Err(error) => error,
25243 };
25244 assert!(matches!(
25245 error,
25246 NodeServerError::ProviderContractManifest(message)
25247 if message.contains("authentication binding length")
25248 && message.contains("8192-byte limit")
25249 ));
25250 }
25251
25252 #[test]
25253 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25254 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25255 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25256 let registry = AgentRegistry::new([spec]).unwrap();
25257 assert!(matches!(
25258 provider_contract_manifest(®istry),
25259 Err(NodeServerError::ProviderContractManifest(message))
25260 if message.contains("exceeds the 128-byte limit")
25261 ));
25262 }
25263
25264 #[test]
25265 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25266 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25267 spec.revision = "invalid revision".to_owned();
25268 let registry = AgentRegistry::new([spec]).unwrap();
25269 assert!(matches!(
25270 provider_contract_manifest(®istry),
25271 Err(NodeServerError::ProviderContractManifest(message))
25272 if message.contains("bounded lowercase ASCII")
25273 ));
25274 }
25275
25276 #[test]
25277 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25278 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25279 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25280 let conflicting_binding = builtin_registry()
25281 .get_by_id("codex")
25282 .unwrap()
25283 .capabilities
25284 .adapters
25285 .one_shot
25286 .clone()
25287 .unwrap();
25288 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25289 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25290 pipe.adapter = transport_binding;
25291 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25292 let registry = AgentRegistry::new([spec]).unwrap();
25293 assert!(matches!(
25294 provider_contract_manifest(®istry),
25295 Err(NodeServerError::ProviderContractManifest(message))
25296 if message.contains("conflicting OneShot adapter bindings")
25297 ));
25298 }
25299
25300 #[test]
25301 fn public_node_failures_expose_only_fixed_categories() {
25302 let provider = failure(
25303 NodeFailureCode::BackendOperationFailed,
25304 r"provider bearer-secret failed at C:\private\session.jsonl",
25305 );
25306 assert_eq!(provider.message, "backend-operation-failed");
25307 assert!(!provider.message.contains("bearer-secret"));
25308 assert!(!provider.message.contains(r"C:\private"));
25309 let persistence = persistence_failure(io::Error::new(
25310 io::ErrorKind::Other,
25311 r"state write failed at C:\private\state-v1.json",
25312 ));
25313 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25314 assert!(!persistence.message.contains(r"C:\private"));
25315
25316 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25317 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25318 assert_eq!(
25319 generic.to_string(),
25320 "node durable state failed: durable-state-load-failed",
25321 );
25322 }
25323 }
25324
25325 #[test]
25326 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25327 let sequence = b"\x1b[1;5D".to_vec();
25328 assert_eq!(
25329 terminal_bytes_action(sequence.clone()).unwrap(),
25330 InputAction::TerminalBytes(sequence),
25331 );
25332 assert_eq!(
25333 terminal_bytes_action(Vec::new()).unwrap_err().code,
25334 NodeFailureCode::InvalidRequest,
25335 );
25336 assert_eq!(
25337 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25338 .unwrap_err()
25339 .code,
25340 NodeFailureCode::InvalidRequest,
25341 );
25342 }
25343
25344 #[test]
25345 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25346 let root = temporary_workspace_root("exclusive-state-owner");
25347 std::fs::create_dir_all(&root).unwrap();
25348 let workspace = WorkspaceConfig::new(
25349 WorkspaceId::new("primary").unwrap(),
25350 &root,
25351 )
25352 .unwrap();
25353 let state_path = root.join("state-v1.json");
25354 let endpoint = format!(
25355 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25356 std::process::id(),
25357 std::time::SystemTime::now()
25358 .duration_since(std::time::UNIX_EPOCH)
25359 .unwrap()
25360 .as_nanos(),
25361 );
25362 let config = NodeServerConfig::new(
25363 endpoint,
25364 "fixture-token",
25365 NodeId::new("node-1").unwrap(),
25366 [workspace],
25367 )
25368 .unwrap()
25369 .with_state_path(&state_path)
25370 .unwrap();
25371
25372 let first = NodeServer::new(config.clone()).unwrap();
25373 let error = match NodeServer::new(config.clone()) {
25374 Ok(_) => panic!("a second node server acquired the same durable state path"),
25375 Err(error) => error,
25376 };
25377 assert!(matches!(
25378 &error,
25379 NodeServerError::DurableState(error)
25380 if error.kind() == io::ErrorKind::WouldBlock
25381 ));
25382 assert_eq!(
25383 error.to_string(),
25384 "node durable state failed: durable-state-lock-failed",
25385 );
25386 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25387 drop(first);
25388 let second = NodeServer::new(config).unwrap();
25389 drop(second);
25390 std::fs::remove_dir_all(root).unwrap();
25391 }
25392
25393 #[test]
25394 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25395 let catalog = active_registry().unwrap();
25396 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25397 let workspace_id = WorkspaceId::new("primary").unwrap();
25398 let workspace = WorkspaceConfig::new(
25399 workspace_id.clone(),
25400 std::env::current_dir().unwrap(),
25401 )
25402 .unwrap();
25403 let shared = NodeShared::new(
25404 handle,
25405 "fixture-token".to_owned(),
25406 NodeId::new("node-1").unwrap(),
25407 vec![workspace.clone()],
25408 vec![agent("claude"), agent("codex"), agent("kimi")],
25409 );
25410 let instance_id = AgentInstanceId(17);
25411 let generation = SessionGeneration(3);
25412 assert!(shared.address_for(instance_id, generation).is_none());
25413 shared.bind_session(instance_id, workspace_id.clone(), generation);
25414 let address = SessionAddress {
25415 workspace_id,
25416 session: SessionKey { instance_id, generation },
25417 };
25418 assert_eq!(
25419 shared.workspace_root(&address.workspace_id).unwrap(),
25420 workspace.canonical_root,
25421 );
25422 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25423 shared.remove_binding(&address);
25424 assert!(shared.address_for(instance_id, generation).is_none());
25425 }
25426
25427 #[test]
25428 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25429 let address = SessionAddress {
25430 workspace_id: WorkspaceId::new("primary").unwrap(),
25431 session: SessionKey {
25432 instance_id: AgentInstanceId(7),
25433 generation: SessionGeneration(2),
25434 },
25435 };
25436 let record_id = SessionRecordId::new("record-a").unwrap();
25437 let size = gate4agent_types::TerminalSize {
25438 rows: 24,
25439 columns: 80,
25440 };
25441 let address_requests = [
25442 NodeRequest::Resume {
25443 session: address.clone(),
25444 terminal_size: size,
25445 initial_prompt: None,
25446 },
25447 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25448 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25449 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25450 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25451 NodeRequest::TerminalControl {
25452 session: address.clone(),
25453 control: TerminalControl::Enter,
25454 },
25455 NodeRequest::Resize { session: address.clone(), size },
25456 NodeRequest::Interrupt { session: address.clone() },
25457 NodeRequest::Stop { session: address.clone(), force: false },
25458 NodeRequest::Remove { session: address },
25459 ];
25460 for request in &address_requests {
25461 assert!(request_requires_open_provider_ids_with(
25462 request,
25463 |_| Some(agent("third-party-agent")),
25464 |_| Some(agent("claude")),
25465 ));
25466 assert!(!request_requires_open_provider_ids_with(
25467 request,
25468 |_| Some(agent("claude")),
25469 |_| Some(agent("claude")),
25470 ));
25471 assert!(request_requires_open_provider_ids_with(
25472 request,
25473 |_| None,
25474 |_| Some(agent("claude")),
25475 ));
25476 }
25477
25478 let record_requests = [
25479 NodeRequest::RenameSessionRecord {
25480 record_id: record_id.clone(),
25481 display_name: "renamed".to_owned(),
25482 },
25483 NodeRequest::ResumeSessionRecord {
25484 record_id: record_id.clone(),
25485 terminal_size: size,
25486 initial_prompt: None,
25487 },
25488 NodeRequest::ForgetSessionRecord { record_id },
25489 ];
25490 for request in &record_requests {
25491 assert!(request_requires_open_provider_ids_with(
25492 request,
25493 |_| Some(agent("claude")),
25494 |_| Some(agent("grok")),
25495 ));
25496 assert!(!request_requires_open_provider_ids_with(
25497 request,
25498 |_| Some(agent("claude")),
25499 |_| Some(agent("kimi")),
25500 ));
25501 assert!(request_requires_open_provider_ids_with(
25502 request,
25503 |_| Some(agent("claude")),
25504 |_| None,
25505 ));
25506 }
25507
25508 assert!(request_requires_open_provider_ids_with(
25509 &NodeRequest::Spawn {
25510 workspace_id: WorkspaceId::new("primary").unwrap(),
25511 provider: agent("third-party-agent"),
25512 mode: SessionMode::Pty,
25513 terminal_size: size,
25514 initial_prompt: None,
25515 },
25516 |_| None,
25517 |_| None,
25518 ));
25519 assert!(!request_requires_open_provider_ids_with(
25520 &NodeRequest::Snapshot,
25521 |_| None,
25522 |_| None,
25523 ));
25524 assert!(request_requires_open_provider_ids_with(
25525 &NodeRequest::ForgetContextPack {
25526 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25527 },
25528 |_| None,
25529 |_| None,
25530 ));
25531 }
25532
25533 #[test]
25534 fn legacy_projection_filters_open_record_replies_and_event_history() {
25535 let catalog = active_registry().unwrap();
25536 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25537 let workspace = WorkspaceConfig::new(
25538 WorkspaceId::new("primary").unwrap(),
25539 std::env::current_dir().unwrap(),
25540 )
25541 .unwrap();
25542 let shared = NodeShared::new(
25543 handle,
25544 "fixture-token".to_owned(),
25545 NodeId::new("node-1").unwrap(),
25546 vec![workspace],
25547 vec![agent("claude"), agent("grok")],
25548 );
25549 let legacy = record("claude", "legacy-record");
25550 let open = record("grok", "open-record");
25551 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25552 record: legacy.clone(),
25553 });
25554 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25555 record: open.clone(),
25556 });
25557 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25558 record_id: legacy.record_id.clone(),
25559 });
25560 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25561 record_id: open.record_id.clone(),
25562 });
25563 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25564 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25565 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25566 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25567
25568 let mut record_reply = ResponseEnvelope {
25569 request_id: 1,
25570 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25571 };
25572 project_response_legacy_provider_ids(&shared, &mut record_reply);
25573 assert!(matches!(
25574 record_reply.result,
25575 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25576 ));
25577
25578 let mut resync = ResponseEnvelope {
25579 request_id: 2,
25580 result: Ok(NodeResponse::Resync {
25581 event_sequence: open_removed.sequence,
25582 oldest_available_sequence: 1,
25583 snapshot: shared.snapshot(),
25584 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25585 }),
25586 };
25587 project_response_legacy_provider_ids(&shared, &mut resync);
25588 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25589 panic!("legacy projection changed the resync response kind");
25590 };
25591 assert!(snapshot.session_records.iter().all(|item| {
25592 provider_id_is_legacy(&item.provider)
25593 }));
25594 assert_eq!(events.len(), 2);
25595 assert!(events.iter().all(|event| matches!(
25596 &event.event,
25597 NodeEvent::SessionRecordUpserted { record }
25598 if provider_id_is_legacy(&record.provider)
25599 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25600 }
25601
25602 #[test]
25603 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25604 let catalog = active_registry().unwrap();
25605 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25606 let workspace_id = WorkspaceId::new("primary").unwrap();
25607 let workspace = WorkspaceConfig::new(
25608 workspace_id.clone(),
25609 std::env::current_dir().unwrap(),
25610 )
25611 .unwrap();
25612 let shared = NodeShared::new(
25613 handle,
25614 "fixture-token".to_owned(),
25615 NodeId::new("node-1").unwrap(),
25616 vec![workspace],
25617 vec![agent("claude")],
25618 );
25619 for index in 0..CONTROL_SESSIONS_MAX {
25620 shared.bind_session(
25621 AgentInstanceId((index + 1) as u64),
25622 workspace_id.clone(),
25623 SessionGeneration(1),
25624 );
25625 }
25626 let error = shared.ensure_binding_capacity().unwrap_err();
25627 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25628 }
25629
25630 #[tokio::test]
25631 async fn provider_spawn_admission_rejects_before_session_mutation() {
25632 let catalog = active_registry().unwrap();
25633 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25634 let workspace = WorkspaceConfig::new(
25635 WorkspaceId::new("primary").unwrap(),
25636 std::env::current_dir().unwrap(),
25637 )
25638 .unwrap();
25639 let mut shared = NodeShared::new(
25640 handle,
25641 "fixture-token".to_owned(),
25642 NodeId::new("node-provider-admission").unwrap(),
25643 vec![workspace],
25644 vec![agent("claude")],
25645 );
25646 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25647 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25648 ])
25649 .unwrap();
25650 let terminal_size = gate4agent_types::TerminalSize {
25651 rows: 24,
25652 columns: 80,
25653 };
25654
25655 let unavailable = shared
25656 .spawn_session(
25657 WorkspaceId::new("primary").unwrap(),
25658 agent("claude"),
25659 SessionMode::Pty,
25660 terminal_size,
25661 None,
25662 )
25663 .await
25664 .unwrap_err();
25665 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25666 assert_eq!(unavailable.message, "backend-operation-failed");
25667
25668 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25669 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25670 agent("claude"),
25671 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25672 ),
25673 ])
25674 .unwrap();
25675 let semantic = shared
25676 .spawn_session(
25677 WorkspaceId::new("primary").unwrap(),
25678 agent("claude"),
25679 SessionMode::Inline,
25680 terminal_size,
25681 None,
25682 )
25683 .await
25684 .unwrap_err();
25685 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25686 assert_eq!(semantic.message, "unsupported-capability");
25687
25688 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25689 assert!(shared
25690 .session_bindings
25691 .lock()
25692 .unwrap_or_else(|poisoned| poisoned.into_inner())
25693 .is_empty());
25694 assert!(shared
25695 .session_records
25696 .lock()
25697 .unwrap_or_else(|poisoned| poisoned.into_inner())
25698 .records
25699 .is_empty());
25700 assert!(shared.handle.snapshot().sessions.is_empty());
25701 }
25702
25703 #[tokio::test]
25704 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25705 let catalog = active_registry().unwrap();
25706 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25707 let workspace_id = WorkspaceId::new("primary").unwrap();
25708 let workspace = WorkspaceConfig::new(
25709 workspace_id.clone(),
25710 std::env::current_dir().unwrap(),
25711 )
25712 .unwrap();
25713 let shared = NodeShared::new(
25714 handle,
25715 "fixture-token".to_owned(),
25716 NodeId::new("node-raw-spawn-record").unwrap(),
25717 vec![workspace],
25718 vec![agent("claude")],
25719 );
25720 let address = SessionAddress {
25721 workspace_id,
25722 session: SessionKey {
25723 instance_id: AgentInstanceId(71),
25724 generation: SessionGeneration::default(),
25725 },
25726 };
25727
25728 let record_id = shared
25729 .bind_spawn_session(
25730 &address,
25731 agent("claude"),
25732 SessionMode::Pty,
25733 ProviderRuntimePolicy::raw_pty(),
25734 SpawnRecordPolicy::Always,
25735 None,
25736 )
25737 .unwrap()
25738 .unwrap();
25739 shared
25740 .handle
25741 .dispatch(shared.prepare_command(ControlCommand::Register {
25742 instance_id: address.session.instance_id,
25743 agent_id: agent("claude"),
25744 transport: TransportKind::Pty,
25745 }))
25746 .unwrap();
25747 runtime.tick().await;
25748
25749 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25750 session.instance_id == address.session.instance_id
25751 && session.generation == address.session.generation
25752 }));
25753 let record = shared.record(&record_id).unwrap();
25754 assert_eq!(record.display_name, "claude #1");
25755 assert_eq!(record.state, ManagedSessionState::Live);
25756 assert_eq!(record.active_session.as_ref(), Some(&address));
25757 assert!(record.provider_session.is_none());
25758 let binding_record_id = shared
25759 .session_bindings
25760 .lock()
25761 .unwrap_or_else(|poisoned| poisoned.into_inner())
25762 .get(&address.session.instance_id)
25763 .and_then(|binding| binding.record_id.clone());
25764 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25765
25766 let renamed = shared
25767 .rename_session_record(&record_id, "raw review".to_owned())
25768 .unwrap();
25769 assert_eq!(renamed.record_id, record_id);
25770 assert_eq!(renamed.display_name, "raw review");
25771 shared.reconcile_managed_record(
25772 &record_id,
25773 &address,
25774 &ControlEvent {
25775 sequence: 1,
25776 command_id: None,
25777 instance_id: address.session.instance_id,
25778 generation: address.session.generation,
25779 event: ControlEventKind::Running { process_id: None },
25780 },
25781 false,
25782 false,
25783 );
25784 let running = shared.record(&record_id).unwrap();
25785 assert_eq!(running.state, ManagedSessionState::Live);
25786 assert_eq!(running.active_session.as_ref(), Some(&address));
25787 assert_eq!(running.display_name, "raw review");
25788
25789 shared.reconcile_managed_record(
25790 &record_id,
25791 &address,
25792 &ControlEvent {
25793 sequence: 2,
25794 command_id: None,
25795 instance_id: address.session.instance_id,
25796 generation: address.session.generation,
25797 event: ControlEventKind::Exited {
25798 exit_code: Some(0),
25799 forced: false,
25800 },
25801 },
25802 false,
25803 false,
25804 );
25805 let detached = shared.record(&record_id).unwrap();
25806 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25807 assert!(detached.active_session.is_none());
25808 assert_eq!(detached.display_name, "raw review");
25809 let renamed_detached = shared
25810 .rename_session_record(&record_id, "raw review detached".to_owned())
25811 .unwrap();
25812 assert_eq!(renamed_detached.record_id, record_id);
25813 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25814 assert!(renamed_detached.active_session.is_none());
25815 assert_eq!(renamed_detached.display_name, "raw review detached");
25816 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25817 }
25818
25819 #[test]
25820 fn legacy_raw_spawn_binding_remains_untracked() {
25821 let catalog = active_registry().unwrap();
25822 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25823 let workspace_id = WorkspaceId::new("primary").unwrap();
25824 let workspace = WorkspaceConfig::new(
25825 workspace_id.clone(),
25826 std::env::current_dir().unwrap(),
25827 )
25828 .unwrap();
25829 let shared = NodeShared::new(
25830 handle,
25831 "fixture-token".to_owned(),
25832 NodeId::new("node-legacy-raw-spawn").unwrap(),
25833 vec![workspace],
25834 vec![agent("claude")],
25835 );
25836 let address = SessionAddress {
25837 workspace_id,
25838 session: SessionKey {
25839 instance_id: AgentInstanceId(72),
25840 generation: SessionGeneration::default(),
25841 },
25842 };
25843
25844 let record_id = shared
25845 .bind_spawn_session(
25846 &address,
25847 agent("claude"),
25848 SessionMode::Pty,
25849 ProviderRuntimePolicy::raw_pty(),
25850 SpawnRecordPolicy::ProviderIdentityOnly,
25851 None,
25852 )
25853 .unwrap();
25854
25855 assert!(record_id.is_none());
25856 let binding_record_id = shared
25857 .session_bindings
25858 .lock()
25859 .unwrap_or_else(|poisoned| poisoned.into_inner())
25860 .get(&address.session.instance_id)
25861 .and_then(|binding| binding.record_id.clone());
25862 assert!(binding_record_id.is_none());
25863 assert!(shared.snapshot().session_records.is_empty());
25864 }
25865
25866 #[tokio::test]
25867 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25868 let catalog = active_registry().unwrap();
25869 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25870 let workspace_id = WorkspaceId::new("primary").unwrap();
25871 let workspace = WorkspaceConfig::new(
25872 workspace_id.clone(),
25873 std::env::current_dir().unwrap(),
25874 )
25875 .unwrap();
25876 let mut shared = NodeShared::new(
25877 handle,
25878 "fixture-token".to_owned(),
25879 NodeId::new("node-raw-semantic-gate").unwrap(),
25880 vec![workspace],
25881 vec![agent("claude")],
25882 );
25883 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25884 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25885 ])
25886 .unwrap();
25887 shared
25888 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25889 .unwrap();
25890 let address = SessionAddress {
25891 workspace_id,
25892 session: SessionKey {
25893 instance_id: AgentInstanceId(72),
25894 generation: SessionGeneration::default(),
25895 },
25896 };
25897 shared.bind_session_with_policy(
25898 &address,
25899 ProviderRuntimePolicy::raw_pty(),
25900 None,
25901 );
25902 shared
25903 .handle
25904 .dispatch(shared.prepare_command(ControlCommand::Register {
25905 instance_id: address.session.instance_id,
25906 agent_id: agent("claude"),
25907 transport: TransportKind::Pty,
25908 }))
25909 .unwrap();
25910 runtime.tick().await;
25911 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25912 let revision_before = shared.handle.snapshot().revision;
25913 let history_sequence_before = shared.current_sequence();
25914 let terminal_size = gate4agent_types::TerminalSize {
25915 rows: 24,
25916 columns: 80,
25917 };
25918
25919 for request in [
25920 NodeRequest::Prompt {
25921 session: address.clone(),
25922 text: "prompt".to_owned(),
25923 },
25924 NodeRequest::Paste {
25925 session: address.clone(),
25926 text: "paste".to_owned(),
25927 },
25928 NodeRequest::Resume {
25934 session: address.clone(),
25935 terminal_size,
25936 initial_prompt: Some("resume".to_owned()),
25937 },
25938 ] {
25939 let error = process_request_inner(
25940 &shared,
25941 77,
25942 ClientRole::Operator,
25943 request,
25944 )
25945 .await
25946 .unwrap_err();
25947 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25948 }
25949
25950 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25951 assert_eq!(shared.handle.snapshot().revision, revision_before);
25952 assert_eq!(shared.current_sequence(), history_sequence_before);
25953 let bindings = shared
25954 .session_bindings
25955 .lock()
25956 .unwrap_or_else(|poisoned| poisoned.into_inner());
25957 let binding = bindings.get(&address.session.instance_id).unwrap();
25958 assert_eq!(binding.generation, address.session.generation);
25959 assert!(binding.pending_resume.is_none());
25960 assert!(binding.record_id.is_none());
25961 }
25962
25963 #[tokio::test]
25964 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25965 let catalog = active_registry().unwrap();
25966 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25967 let primary = WorkspaceConfig::new(
25968 WorkspaceId::new("primary").unwrap(),
25969 std::env::current_dir().unwrap(),
25970 )
25971 .unwrap();
25972 let shared = NodeShared::new(
25973 handle,
25974 "fixture-token".to_owned(),
25975 NodeId::new("node-1").unwrap(),
25976 vec![primary],
25977 vec![agent("claude")],
25978 );
25979 shared
25980 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25981 .unwrap();
25982 let before = shared.snapshot();
25983 let error = process_request_inner(
25984 &shared,
25985 77,
25986 ClientRole::Operator,
25987 NodeRequest::RegisterWorkspace {
25988 workspace_id: WorkspaceId::new("foreign").unwrap(),
25989 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25990 },
25991 )
25992 .await
25993 .unwrap_err();
25994 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25995 assert_eq!(error.message, "invalid-request");
25996 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25997 }
25998
25999 #[tokio::test]
26000 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26001 let catalog = active_registry().unwrap();
26002 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26003 let primary_id = WorkspaceId::new("primary").unwrap();
26004 let primary = WorkspaceConfig::new(
26005 primary_id.clone(),
26006 std::env::current_dir().unwrap(),
26007 )
26008 .unwrap();
26009 let shared = NodeShared::new(
26010 handle,
26011 "fixture-token".to_owned(),
26012 NodeId::new("node-1").unwrap(),
26013 vec![primary.clone()],
26014 vec![agent("claude")],
26015 );
26016 let secondary_id = WorkspaceId::new("secondary").unwrap();
26017 let secondary = shared
26018 .register_workspace(
26019 secondary_id.clone(),
26020 std::env::temp_dir().to_string_lossy().into_owned(),
26021 )
26022 .await
26023 .unwrap();
26024 assert_eq!(secondary.workspace_id, secondary_id);
26025 assert!(secondary.sessions.is_empty());
26026 assert_eq!(shared.snapshot().workspaces.len(), 2);
26027
26028 let duplicate_id = shared
26029 .register_workspace(
26030 secondary_id.clone(),
26031 primary.canonical_root().to_owned(),
26032 )
26033 .await
26034 .unwrap_err();
26035 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26036 let duplicate_root = shared
26037 .register_workspace(
26038 WorkspaceId::new("duplicate-root").unwrap(),
26039 windows_path_text(&secondary.canonical_root).to_owned(),
26040 )
26041 .await
26042 .unwrap_err();
26043 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26044
26045 let address = SessionAddress {
26046 workspace_id: secondary_id.clone(),
26047 session: SessionKey {
26048 instance_id: AgentInstanceId(41),
26049 generation: SessionGeneration(1),
26050 },
26051 };
26052 shared.bind_session(
26053 address.session.instance_id,
26054 secondary_id.clone(),
26055 address.session.generation,
26056 );
26057 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26058 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26059 shared.remove_binding(&address);
26060 shared.unregister_workspace(&secondary_id).unwrap();
26061 assert_eq!(shared.snapshot().workspaces.len(), 1);
26062
26063 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26064 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26065 let history = shared
26066 .history
26067 .lock()
26068 .unwrap_or_else(|poisoned| poisoned.into_inner());
26069 assert!(matches!(
26070 &history.events[0],
26071 NodeEventEnvelope {
26072 sequence: 1,
26073 event: NodeEvent::WorkspaceAdded { workspace },
26074 } if workspace.workspace_id == secondary_id
26075 ));
26076 assert!(matches!(
26077 &history.events[1],
26078 NodeEventEnvelope {
26079 sequence: 2,
26080 event: NodeEvent::WorkspaceRemoved { workspace_id },
26081 } if workspace_id == &secondary_id
26082 ));
26083 }
26084
26085 #[tokio::test]
26086 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26087 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26088 std::fs::create_dir_all(&secondary_root).unwrap();
26089 let catalog = active_registry().unwrap();
26090 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26091 let primary_id = WorkspaceId::new("primary").unwrap();
26092 let secondary_id = WorkspaceId::new("secondary").unwrap();
26093 let shared = NodeShared::new(
26094 handle,
26095 "fixture-token".to_owned(),
26096 NodeId::new("node-session-reference-index").unwrap(),
26097 vec![
26098 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26099 .unwrap(),
26100 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26101 ],
26102 vec![agent("claude"), agent("codex")],
26103 );
26104 let identity = ProviderSessionIdentity {
26105 key: gate4agent_types::ProviderSessionKey::SessionId,
26106 id: "provider-session-42".to_owned(),
26107 transcript_path: None,
26108 };
26109 let index_request = |workspace_id: WorkspaceId,
26110 provider: AgentId,
26111 identity: ProviderSessionIdentity,
26112 display_name: &str| {
26113 NodeRequest::IndexProviderSession {
26114 workspace_id,
26115 provider,
26116 identity,
26117 display_name: display_name.to_owned(),
26118 }
26119 };
26120 let request = index_request(
26121 primary_id.clone(),
26122 agent("claude"),
26123 identity.clone(),
26124 "release shepherd",
26125 );
26126 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26127 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26128 }));
26129 let observer = process_request_inner(
26130 &shared,
26131 77,
26132 ClientRole::Observer,
26133 request.clone(),
26134 )
26135 .await
26136 .unwrap_err();
26137 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26138 let uncontrolled = process_request_inner(
26139 &shared,
26140 77,
26141 ClientRole::Operator,
26142 request.clone(),
26143 )
26144 .await
26145 .unwrap_err();
26146 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26147 shared
26148 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26149 .unwrap();
26150
26151 let indexed = process_request_inner(
26152 &shared,
26153 77,
26154 ClientRole::Operator,
26155 request.clone(),
26156 )
26157 .await
26158 .unwrap();
26159 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26160 panic!("provider session reference index returned another response");
26161 };
26162 assert_eq!(record.state, ManagedSessionState::Dormant);
26163 assert_eq!(record.mode, SessionMode::Pty);
26164 assert_eq!(record.workspace_id, primary_id);
26165 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26166 assert!(record.active_session.is_none());
26167 assert!(record.environment_profile.is_none());
26168 assert!(record.bundle.is_none());
26169 assert!(record.context.is_none());
26170
26171 let repeated = process_request_inner(
26172 &shared,
26173 77,
26174 ClientRole::Operator,
26175 index_request(
26176 primary_id.clone(),
26177 agent("claude"),
26178 identity.clone(),
26179 "ignored replacement",
26180 ),
26181 )
26182 .await
26183 .unwrap();
26184 assert!(matches!(
26185 repeated,
26186 NodeResponse::ProviderSessionIndexed { record: ref existing }
26187 if existing.record_id == record.record_id
26188 && existing.display_name == "release shepherd"
26189 ));
26190 let conflicting_workspace = process_request_inner(
26191 &shared,
26192 77,
26193 ClientRole::Operator,
26194 index_request(
26195 secondary_id,
26196 agent("claude"),
26197 identity.clone(),
26198 "release shepherd",
26199 ),
26200 )
26201 .await
26202 .unwrap_err();
26203 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26204 let conflicting_provider = process_request_inner(
26205 &shared,
26206 77,
26207 ClientRole::Operator,
26208 index_request(
26209 primary_id.clone(),
26210 agent("codex"),
26211 identity.clone(),
26212 "release shepherd",
26213 ),
26214 )
26215 .await
26216 .unwrap_err();
26217 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26218 let transcript_rejected = process_request_inner(
26219 &shared,
26220 77,
26221 ClientRole::Operator,
26222 index_request(
26223 primary_id,
26224 agent("claude"),
26225 ProviderSessionIdentity {
26226 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26227 ..identity
26228 },
26229 "release shepherd",
26230 ),
26231 )
26232 .await
26233 .unwrap_err();
26234 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26235 let upserts = shared
26236 .history
26237 .lock()
26238 .unwrap_or_else(|poisoned| poisoned.into_inner())
26239 .events
26240 .iter()
26241 .filter(|event| matches!(
26242 &event.event,
26243 NodeEvent::SessionRecordUpserted { record: published }
26244 if published.record_id == record.record_id
26245 ))
26246 .count();
26247 assert_eq!(upserts, 1);
26248 std::fs::remove_dir_all(secondary_root).unwrap();
26249 }
26250
26251 #[tokio::test]
26252 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26253 let fixture_root = temporary_workspace_root("standalone-independent");
26254 let target = fixture_root.join("independent");
26255 std::fs::create_dir_all(&fixture_root).unwrap();
26256 let catalog = active_registry().unwrap();
26257 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26258 let selected = WorkspaceConfig::new(
26259 WorkspaceId::new("primary").unwrap(),
26260 std::env::current_dir().unwrap(),
26261 )
26262 .unwrap();
26263 let selected_root = selected.canonical_root().to_owned();
26264 let shared = NodeShared::new(
26265 handle,
26266 "fixture-token".to_owned(),
26267 NodeId::new("node-standalone").unwrap(),
26268 vec![selected],
26269 vec![agent("claude")],
26270 );
26271 let workspace_id = WorkspaceId::new("independent").unwrap();
26272 let request = NodeRequest::CreateStandaloneWorkspace {
26273 workspace_id: workspace_id.clone(),
26274 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26275 initial_branch: Some("main".to_owned()),
26276 };
26277 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26278 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26279 }));
26280 assert!(!request_uses_unnegotiated_capability(
26281 &request,
26282 &baseline_capabilities().unwrap(),
26283 ));
26284 assert!(request_uses_unnegotiated_capability(&request, &[]));
26285
26286 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26287 .await
26288 .unwrap_err();
26289 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26290 assert!(!target.exists());
26291 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26292 .await
26293 .unwrap_err();
26294 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26295 assert!(!target.exists());
26296 shared
26297 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26298 .unwrap();
26299
26300 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26301 .await
26302 .unwrap();
26303 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26304 panic!("standalone workspace creation returned another response");
26305 };
26306 assert_eq!(workspace.workspace_id, workspace_id);
26307 assert!(target.join(".git").is_dir());
26308 let branch = run_git_bounded(
26309 target.to_str().unwrap(),
26310 &["branch", "--show-current"],
26311 GIT_OUTPUT_MAX_BYTES,
26312 )
26313 .await
26314 .unwrap();
26315 assert!(branch.success);
26316 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26317 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26318 assert_eq!(worktrees.len(), 1);
26319 let listed_root = WorkspaceConfig::new(
26320 WorkspaceId::new("listed-independent").unwrap(),
26321 &worktrees[0].path,
26322 )
26323 .unwrap();
26324 assert!(platform::roots_equal(
26325 listed_root.canonical_root(),
26326 windows_path_text(&workspace.canonical_root),
26327 ));
26328 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26329 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26330 snapshot.workspace_id == workspace_id
26331 && snapshot.canonical_root == workspace.canonical_root
26332 }));
26333 assert!(shared
26334 .history
26335 .lock()
26336 .unwrap_or_else(|poisoned| poisoned.into_inner())
26337 .events
26338 .iter()
26339 .any(|event| matches!(
26340 &event.event,
26341 NodeEvent::WorkspaceAdded { workspace }
26342 if workspace.workspace_id == workspace_id
26343 )));
26344 std::fs::remove_dir_all(fixture_root).unwrap();
26345 }
26346
26347 #[tokio::test]
26348 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26349 let fixture_root = temporary_workspace_root("standalone-compensation");
26350 let created_target = fixture_root.join("created-target");
26351 let existing_target = fixture_root.join("existing-target");
26352 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26353 let invalid_state_path = invalid_state_parent.join("state.json");
26354 std::fs::create_dir_all(&existing_target).unwrap();
26355 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26356 let catalog = active_registry().unwrap();
26357 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26358 let primary = WorkspaceConfig::new(
26359 WorkspaceId::new("primary").unwrap(),
26360 std::env::current_dir().unwrap(),
26361 )
26362 .unwrap();
26363 let mut shared = NodeShared::new(
26364 handle,
26365 "fixture-token".to_owned(),
26366 NodeId::new("node-standalone-compensation").unwrap(),
26367 vec![primary],
26368 vec![agent("claude")],
26369 );
26370 shared.state_path = Some(invalid_state_path);
26371
26372 let created_recovery = shared
26373 .create_standalone_workspace(
26374 WorkspaceId::new("compensated").unwrap(),
26375 created_target.to_string_lossy().into_owned(),
26376 Some("main".to_owned()),
26377 )
26378 .await
26379 .unwrap_err();
26380 assert_eq!(
26381 created_recovery.code,
26382 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26383 );
26384 assert!(created_target.join(".git").is_dir());
26385 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26386 workspace.workspace_id.as_str() != "compensated"
26387 }));
26388
26389 let existing_recovery = shared
26390 .create_standalone_workspace(
26391 WorkspaceId::new("restored").unwrap(),
26392 existing_target.to_string_lossy().into_owned(),
26393 None,
26394 )
26395 .await
26396 .unwrap_err();
26397 assert_eq!(
26398 existing_recovery.code,
26399 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26400 );
26401 assert!(existing_target.is_dir());
26402 assert!(existing_target.join(".git").is_dir());
26403 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26404 workspace.workspace_id.as_str() != "restored"
26405 }));
26406
26407 let registered_empty = fixture_root.join("registered-empty");
26408 std::fs::create_dir(®istered_empty).unwrap();
26409 let catalog = active_registry().unwrap();
26410 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26411 let registered = WorkspaceConfig::new(
26412 WorkspaceId::new("registered-empty").unwrap(),
26413 ®istered_empty,
26414 )
26415 .unwrap();
26416 let duplicate_shared = NodeShared::new(
26417 handle,
26418 "fixture-token".to_owned(),
26419 NodeId::new("node-standalone-duplicate-root").unwrap(),
26420 vec![registered],
26421 vec![agent("claude")],
26422 );
26423 let duplicate = duplicate_shared
26424 .create_standalone_workspace(
26425 WorkspaceId::new("duplicate-root").unwrap(),
26426 registered_empty.to_string_lossy().into_owned(),
26427 None,
26428 )
26429 .await
26430 .unwrap_err();
26431 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26432 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26433 std::fs::remove_dir_all(fixture_root).unwrap();
26434 }
26435
26436 #[test]
26437 fn managed_record_id_collision_preserves_the_existing_record() {
26438 let catalog = active_registry().unwrap();
26439 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26440 let workspace = WorkspaceConfig::new(
26441 WorkspaceId::new("primary").unwrap(),
26442 std::env::current_dir().unwrap(),
26443 )
26444 .unwrap();
26445 let shared = NodeShared::new(
26446 handle,
26447 "fixture-token".to_owned(),
26448 NodeId::new("node-1").unwrap(),
26449 vec![workspace.clone()],
26450 vec![agent("claude")],
26451 );
26452 let record_id = SessionRecordId::new("sr-collision").unwrap();
26453 let original = ManagedSessionRecord {
26454 record_id: record_id.clone(),
26455 display_name: "original".to_owned(),
26456 provider: agent("claude"),
26457 mode: SessionMode::Pty,
26458 state: ManagedSessionState::Dormant,
26459 workspace_id: workspace.workspace_id,
26460 canonical_root: opaque_windows_path(workspace.canonical_root),
26461 provider_session: None,
26462 active_session: None,
26463 environment_profile: None,
26464 bundle: None,
26465 context_id: None,
26466 context: None,
26467 exported_context: None,
26468 task_binding: None,
26469 created_at_unix_ms: 1,
26470 updated_at_unix_ms: 1,
26471 last_error: None,
26472 };
26473 shared.insert_record(original.clone()).unwrap();
26474 let mut collision = original.clone();
26475 collision.display_name = "replacement".to_owned();
26476 let error = shared.insert_record(collision).unwrap_err();
26477 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26478 assert_eq!(shared.record(&record_id).unwrap(), original);
26479 }
26480
26481 #[test]
26482 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26483 let catalog = active_registry().unwrap();
26484 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26485 let workspace = WorkspaceConfig::new(
26486 WorkspaceId::new("primary").unwrap(),
26487 std::env::current_dir().unwrap(),
26488 )
26489 .unwrap();
26490 let shared = NodeShared::new(
26491 handle,
26492 "fixture-token".to_owned(),
26493 NodeId::new("node-1").unwrap(),
26494 vec![workspace.clone()],
26495 vec![agent("claude")],
26496 );
26497 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26498 shared
26499 .insert_record(ManagedSessionRecord {
26500 record_id: record_id.clone(),
26501 display_name: "runtime error".to_owned(),
26502 provider: agent("claude"),
26503 mode: SessionMode::Pty,
26504 state: ManagedSessionState::IdentityPending,
26505 workspace_id: workspace.workspace_id,
26506 canonical_root: opaque_windows_path(workspace.canonical_root),
26507 provider_session: None,
26508 active_session: None,
26509 environment_profile: None,
26510 bundle: None,
26511 context_id: None,
26512 context: None,
26513 exported_context: None,
26514 task_binding: None,
26515 created_at_unix_ms: 1,
26516 updated_at_unix_ms: 1,
26517 last_error: None,
26518 })
26519 .unwrap();
26520
26521 shared
26522 .mark_record_error(
26523 &record_id,
26524 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26525 )
26526 .unwrap();
26527 let record = shared.record(&record_id).unwrap();
26528 assert_eq!(record.state, ManagedSessionState::Unavailable);
26529 assert_eq!(
26530 record.last_error.as_deref(),
26531 Some("provider-runtime-failed"),
26532 );
26533 let snapshot = shared.snapshot();
26534 assert_eq!(snapshot.session_records, vec![record]);
26535 }
26536
26537 #[test]
26546 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26547 let catalog = active_registry().unwrap();
26548 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26549 let workspace = WorkspaceConfig::new(
26550 WorkspaceId::new("primary").unwrap(),
26551 std::env::current_dir().unwrap(),
26552 )
26553 .unwrap();
26554 let shared = NodeShared::new(
26555 handle,
26556 "fixture-token".to_owned(),
26557 NodeId::new("node-1").unwrap(),
26558 vec![workspace],
26559 vec![agent("claude")],
26560 );
26561
26562 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26563
26564 shared.record_rejected_command(
26565 CommandId(1),
26566 "agent 'claude' does not support transport Acp".to_owned(),
26567 true,
26568 );
26569 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26571
26572 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26573 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26574 assert!(reason.unsupported_transport);
26575
26576 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26578
26579 shared.record_rejected_command(
26584 CommandId(3),
26585 "backend busy".to_owned(),
26586 false,
26587 );
26588 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26589 assert!(!unclassified.unsupported_transport);
26590 }
26591
26592 #[test]
26593 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26594 let catalog = active_registry().unwrap();
26595 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26596 let workspace = WorkspaceConfig::new(
26597 WorkspaceId::new("primary").unwrap(),
26598 std::env::current_dir().unwrap(),
26599 )
26600 .unwrap();
26601 let shared = NodeShared::new(
26602 handle,
26603 "fixture-token".to_owned(),
26604 NodeId::new("node-identity-policy").unwrap(),
26605 vec![workspace.clone()],
26606 vec![agent("claude")],
26607 );
26608 let address = SessionAddress {
26609 workspace_id: workspace.workspace_id.clone(),
26610 session: SessionKey {
26611 instance_id: AgentInstanceId(90),
26612 generation: SessionGeneration(1),
26613 },
26614 };
26615 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26616 shared
26617 .insert_record(ManagedSessionRecord {
26618 record_id: record_id.clone(),
26619 display_name: "policy denied".to_owned(),
26620 provider: agent("claude"),
26621 mode: SessionMode::Pty,
26622 state: ManagedSessionState::IdentityPending,
26623 workspace_id: workspace.workspace_id,
26624 canonical_root: opaque_windows_path(workspace.canonical_root),
26625 provider_session: None,
26626 active_session: Some(address.clone()),
26627 environment_profile: None,
26628 bundle: None,
26629 context_id: None,
26630 context: None,
26631 exported_context: None,
26632 task_binding: None,
26633 created_at_unix_ms: 1,
26634 updated_at_unix_ms: 1,
26635 last_error: None,
26636 })
26637 .unwrap();
26638 let event = ControlEvent {
26639 sequence: 1,
26640 command_id: None,
26641 instance_id: address.session.instance_id,
26642 generation: address.session.generation,
26643 event: ControlEventKind::ProviderEvent {
26644 sequence: 1,
26645 source: gate4agent_types::ProviderSource {
26646 family: AdapterFamily::PtySemantic,
26647 binding: AdapterBinding::new(
26648 gate4agent_types::AdapterId::new("claude").unwrap(),
26649 "fixture/v1",
26650 gate4agent_types::AdapterVerification::SyntheticFixture,
26651 )
26652 .unwrap(),
26653 },
26654 source_sequence: 1,
26655 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26656 identity: gate4agent_types::ProviderSessionIdentity {
26657 key: gate4agent_types::ProviderSessionKey::SessionId,
26658 id: "provider-session".to_owned(),
26659 transcript_path: None,
26660 },
26661 },
26662 },
26663 };
26664
26665 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26666
26667 let record = shared.record(&record_id).unwrap();
26668 assert!(record.provider_session.is_none());
26669 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26670 assert_eq!(record.active_session.as_ref(), Some(&address));
26671 }
26672
26673 #[test]
26674 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26675 let secondary_root = temporary_workspace_root("identity-scope");
26676 std::fs::create_dir_all(&secondary_root).unwrap();
26677 let catalog = active_registry().unwrap();
26678 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26679 let primary = WorkspaceConfig::new(
26680 WorkspaceId::new("primary").unwrap(),
26681 std::env::current_dir().unwrap(),
26682 )
26683 .unwrap();
26684 let secondary = WorkspaceConfig::new(
26685 WorkspaceId::new("secondary").unwrap(),
26686 &secondary_root,
26687 )
26688 .unwrap();
26689 let shared = NodeShared::new(
26690 handle,
26691 "fixture-token".to_owned(),
26692 NodeId::new("node-1").unwrap(),
26693 vec![primary.clone(), secondary.clone()],
26694 vec![agent("claude")],
26695 );
26696 let identity = gate4agent_types::ProviderSessionIdentity {
26697 key: gate4agent_types::ProviderSessionKey::SessionId,
26698 id: "provider-session".to_owned(),
26699 transcript_path: None,
26700 };
26701 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26702 shared
26703 .insert_record(ManagedSessionRecord {
26704 record_id: dormant_id.clone(),
26705 display_name: "dormant".to_owned(),
26706 provider: agent("claude"),
26707 mode: SessionMode::Pty,
26708 state: ManagedSessionState::Dormant,
26709 workspace_id: primary.workspace_id,
26710 canonical_root: opaque_windows_path(primary.canonical_root),
26711 provider_session: Some(identity.clone()),
26712 active_session: None,
26713 environment_profile: None,
26714 bundle: None,
26715 context_id: None,
26716 context: None,
26717 exported_context: None,
26718 task_binding: None,
26719 created_at_unix_ms: 1,
26720 updated_at_unix_ms: 1,
26721 last_error: None,
26722 })
26723 .unwrap();
26724 let address = SessionAddress {
26725 workspace_id: secondary.workspace_id.clone(),
26726 session: SessionKey {
26727 instance_id: AgentInstanceId(91),
26728 generation: SessionGeneration(1),
26729 },
26730 };
26731 let current_id = SessionRecordId::new("sr-current").unwrap();
26732 shared
26733 .insert_record(ManagedSessionRecord {
26734 record_id: current_id.clone(),
26735 display_name: "current".to_owned(),
26736 provider: agent("claude"),
26737 mode: SessionMode::Pty,
26738 state: ManagedSessionState::IdentityPending,
26739 workspace_id: secondary.workspace_id,
26740 canonical_root: opaque_windows_path(secondary.canonical_root),
26741 provider_session: None,
26742 active_session: Some(address.clone()),
26743 environment_profile: None,
26744 bundle: None,
26745 context_id: None,
26746 context: None,
26747 exported_context: None,
26748 task_binding: None,
26749 created_at_unix_ms: 2,
26750 updated_at_unix_ms: 2,
26751 last_error: None,
26752 })
26753 .unwrap();
26754 shared.bind_managed_session(
26755 &address,
26756 current_id.clone(),
26757 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26758 None,
26759 );
26760 let event = ControlEvent {
26761 sequence: 1,
26762 command_id: None,
26763 instance_id: address.session.instance_id,
26764 generation: address.session.generation,
26765 event: ControlEventKind::ProviderEvent {
26766 sequence: 1,
26767 source: gate4agent_types::ProviderSource {
26768 family: gate4agent_types::AdapterFamily::PtySemantic,
26769 binding: gate4agent_types::AdapterBinding::new(
26770 gate4agent_types::AdapterId::new("claude").unwrap(),
26771 "fixture/v1",
26772 gate4agent_types::AdapterVerification::SyntheticFixture,
26773 )
26774 .unwrap(),
26775 },
26776 source_sequence: 1,
26777 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26778 identity,
26779 },
26780 },
26781 };
26782 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26783
26784 let binding = shared
26785 .session_bindings
26786 .lock()
26787 .unwrap_or_else(|poisoned| poisoned.into_inner())
26788 .get(&address.session.instance_id)
26789 .cloned()
26790 .unwrap();
26791 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26792 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26793 let current = shared.record(¤t_id).unwrap();
26794 assert_eq!(current.state, ManagedSessionState::Unavailable);
26795 assert_eq!(current.active_session.as_ref(), Some(&address));
26796 assert_eq!(
26797 current.last_error.as_deref(),
26798 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26799 );
26800 std::fs::remove_dir_all(secondary_root).unwrap();
26801 }
26802
26803 #[tokio::test]
26804 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26805 let root = temporary_workspace_root("semantic-provider-session");
26806 std::fs::create_dir_all(&root).unwrap();
26807 let state_path = root.join("state-v1.json");
26808 let catalog = active_registry().unwrap();
26809 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26810 let workspace = WorkspaceConfig::new(
26811 WorkspaceId::new("primary").unwrap(),
26812 &root,
26813 )
26814 .unwrap();
26815 let node_id = NodeId::new("node-semantic-identity").unwrap();
26816 let shared = NodeShared::new_with_incarnation(
26817 handle,
26818 "fixture-token".to_owned(),
26819 node_id.clone(),
26820 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26821 vec![workspace.clone()],
26822 vec![agent("claude")],
26823 ProviderRuntimeStatuses::default(),
26824 None,
26825 Vec::new(),
26826 Vec::new(),
26827 SpawnProfileRegistry::default(),
26828 None,
26829 None,
26830 None,
26831 Some(state_path.clone()),
26832 Vec::new(),
26833 Vec::new(),
26834 Vec::new(),
26835 Vec::new(),
26836 None,
26837 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26838 );
26839 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26840 key: gate4agent_types::ProviderSessionKey::SessionId,
26841 id: "provider-session".to_owned(),
26842 transcript_path: None,
26843 };
26844 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26845 shared
26846 .insert_record(ManagedSessionRecord {
26847 record_id: durable_id.clone(),
26848 display_name: "durable".to_owned(),
26849 provider: agent("claude"),
26850 mode: SessionMode::Pty,
26851 state: ManagedSessionState::Dormant,
26852 workspace_id: workspace.workspace_id.clone(),
26853 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26854 provider_session: Some(persisted_identity.clone()),
26855 active_session: None,
26856 environment_profile: None,
26857 bundle: None,
26858 context_id: None,
26859 context: None,
26860 exported_context: None,
26861 task_binding: None,
26862 created_at_unix_ms: 1,
26863 updated_at_unix_ms: 1,
26864 last_error: None,
26865 })
26866 .unwrap();
26867 let address = SessionAddress {
26868 workspace_id: workspace.workspace_id.clone(),
26869 session: SessionKey {
26870 instance_id: AgentInstanceId(92),
26871 generation: SessionGeneration(2),
26872 },
26873 };
26874 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26875 shared
26876 .insert_record(ManagedSessionRecord {
26877 record_id: pending_id.clone(),
26878 display_name: "pending".to_owned(),
26879 provider: agent("claude"),
26880 mode: SessionMode::Pty,
26881 state: ManagedSessionState::IdentityPending,
26882 workspace_id: workspace.workspace_id.clone(),
26883 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26884 provider_session: None,
26885 active_session: Some(address.clone()),
26886 environment_profile: None,
26887 bundle: None,
26888 context_id: None,
26889 context: None,
26890 exported_context: None,
26891 task_binding: None,
26892 created_at_unix_ms: 2,
26893 updated_at_unix_ms: 2,
26894 last_error: None,
26895 })
26896 .unwrap();
26897 shared.bind_managed_session(
26898 &address,
26899 pending_id.clone(),
26900 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26901 None,
26902 );
26903 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26904 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26905 ..persisted_identity
26906 };
26907 let event = ControlEvent {
26908 sequence: 1,
26909 command_id: None,
26910 instance_id: address.session.instance_id,
26911 generation: address.session.generation,
26912 event: ControlEventKind::ProviderEvent {
26913 sequence: 1,
26914 source: gate4agent_types::ProviderSource {
26915 family: gate4agent_types::AdapterFamily::PtySemantic,
26916 binding: gate4agent_types::AdapterBinding::new(
26917 gate4agent_types::AdapterId::new("claude").unwrap(),
26918 "fixture/v1",
26919 gate4agent_types::AdapterVerification::SyntheticFixture,
26920 )
26921 .unwrap(),
26922 },
26923 source_sequence: 1,
26924 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26925 identity: observed_identity.clone(),
26926 },
26927 },
26928 };
26929 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26930
26931 let snapshot = shared.snapshot();
26932 assert_eq!(snapshot.session_records.len(), 1);
26933 let live = &snapshot.session_records[0];
26934 assert_eq!(live.record_id, durable_id);
26935 assert_eq!(live.state, ManagedSessionState::Live);
26936 assert_eq!(live.active_session.as_ref(), Some(&address));
26937 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26938 let binding = shared
26939 .session_bindings
26940 .lock()
26941 .unwrap_or_else(|poisoned| poisoned.into_inner())
26942 .get(&address.session.instance_id)
26943 .cloned()
26944 .unwrap();
26945 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26946
26947 let mut duplicate = live.clone();
26948 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26949 duplicate.display_name = "duplicate".to_owned();
26950 duplicate.state = ManagedSessionState::Dormant;
26951 duplicate.active_session = None;
26952 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26953 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26954 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26955 assert_eq!(shared.snapshot().session_records.len(), 1);
26956
26957 let terminal_size = gate4agent_types::TerminalSize {
26958 rows: 24,
26959 columns: 80,
26960 };
26961 let (first_busy, second_busy) = tokio::join!(
26962 shared.resume_session_record(&durable_id, terminal_size, None),
26963 shared.resume_session_record(&durable_id, terminal_size, None),
26964 );
26965 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26966 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26967 assert_eq!(busy.message, "session-record-busy");
26968 }
26969
26970 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26971 assert_eq!(loaded.records.len(), 1);
26972 assert_eq!(loaded.records[0].record_id, durable_id);
26973 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26974 assert_eq!(
26975 loaded.records[0]
26976 .provider_session
26977 .as_ref()
26978 .unwrap()
26979 .transcript_path,
26980 None,
26981 );
26982 let catalog = active_registry().unwrap();
26983 let (reloaded_handle, _reloaded_runtime) =
26984 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26985 let reloaded = NodeShared::new_with_incarnation(
26986 reloaded_handle,
26987 "fixture-token".to_owned(),
26988 node_id,
26989 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26990 vec![workspace],
26991 vec![agent("claude")],
26992 ProviderRuntimeStatuses::default(),
26993 None,
26994 Vec::new(),
26995 Vec::new(),
26996 SpawnProfileRegistry::default(),
26997 None,
26998 None,
26999 None,
27000 None,
27001 loaded.records,
27002 loaded.managed_worktrees,
27003 loaded.managed_worktree_tombstones,
27004 loaded.materializations,
27005 loaded.warning,
27006 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27007 );
27008 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27009 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27010 std::fs::remove_dir_all(root).unwrap();
27011 }
27012
27013 #[tokio::test]
27014 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27015 let secondary_root = temporary_workspace_root("workspace-restore");
27016 let other_root = temporary_workspace_root("workspace-conflict");
27017 std::fs::create_dir_all(&secondary_root).unwrap();
27018 std::fs::create_dir_all(&other_root).unwrap();
27019 let catalog = active_registry().unwrap();
27020 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27021 let primary = WorkspaceConfig::new(
27022 WorkspaceId::new("primary").unwrap(),
27023 std::env::current_dir().unwrap(),
27024 )
27025 .unwrap();
27026 let secondary = WorkspaceConfig::new(
27027 WorkspaceId::new("secondary").unwrap(),
27028 &secondary_root,
27029 )
27030 .unwrap();
27031 let shared = NodeShared::new(
27032 handle,
27033 "fixture-token".to_owned(),
27034 NodeId::new("node-1").unwrap(),
27035 vec![primary, secondary.clone()],
27036 vec![agent("claude")],
27037 );
27038 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27039 shared
27040 .insert_record(ManagedSessionRecord {
27041 record_id: record_id.clone(),
27042 display_name: "workspace record".to_owned(),
27043 provider: agent("claude"),
27044 mode: SessionMode::Pty,
27045 state: ManagedSessionState::Dormant,
27046 workspace_id: secondary.workspace_id.clone(),
27047 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27048 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27049 key: gate4agent_types::ProviderSessionKey::SessionId,
27050 id: "provider-session".to_owned(),
27051 transcript_path: None,
27052 }),
27053 active_session: None,
27054 environment_profile: None,
27055 bundle: None,
27056 context_id: None,
27057 context: None,
27058 exported_context: None,
27059 task_binding: None,
27060 created_at_unix_ms: 1,
27061 updated_at_unix_ms: 1,
27062 last_error: None,
27063 })
27064 .unwrap();
27065 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27066 let unavailable = shared.record(&record_id).unwrap();
27067 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27068 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27069
27070 let conflict = shared
27071 .register_workspace(
27072 secondary.workspace_id.clone(),
27073 other_root.to_string_lossy().into_owned(),
27074 )
27075 .await
27076 .unwrap_err();
27077 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27078 shared
27079 .register_workspace(
27080 secondary.workspace_id,
27081 secondary_root.to_string_lossy().into_owned(),
27082 )
27083 .await
27084 .unwrap();
27085 let restored = shared.record(&record_id).unwrap();
27086 assert_eq!(restored.state, ManagedSessionState::Dormant);
27087 assert!(restored.last_error.is_none());
27088 std::fs::remove_dir_all(secondary_root).unwrap();
27089 std::fs::remove_dir_all(other_root).unwrap();
27090 }
27091
27092 #[test]
27093 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27094 let error = validate_workspace_request_root(
27095 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27096 )
27097 .unwrap_err();
27098 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27099 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27100 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27101 }
27102
27103 #[test]
27104 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27105 let catalog = active_registry().unwrap();
27106 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27107 let workspace = WorkspaceConfig::new(
27108 WorkspaceId::new("primary").unwrap(),
27109 std::env::current_dir().unwrap(),
27110 )
27111 .unwrap();
27112 let shared = NodeShared::new(
27113 handle,
27114 "fixture-token".to_owned(),
27115 NodeId::new("node-1").unwrap(),
27116 vec![workspace],
27117 vec![agent("claude")],
27118 );
27119 let publishers = 4_u64;
27120 let events_per_publisher = 256_u64;
27121 let start = std::sync::Barrier::new(publishers as usize + 1);
27122 let finished = AtomicU64::new(0);
27123
27124 std::thread::scope(|scope| {
27125 for _ in 0..publishers {
27126 scope.spawn(|| {
27127 start.wait();
27128 for _ in 0..events_per_publisher {
27129 shared.publish(NodeEvent::ControllerChanged { controller: None });
27130 }
27131 finished.fetch_add(1, Ordering::Release);
27132 });
27133 }
27134 start.wait();
27135 while finished.load(Ordering::Acquire) != publishers {
27136 assert_resync_history_is_linearized(shared.resync(0));
27137 std::thread::yield_now();
27138 }
27139 });
27140 let response = shared.resync(0);
27141 assert_resync_history_is_linearized(response.clone());
27142 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27143 unreachable!("resync helper returned another response");
27144 };
27145 assert_eq!(event_sequence, publishers * events_per_publisher);
27146 assert_eq!(events.len() as u64, event_sequence);
27147 }
27148
27149 fn assert_resync_history_is_linearized(response: NodeResponse) {
27150 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27151 unreachable!("resync helper returned another response");
27152 };
27153 assert!(events.windows(2).all(|pair| {
27154 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27155 }));
27156 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27157 }
27158
27159 #[test]
27160 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27161 assert_eq!(
27162 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27163 r"C:\repo\worktree",
27164 );
27165 assert_eq!(
27166 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27167 r"\\server\share\repo",
27168 );
27169 assert!(validate_workspace_root(
27170 &WorkspaceId::new("network").unwrap(),
27171 r"\\server\share\repo",
27172 )
27173 .is_err());
27174 let workspace = WorkspaceConfig::new(
27175 WorkspaceId::new("primary").unwrap(),
27176 std::env::current_dir().unwrap(),
27177 )
27178 .unwrap();
27179 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27180 }
27181
27182 #[test]
27183 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27184 let catalog = active_registry().unwrap();
27185 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27186 let workspace_id = WorkspaceId::new("primary").unwrap();
27187 let workspace = WorkspaceConfig::new(
27188 workspace_id.clone(),
27189 std::env::current_dir().unwrap(),
27190 )
27191 .unwrap();
27192 let shared = NodeShared::new(
27193 handle,
27194 "fixture-token".to_owned(),
27195 NodeId::new("node-1").unwrap(),
27196 vec![workspace],
27197 vec![agent("claude")],
27198 );
27199 let instance_id = AgentInstanceId(17);
27200 let original = SessionAddress {
27201 workspace_id,
27202 session: SessionKey {
27203 instance_id,
27204 generation: SessionGeneration(3),
27205 },
27206 };
27207 shared.bind_session(
27208 instance_id,
27209 original.workspace_id.clone(),
27210 original.session.generation,
27211 );
27212 shared
27213 .arm_resume(
27214 &original,
27215 CommandId(41),
27216 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27217 )
27218 .unwrap();
27219
27220 shared.publish_control(ControlEvent {
27221 sequence: 1,
27222 command_id: None,
27223 instance_id,
27224 generation: SessionGeneration(4),
27225 event: ControlEventKind::Running { process_id: None },
27226 });
27227 assert_eq!(
27228 shared.address_for(instance_id, SessionGeneration(3)),
27229 Some(original.clone()),
27230 );
27231 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27232
27233 shared.publish_control(ControlEvent {
27234 sequence: 2,
27235 command_id: Some(CommandId(42)),
27236 instance_id,
27237 generation: SessionGeneration(3),
27238 event: ControlEventKind::CommandRejected {
27239 message: "unrelated command".to_owned(),
27240 },
27241 });
27242
27243 shared.publish_control(ControlEvent {
27244 sequence: 3,
27245 command_id: None,
27246 instance_id,
27247 generation: SessionGeneration(4),
27248 event: ControlEventKind::ResumeAuthorized {
27249 session: gate4agent_types::ResumeSessionSummary {
27250 key: gate4agent_types::ProviderSessionKey::SessionId,
27251 id: "fixture-session".to_owned(),
27252 },
27253 },
27254 });
27255 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27256 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27257 assert_eq!(rebound.workspace_id, original.workspace_id);
27258 let history = shared
27259 .history
27260 .lock()
27261 .unwrap_or_else(|poisoned| poisoned.into_inner());
27262 assert!(history.events.iter().any(|envelope| matches!(
27263 &envelope.event,
27264 NodeEvent::Control { address, event }
27265 if address == &rebound
27266 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27267 )));
27268 }
27269
27270 #[test]
27271 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27272 for agent in ["claude", "codex", "kimi"] {
27273 assert_eq!(
27274 prompt_framing(&AgentId::new(agent).unwrap()),
27275 PromptFraming::BracketedPaste
27276 );
27277 }
27278 }
27279
27280 fn generous_walk_deadline() -> Instant {
27281 Instant::now() + Duration::from_secs(30)
27282 }
27283
27284 #[test]
27285 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27286 let root = temporary_workspace_root("tree");
27287 std::fs::create_dir_all(root.join("src")).unwrap();
27288 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27289 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27290 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27291 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27292 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27293 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27299 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27300 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27304 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27305
27306 let (entries, truncated, _budget) =
27307 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27308 assert!(!truncated);
27309 assert!(entries.iter().any(|entry| {
27310 entry.relative_path.as_utf8() == Some("src")
27311 && entry.kind == WorkspaceEntryKind::Directory
27312 }));
27313 assert!(entries.iter().any(|entry| {
27314 entry.relative_path.as_utf8() == Some("src/lib.rs")
27315 && entry.kind == WorkspaceEntryKind::File
27316 }));
27317 assert!(entries.iter().any(|entry| {
27318 entry.relative_path.as_utf8() == Some("target-notes")
27319 && entry.kind == WorkspaceEntryKind::Directory
27320 }));
27321 assert!(entries.iter().any(|entry| {
27322 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27323 && entry.kind == WorkspaceEntryKind::File
27324 }));
27325 assert!(entries.iter().all(|entry| {
27326 let path = entry.relative_path.as_utf8().unwrap();
27327 !path.starts_with(".git")
27328 && !(path == "target" || path.starts_with("target/"))
27329 && !(path == "target-a2" || path.starts_with("target-a2/"))
27330 && !path.starts_with("node_modules")
27331 }));
27332
27333 std::fs::remove_dir_all(&root).unwrap();
27334 }
27335
27336 #[test]
27337 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27338 let root = temporary_workspace_root("tree-entry-cap");
27339 std::fs::create_dir_all(&root).unwrap();
27340 for index in 0..20 {
27341 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27342 }
27343
27344 let (entries, truncated, budget) =
27345 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27346 assert!(truncated);
27347 assert!(budget.entry_cap_exceeded);
27348 assert!(!budget.time_budget_exceeded);
27349 assert!(entries.len() <= 5);
27350
27351 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27352 .expect("an entry-cap-truncated walk must report a truncation marker");
27353 assert!(inspection_truncation.walk_entry_cap_exceeded);
27354 assert!(!inspection_truncation.walk_time_budget_exceeded);
27355 assert!(!inspection_truncation.git_time_budget_exceeded);
27356 assert!(inspection_truncation.entries_visited >= 5);
27357
27358 std::fs::remove_dir_all(&root).unwrap();
27359 }
27360
27361 #[test]
27362 fn workspace_walk_within_budget_reports_no_truncation() {
27363 let root = temporary_workspace_root("tree-no-truncation");
27364 std::fs::create_dir_all(&root).unwrap();
27365 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27366
27367 let (_entries, truncated, budget) =
27368 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27369 assert!(!truncated);
27370 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27371
27372 std::fs::remove_dir_all(&root).unwrap();
27373 }
27374
27375 #[test]
27376 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27377 let root = temporary_workspace_root("tree-prefix-siblings");
27378 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27379 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27380 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27381 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27382
27383 let (entries, truncated, _budget) =
27384 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27385 assert!(!truncated);
27386 let paths = entries
27387 .iter()
27388 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27389 .collect::<Vec<_>>();
27390 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27391 assert_eq!(
27392 paths,
27393 vec![
27394 "gate4agent-c2".to_owned(),
27395 "gate4agent-c2-client".to_owned(),
27396 "gate4agent-c2-client/lib.rs".to_owned(),
27397 "gate4agent-c2/src".to_owned(),
27398 "gate4agent-c2/src/lib.rs".to_owned(),
27399 ],
27400 );
27401
27402 std::fs::remove_dir_all(&root).unwrap();
27403 }
27404
27405 #[test]
27406 fn git_parsers_cap_status_and_commit_results() {
27407 let mut status_output = Vec::new();
27408 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27409 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27410 }
27411 let mut snapshot = git_snapshot_for_parser();
27412 parse_git_status(&status_output, &mut snapshot);
27413 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27414 assert!(snapshot.truncated);
27415 assert_eq!(snapshot.status[0].index_status, " ");
27416 assert_eq!(snapshot.status[0].worktree_status, "M");
27417
27418 snapshot.truncated = false;
27419 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27420 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27421 .collect::<String>();
27422 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27423 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27424 assert!(snapshot.truncated);
27425 }
27426
27427 #[test]
27428 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27429 let mut snapshot = git_snapshot_for_parser();
27430 parse_git_status(
27431 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27432 &mut snapshot,
27433 );
27434
27435 assert!(!snapshot.truncated);
27436 assert_eq!(snapshot.status.len(), 3);
27437 assert_eq!(snapshot.status[0].index_status, "R");
27438 assert_eq!(snapshot.status[0].worktree_status, " ");
27439 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27440 assert_eq!(
27441 snapshot.status[0]
27442 .previous_path
27443 .as_ref()
27444 .and_then(RepositoryPath::as_utf8),
27445 Some("src/previous name.rs"),
27446 );
27447 assert_eq!(snapshot.status[1].index_status, " ");
27448 assert_eq!(snapshot.status[1].worktree_status, "C");
27449 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27450 assert_eq!(
27451 snapshot.status[1]
27452 .previous_path
27453 .as_ref()
27454 .and_then(RepositoryPath::as_utf8),
27455 Some("src/original.rs"),
27456 );
27457 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27458 assert!(snapshot.status[2].previous_path.is_none());
27459 }
27460
27461 #[test]
27462 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27463 let mut snapshot = git_snapshot_for_parser();
27464 parse_git_status(
27465 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27466 &mut snapshot,
27467 );
27468
27469 assert!(snapshot.truncated);
27470 assert_eq!(snapshot.status.len(), 1);
27471 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27472 assert!(snapshot.status[0].previous_path.is_none());
27473 }
27474
27475 #[tokio::test]
27476 async fn observer_can_browse_host_directories_without_controller() {
27477 let root = temporary_workspace_root("host-directory-observer");
27478 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27479 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27480 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27481 let workspace_id = WorkspaceId::new("primary").unwrap();
27482 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27483 let catalog = active_registry().unwrap();
27484 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27485 let shared = NodeShared::new(
27486 handle,
27487 "fixture-token".to_owned(),
27488 NodeId::new("node-1").unwrap(),
27489 vec![workspace],
27490 vec![agent("claude")],
27491 );
27492 let request = NodeRequest::BrowseHostDirectories {
27493 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27494 after: None,
27495 };
27496 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27497 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27498 }));
27499 assert!(!request_uses_unnegotiated_capability(
27500 &request,
27501 &baseline_capabilities().unwrap(),
27502 ));
27503 assert!(request_uses_unnegotiated_capability(&request, &[]));
27504
27505 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27506 .await
27507 .unwrap();
27508 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27509 panic!("host directory browse returned another response");
27510 };
27511 assert_eq!(
27512 listing
27513 .entries
27514 .iter()
27515 .map(|entry| entry.display_name.as_str())
27516 .collect::<Vec<_>>(),
27517 vec!["directory-a", "directory-b"],
27518 );
27519 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27520 assert!(!listing.incomplete);
27521 std::fs::remove_dir_all(root).unwrap();
27522 }
27523
27524 #[tokio::test]
27525 async fn observer_can_inspect_only_a_registered_workspace() {
27526 let root = temporary_workspace_root("observer");
27527 std::fs::create_dir_all(root.join("src")).unwrap();
27528 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27529 let workspace_id = WorkspaceId::new("primary").unwrap();
27530 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27531 let catalog = active_registry().unwrap();
27532 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27533 let shared = NodeShared::new(
27534 handle,
27535 "fixture-token".to_owned(),
27536 NodeId::new("node-1").unwrap(),
27537 vec![workspace],
27538 vec![agent("claude")],
27539 );
27540
27541 let response = process_request_inner(
27542 &shared,
27543 77,
27544 ClientRole::Observer,
27545 NodeRequest::InspectWorkspace {
27546 workspace_id: workspace_id.clone(),
27547 },
27548 )
27549 .await
27550 .unwrap();
27551 let NodeResponse::WorkspaceInspected { inspection } = response else {
27552 panic!("workspace inspection returned another response");
27553 };
27554 assert_eq!(inspection.workspace_id, workspace_id);
27555 assert!(inspection.entries.iter().any(|entry| {
27556 entry.relative_path.as_utf8() == Some("src/main.rs")
27557 }));
27558
27559 let unknown = process_request_inner(
27560 &shared,
27561 77,
27562 ClientRole::Observer,
27563 NodeRequest::InspectWorkspace {
27564 workspace_id: WorkspaceId::new("unknown").unwrap(),
27565 },
27566 )
27567 .await
27568 .unwrap_err();
27569 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27570 std::fs::remove_dir_all(&root).unwrap();
27571 }
27572
27573 #[test]
27574 fn context_pack_source_accepts_only_running_or_successful_exit() {
27575 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27576 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27577 exit_code: Some(0),
27578 }));
27579 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27580 exit_code: Some(1),
27581 }));
27582 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27583 exit_code: None,
27584 }));
27585 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27586 message: "provider failed".to_owned(),
27587 }));
27588 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27589 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27590 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27591 }
27592
27593 #[test]
27594 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27595 let git = git_snapshot_for_parser();
27596 let first = ContextPackRepositoryObservation {
27597 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27598 git: git.clone(),
27599 files: Vec::new(),
27600 };
27601 let second = ContextPackRepositoryObservation {
27602 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27603 git,
27604 files: Vec::new(),
27605 };
27606
27607 let error = stable_context_pack_repository(first, second).unwrap_err();
27608
27609 assert_eq!(
27610 error.code,
27611 NodeFailureCode::ContextPackMaterializationFailed,
27612 );
27613 }
27614
27615 #[test]
27616 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27617 let git = git_snapshot_for_parser();
27618 let first = ContextPackRepositoryObservation {
27619 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27620 git: git.clone(),
27621 files: vec![ContextPackRepositoryFileSource::utf8(
27622 "README.md",
27623 "first\n".to_owned(),
27624 6,
27625 )],
27626 };
27627 let second = ContextPackRepositoryObservation {
27628 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27629 git,
27630 files: vec![ContextPackRepositoryFileSource::utf8(
27631 "README.md",
27632 "other\n".to_owned(),
27633 6,
27634 )],
27635 };
27636
27637 let error = stable_context_pack_repository(first, second).unwrap_err();
27638
27639 assert_eq!(
27640 error.code,
27641 NodeFailureCode::ContextPackMaterializationFailed,
27642 );
27643 }
27644
27645 #[tokio::test]
27646 async fn native_session_catalog_zero_active_session_fixture() {
27647 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27648 let history_root = temporary_workspace_root("native-catalog-history");
27649 let outside_root = temporary_workspace_root("native-catalog-outside");
27650 std::fs::create_dir_all(&workspace_root).unwrap();
27651 std::fs::create_dir_all(&history_root).unwrap();
27652 std::fs::create_dir_all(&outside_root).unwrap();
27653 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27654 [
27655 serde_json::json!({
27656 "type": "user",
27657 "sessionId": session_id,
27658 "cwd": cwd.to_str().unwrap(),
27659 "message": { "content": "raw user text must not cross the catalog wire" }
27660 }),
27661 serde_json::json!({
27662 "type": "assistant",
27663 "sessionId": session_id,
27664 "cwd": cwd.to_str().unwrap(),
27665 "message": { "model": "claude-sonnet", "content": "raw answer" }
27666 }),
27667 serde_json::json!({
27668 "type": "ai-title",
27669 "sessionId": session_id,
27670 "aiTitle": title
27671 }),
27672 ]
27673 .into_iter()
27674 .map(|record| record.to_string())
27675 .collect::<Vec<_>>()
27676 .join("\n")
27677 };
27678 std::fs::write(
27679 history_root.join("stable-session.jsonl"),
27680 transcript("stable-session", &workspace_root, "Workspace review"),
27681 )
27682 .unwrap();
27683 std::fs::write(
27684 history_root.join("outside-session.jsonl"),
27685 transcript("outside-session", &outside_root, "Outside review"),
27686 )
27687 .unwrap();
27688 let history = NativeHistoryConfig::new(vec![
27689 NativeHistoryRoot::new(
27690 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27691 HistorySourceLayout::SingleNdjson,
27692 history_root.clone(),
27693 )
27694 .unwrap(),
27695 ])
27696 .unwrap();
27697 let catalog = active_registry().unwrap();
27698 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27699 assert!(handle.snapshot().sessions.is_empty());
27700 let workspace_id = WorkspaceId::new("primary").unwrap();
27701 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27702 let mut shared = NodeShared::new(
27703 handle,
27704 "fixture-token".to_owned(),
27705 NodeId::new("node-native-catalog").unwrap(),
27706 vec![workspace],
27707 vec![agent("claude")],
27708 );
27709 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27710 NativeSessionCatalogAuthority::new(history),
27711 )));
27712 let route = NativeSessionCatalogRoute::workspace(
27713 workspace_id.clone(),
27714 agent("claude"),
27715 );
27716 let request = NodeRequest::CatalogNativeSessions {
27717 route: route.clone(),
27718 limit: 2,
27719 };
27720 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27721 .await
27722 .unwrap_err();
27723 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27724 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27725 .await
27726 .unwrap();
27727 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27728 panic!("native catalog returned a different response");
27729 };
27730 let summary = summary.expect("node must emit native session catalog summary metadata");
27731 assert_eq!(entries.len(), 1);
27732 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27733 assert_eq!(entries[0].external_group, None);
27734 assert_eq!(entries[0].record_id, None);
27735 assert_eq!(entries[0].title, None);
27736 assert_eq!(entries[0].model, None);
27737 assert_eq!(entries[0].message_count, 0);
27738 let encoded_entries = serde_json::to_string(&entries).unwrap();
27739 assert!(!encoded_entries.contains("stable-session"));
27740 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27741 let page_request = NodeRequest::PageNativeSessions {
27742 route: route.clone(),
27743 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27744 catalog_revision: summary.catalog_revision,
27745 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27746 after_selection_id: None,
27747 limit: 2,
27748 };
27749 let observer = process_request_inner(
27750 &shared,
27751 7,
27752 ClientRole::Observer,
27753 page_request.clone(),
27754 )
27755 .await
27756 .unwrap_err();
27757 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27758 let page_response = process_request_inner(
27759 &shared,
27760 7,
27761 ClientRole::Operator,
27762 page_request.clone(),
27763 )
27764 .await
27765 .unwrap();
27766 assert!(matches!(
27767 page_response,
27768 NodeResponse::NativeSessionsPaged { page, .. }
27769 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27770 ));
27771 let stale = process_request_inner(
27772 &shared,
27773 7,
27774 ClientRole::Operator,
27775 NodeRequest::PageNativeSessions {
27776 route: route.clone(),
27777 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27778 catalog_revision: summary.catalog_revision.wrapping_add(1),
27779 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27780 after_selection_id: None,
27781 limit: 2,
27782 },
27783 )
27784 .await
27785 .unwrap_err();
27786 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27787 let selection = NativeSessionSelection {
27788 route: route.clone(),
27789 catalog_revision: summary.catalog_revision,
27790 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27791 selection_id: entries[0].selection_id.clone(),
27792 };
27793 let preview_response = process_request_inner(
27794 &shared,
27795 7,
27796 ClientRole::Operator,
27797 NodeRequest::PreviewNativeSession {
27798 selection: selection.clone(),
27799 message_limit: 1,
27800 },
27801 )
27802 .await
27803 .unwrap();
27804 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27805 panic!("native preview returned a different response");
27806 };
27807 assert_eq!(preview.messages.len(), 1);
27808 assert!(preview.message_count_exact);
27809 assert_eq!(preview.messages[0].text, "raw answer");
27810 assert!(preview.truncated);
27811
27812 let index_request = NodeRequest::IndexNativeSession {
27813 selection: selection.clone(),
27814 display_name: "Saved Claude".to_owned(),
27815 };
27816 let observer = process_request_inner(
27817 &shared,
27818 7,
27819 ClientRole::Observer,
27820 index_request.clone(),
27821 )
27822 .await
27823 .unwrap_err();
27824 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27825 let without_controller = process_request_inner(
27826 &shared,
27827 7,
27828 ClientRole::Operator,
27829 index_request.clone(),
27830 )
27831 .await
27832 .unwrap_err();
27833 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27834 let acquired = process_request_inner(
27835 &shared,
27836 7,
27837 ClientRole::Operator,
27838 NodeRequest::AcquireController { lease_ms: 5_000 },
27839 )
27840 .await
27841 .unwrap();
27842 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27843 let indexed = process_request_inner(
27844 &shared,
27845 7,
27846 ClientRole::Operator,
27847 index_request,
27848 )
27849 .await
27850 .unwrap();
27851 assert!(indexed.native_session_index_contract_is_valid());
27852 let NodeResponse::NativeSessionIndexed {
27853 selection: echoed_selection,
27854 record,
27855 } = indexed else {
27856 panic!("native session index returned a different response");
27857 };
27858 assert_eq!(echoed_selection, selection);
27859 let recataloged = process_request_inner(
27860 &shared,
27861 7,
27862 ClientRole::Operator,
27863 NodeRequest::CatalogNativeSessions {
27864 route,
27865 limit: 2,
27866 },
27867 )
27868 .await
27869 .unwrap();
27870 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27871 panic!("native catalog returned a different response after indexing");
27872 };
27873 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27874
27875 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27876 let external_catalog = process_request_inner(
27877 &shared,
27878 7,
27879 ClientRole::Operator,
27880 NodeRequest::CatalogNativeSessions {
27881 route: unregistered_route.clone(),
27882 limit: 2,
27883 },
27884 )
27885 .await
27886 .unwrap();
27887 let external_json = serde_json::to_string(&external_catalog).unwrap();
27888 assert!(!external_json.contains("outside-session"));
27889 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27890 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27891 assert!(!external_json.contains("\"session_id\""));
27892 assert!(!external_json.contains("\"cwd\""));
27893 assert!(!external_json.contains("\"path\""));
27894 let NodeResponse::NativeSessionsCataloged {
27895 route: echoed_route,
27896 entries: external_entries,
27897 summary: Some(external_summary),
27898 } = external_catalog
27899 else {
27900 panic!("unregistered native catalog returned a different response");
27901 };
27902 assert_eq!(echoed_route, unregistered_route);
27903 assert_eq!(external_entries.len(), 1);
27904 assert_eq!(external_entries[0].record_id, None);
27905 let external_group = external_entries[0]
27906 .external_group
27907 .as_ref()
27908 .expect("unregistered session must have an external group");
27909 assert_eq!(
27910 external_group.kind,
27911 gate4agent_types::NativeSessionExternalGroupKind::Project,
27912 );
27913 assert!(external_group.group_id.starts_with("external-"));
27914 assert_eq!(
27915 external_group.display_name,
27916 outside_root.file_name().unwrap().to_string_lossy(),
27917 );
27918 let external_selection = NativeSessionSelection {
27919 route: unregistered_route,
27920 catalog_revision: external_summary.catalog_revision,
27921 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27922 selection_id: external_entries[0].selection_id.clone(),
27923 };
27924 let external_preview = process_request_inner(
27925 &shared,
27926 7,
27927 ClientRole::Operator,
27928 NodeRequest::PreviewNativeSession {
27929 selection: external_selection.clone(),
27930 message_limit: 1,
27931 },
27932 )
27933 .await
27934 .unwrap();
27935 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27936 assert!(!external_preview_json.contains("outside-session"));
27937 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27938 assert!(!external_preview_json.contains("\"session_id\""));
27939 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27940 panic!("unregistered native preview returned a different response");
27941 };
27942 assert_eq!(preview.messages.len(), 1);
27943 let external_index = process_request_inner(
27944 &shared,
27945 7,
27946 ClientRole::Operator,
27947 NodeRequest::IndexNativeSession {
27948 selection: external_selection,
27949 display_name: "External Claude".to_owned(),
27950 },
27951 )
27952 .await
27953 .unwrap_err();
27954 assert_eq!(
27955 external_index.code,
27956 NodeFailureCode::WorkspaceRegistrationRequired,
27957 );
27958 let record_response = process_request_inner(
27959 &shared,
27960 7,
27961 ClientRole::Operator,
27962 NodeRequest::PreviewSessionRecord {
27963 record_id: record.record_id.clone(),
27964 message_limit: 2,
27965 },
27966 )
27967 .await
27968 .unwrap();
27969 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27970 panic!("record preview returned a different response");
27971 };
27972 assert_eq!(record_id, record.record_id);
27973 assert_eq!(preview.messages.len(), 2);
27974 let encoded = serde_json::to_string(&preview).unwrap();
27975 assert!(!encoded.contains("stable-session"));
27976 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27977 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27978 panic!("record preview history summary returned a different response");
27979 };
27980 let summaries = events
27981 .iter()
27982 .filter_map(|envelope| match &envelope.event {
27983 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27984 if record_id == &record.record_id =>
27985 {
27986 Some(*summary)
27987 }
27988 _ => None,
27989 })
27990 .collect::<Vec<_>>();
27991 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27992 let summary_json = serde_json::to_string(&summaries).unwrap();
27993 for private in [
27994 "stable-session",
27995 "raw user text must not cross the catalog wire",
27996 "raw answer",
27997 "Workspace review",
27998 "claude-sonnet",
27999 ] {
28000 assert!(!summary_json.contains(private), "{private}");
28001 }
28002 let identity = record.provider_session.clone().unwrap();
28003 assert_eq!(
28004 shared.revalidate_session_record_preview(&record, &identity),
28005 Ok(())
28006 );
28007 shared.remove_record_memory(&record.record_id);
28008 assert_eq!(
28009 shared
28010 .revalidate_session_record_preview(&record, &identity)
28011 .unwrap_err()
28012 .code,
28013 NodeFailureCode::UnknownSessionRecord
28014 );
28015 let released = process_request_inner(
28016 &shared,
28017 7,
28018 ClientRole::Operator,
28019 NodeRequest::ReleaseController,
28020 )
28021 .await
28022 .unwrap();
28023 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28024 assert!(shared.controller_state().is_none());
28025 std::fs::remove_dir_all(workspace_root).unwrap();
28026 std::fs::remove_dir_all(history_root).unwrap();
28027 std::fs::remove_dir_all(outside_root).unwrap();
28028 }
28029
28030 #[tokio::test]
28031 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28032 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28033 std::fs::create_dir_all(&history_root).unwrap();
28034 let history = NativeHistoryConfig::new(vec![
28035 NativeHistoryRoot::new(
28036 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28037 HistorySourceLayout::SingleNdjson,
28038 history_root.clone(),
28039 )
28040 .unwrap(),
28041 ])
28042 .unwrap();
28043 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28044 let held = catalog.lock().unwrap();
28045 let result = timeout(
28046 Duration::from_secs(1),
28047 run_native_session_catalog_operation(
28048 Arc::clone(&catalog),
28049 "native session catalog",
28050 |_| Ok::<(), std::convert::Infallible>(()),
28051 ),
28052 )
28053 .await
28054 .expect("concurrent native catalog request must not wait for the operation deadline")
28055 .unwrap_err();
28056 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28057 drop(held);
28058 std::fs::remove_dir_all(history_root).unwrap();
28059 }
28060
28061 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28062 let unique = std::time::SystemTime::now()
28063 .duration_since(std::time::UNIX_EPOCH)
28064 .unwrap()
28065 .as_nanos();
28066 std::env::temp_dir().join(format!(
28067 "gate4agent-node-{label}-{}-{unique}",
28068 std::process::id(),
28069 ))
28070 }
28071
28072 fn git_snapshot_for_parser() -> GitSnapshot {
28073 GitSnapshot {
28074 is_repository: true,
28075 branch: Some("main".to_owned()),
28076 status: Vec::new(),
28077 recent_commits: Vec::new(),
28078 worktrees: Vec::new(),
28079 managed_worktree: None,
28080 truncated: false,
28081 diagnostic: None,
28082 }
28083 }
28084
28085 #[tokio::test]
28086 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28087 let root = temporary_workspace_root("managed-git-scope");
28088 std::fs::create_dir_all(&root).unwrap();
28089 run_test_git(&root, &["init"]);
28090 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28091 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28092 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28093 run_test_git(&root, &["add", "seed.txt"]);
28094 run_test_git(&root, &["commit", "-m", "seed"]);
28095 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28096
28097 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28098 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28099 let catalog = active_registry().unwrap();
28100 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28101 let shared = NodeShared::new(
28102 handle,
28103 "fixture-token".to_owned(),
28104 NodeId::new("node-1").unwrap(),
28105 vec![workspace],
28106 vec![agent("claude")],
28107 );
28108 let mut lease = ManagedWorktreeLeaseRecord {
28109 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28110 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28111 workspace_id: workspace_id.clone(),
28112 profile_id: WorktreeProfileId::new("default").unwrap(),
28113 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28114 target_root: root.to_string_lossy().into_owned(),
28115 branch: "gate4agent/a".to_owned(),
28116 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28117 expected_head: None,
28118 retention: ManagedWorktreeRetention::Retain,
28119 state: ManagedWorktreeLeaseState::InUse,
28120 session_holders: vec![ManagedWorktreeSessionHolder {
28121 incarnation_id: shared.incarnation_id,
28122 instance_id: AgentInstanceId(7),
28123 generation: SessionGeneration(1),
28124 }],
28125 record_holders: Vec::new(),
28126 cleanup_failure: None,
28127 created_at_unix_ms: 1,
28128 updated_at_unix_ms: 1,
28129 };
28130 *shared
28131 .managed_worktrees
28132 .lock()
28133 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28134 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28135
28136 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28137 let scope = inspection.git.managed_worktree.unwrap();
28138 assert_eq!(scope.lease_id, lease.lease_id);
28139 assert_eq!(scope.branch, "gate4agent/a");
28140 assert_eq!(scope.active_session_count, 1);
28141
28142 lease.branch = "gate4agent/b".to_owned();
28143 *shared
28144 .managed_worktrees
28145 .lock()
28146 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28147 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28148 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28149 assert!(mismatch.git.managed_worktree.is_none());
28150 std::fs::remove_dir_all(root).unwrap();
28151 }
28152
28153 #[tokio::test]
28154 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28155 let root = temporary_workspace_root("workspace-file-write-controller");
28156 std::fs::create_dir_all(root.join("src")).unwrap();
28157 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28158 let workspace_id = WorkspaceId::new("primary").unwrap();
28159 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28160 let catalog = active_registry().unwrap();
28161 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28162 let shared = NodeShared::new(
28163 handle,
28164 "fixture-token".to_owned(),
28165 NodeId::new("node-1").unwrap(),
28166 vec![workspace],
28167 vec![agent("claude")],
28168 );
28169 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28170 let expected_revision = workspace_file_revision(b"old\n");
28171 let request = NodeRequest::WriteWorkspaceFile {
28172 workspace_id: workspace_id.clone(),
28173 path: path.clone(),
28174 expected_revision: expected_revision.clone(),
28175 text: "new\n".to_owned(),
28176 };
28177 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28178 .await
28179 .unwrap_err();
28180 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28181 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28182 .await
28183 .unwrap_err();
28184 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28185 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28186 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28187 let NodeResponse::WorkspaceFileWritten { file } = response else {
28188 panic!("workspace file write returned another response");
28189 };
28190 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28191 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28192 let stale = process_request_inner(
28193 &shared,
28194 77,
28195 ClientRole::Operator,
28196 NodeRequest::WriteWorkspaceFile {
28197 workspace_id,
28198 path,
28199 expected_revision,
28200 text: "lost update\n".to_owned(),
28201 },
28202 )
28203 .await
28204 .unwrap_err();
28205 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28206 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28207 std::fs::remove_dir_all(root).unwrap();
28208 }
28209
28210 #[tokio::test]
28211 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28212 let root = temporary_workspace_root("workspace-entry-create-controller");
28213 std::fs::create_dir_all(root.join("src")).unwrap();
28214 let workspace_id = WorkspaceId::new("primary").unwrap();
28215 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28216 let catalog = active_registry().unwrap();
28217 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28218 let shared = NodeShared::new(
28219 handle,
28220 "fixture-token".to_owned(),
28221 NodeId::new("node-1").unwrap(),
28222 vec![workspace],
28223 vec![agent("claude")],
28224 );
28225 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28226 let request = NodeRequest::CreateWorkspaceFile {
28227 workspace_id: workspace_id.clone(),
28228 path: path.clone(),
28229 };
28230 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28231 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28232 }));
28233 assert!(!request_uses_unnegotiated_capability(
28234 &request,
28235 &baseline_capabilities().unwrap(),
28236 ));
28237 assert!(request_uses_unnegotiated_capability(&request, &[]));
28238 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28239 .await
28240 .unwrap_err();
28241 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28242 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28243 .await
28244 .unwrap_err();
28245 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28246 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28247
28248 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28249 .await
28250 .unwrap();
28251 let NodeResponse::WorkspaceFileCreated { file } = response else {
28252 panic!("workspace file create returned another response");
28253 };
28254 assert_eq!(file.workspace_id, workspace_id);
28255 assert_eq!(file.path, path);
28256 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28257 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28258
28259 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28260 .await
28261 .unwrap_err();
28262 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28263
28264 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28265 let response = process_request_inner(
28266 &shared,
28267 77,
28268 ClientRole::Operator,
28269 NodeRequest::CreateWorkspaceDirectory {
28270 workspace_id: workspace_id.clone(),
28271 path: directory_path.clone(),
28272 },
28273 )
28274 .await
28275 .unwrap();
28276 assert_eq!(
28277 response,
28278 NodeResponse::WorkspaceDirectoryCreated {
28279 workspace_id: workspace_id.clone(),
28280 entry: WorkspaceEntry {
28281 relative_path: directory_path,
28282 kind: WorkspaceEntryKind::Directory,
28283 },
28284 },
28285 );
28286 assert!(root.join("src/nested").is_dir());
28287
28288 let missing_parent = process_request_inner(
28289 &shared,
28290 77,
28291 ClientRole::Operator,
28292 NodeRequest::CreateWorkspaceFile {
28293 workspace_id: workspace_id.clone(),
28294 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28295 },
28296 )
28297 .await
28298 .unwrap_err();
28299 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28300 let non_directory_parent = process_request_inner(
28301 &shared,
28302 77,
28303 ClientRole::Operator,
28304 NodeRequest::CreateWorkspaceFile {
28305 workspace_id,
28306 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28307 },
28308 )
28309 .await
28310 .unwrap_err();
28311 assert_eq!(
28312 non_directory_parent.code,
28313 NodeFailureCode::RepositoryParentNotDirectory,
28314 );
28315 std::fs::remove_dir_all(root).unwrap();
28316 }
28317
28318 #[tokio::test]
28319 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28320 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28321 std::fs::create_dir_all(root.join("src")).unwrap();
28322 #[cfg(windows)]
28323 let root = std::fs::canonicalize(root).unwrap();
28324 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28325 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28326 let barrier = Arc::new(std::sync::Barrier::new(2));
28327 let done = Arc::new(AtomicBool::new(false));
28328 let task_commit_state = Arc::clone(&commit_state);
28329 let task_barrier = Arc::clone(&barrier);
28330 let task_done = Arc::clone(&done);
28331 let task_root = root.clone();
28332 let task = tokio::task::spawn_blocking(move || {
28333 task_barrier.wait();
28334 let result = create_workspace_file_on_disk(
28335 &task_root,
28336 &path,
28337 &task_commit_state,
28338 );
28339 task_done.store(true, Ordering::Release);
28340 result
28341 });
28342
28343 let error = settle_workspace_entry_create(
28344 task,
28345 Arc::clone(&commit_state),
28346 Duration::from_millis(10),
28347 "fixture deadline",
28348 "fixture task failed",
28349 )
28350 .await
28351 .unwrap_err();
28352 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28353 assert_eq!(
28354 commit_state.load(Ordering::Acquire),
28355 WORKSPACE_ENTRY_CREATE_CANCELED,
28356 );
28357
28358 barrier.wait();
28359 timeout(Duration::from_secs(1), async {
28360 while !done.load(Ordering::Acquire) {
28361 tokio::task::yield_now().await;
28362 }
28363 })
28364 .await
28365 .expect("canceled blocking create did not settle");
28366 assert!(!root.join("src/late.rs").exists());
28367 std::fs::remove_dir_all(root).unwrap();
28368 }
28369}
28370
28371#[cfg(test)]
28372mod provisional_spawn_tests {
28373 use super::*;
28374 use std::sync::Arc;
28375
28376 fn shared() -> NodeShared {
28377 let catalog = active_registry().unwrap();
28378 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28379 let workspace = WorkspaceConfig::new(
28380 WorkspaceId::new("primary").unwrap(),
28381 std::env::current_dir().unwrap(),
28382 )
28383 .unwrap()
28384 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28385 NodeShared::new(
28386 handle,
28387 "fixture-token".to_owned(),
28388 NodeId::new("node-provisional-test").unwrap(),
28389 vec![workspace],
28390 vec![AgentId::new("claude").unwrap()],
28391 )
28392 }
28393
28394 #[test]
28397 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28398 let shared = shared();
28399 let ws = WorkspaceId::new("primary").unwrap();
28400 let mut ids = Vec::with_capacity(50);
28401 for i in 1u64..=50 {
28402 let id = AgentInstanceId(i);
28403 ids.push(id);
28404 let address = SessionAddress {
28405 workspace_id: ws.clone(),
28406 session: SessionKey {
28407 instance_id: id,
28408 generation: SessionGeneration(1),
28409 },
28410 };
28411 shared.mark_spawn_provisional(address);
28412 }
28413 assert_eq!(
28414 shared
28415 .provisional_spawns
28416 .lock()
28417 .unwrap_or_else(|p| p.into_inner())
28418 .len(),
28419 50
28420 );
28421 for id in ids.iter().step_by(2) {
28422 shared.sessionize_spawn(*id);
28423 }
28424 assert_eq!(
28425 shared
28426 .provisional_spawns
28427 .lock()
28428 .unwrap_or_else(|p| p.into_inner())
28429 .len(),
28430 25
28431 );
28432 assert!(shared.take_due_provisional_spawns().is_empty());
28433 {
28434 let mut map = shared
28435 .provisional_spawns
28436 .lock()
28437 .unwrap_or_else(|p| p.into_inner());
28438 let past = Instant::now() - Duration::from_secs(91);
28439 for (_, (_, at)) in map.iter_mut() {
28440 *at = past;
28441 }
28442 }
28443 let due = shared.take_due_provisional_spawns();
28444 assert_eq!(due.len(), 25);
28445 let due_ids: std::collections::BTreeSet<_> =
28446 due.iter().map(|a| a.session.instance_id.0).collect();
28447 let expected: std::collections::BTreeSet<_> =
28448 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28449 assert_eq!(due_ids, expected);
28450 assert!(shared
28451 .provisional_spawns
28452 .lock()
28453 .unwrap_or_else(|p| p.into_inner())
28454 .is_empty());
28455 }
28456
28457 #[test]
28460 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28461 let shared = Arc::new(shared());
28462 let ws = WorkspaceId::new("primary").unwrap();
28463 let mut handles = Vec::new();
28464 for i in 1u64..=20 {
28465 let shared = Arc::clone(&shared);
28466 let ws = ws.clone();
28467 handles.push(std::thread::spawn(move || {
28468 let id = AgentInstanceId(i);
28469 let address = SessionAddress {
28470 workspace_id: ws,
28471 session: SessionKey {
28472 instance_id: id,
28473 generation: SessionGeneration(1),
28474 },
28475 };
28476 shared.mark_spawn_provisional(address);
28477 id
28478 }));
28479 }
28480 let mut ids = Vec::new();
28481 for h in handles {
28482 ids.push(h.join().unwrap());
28483 }
28484 ids.sort_by_key(|id| id.0);
28485 assert_eq!(ids.len(), 20);
28486 ids.dedup();
28487 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28488 assert_eq!(
28489 shared
28490 .provisional_spawns
28491 .lock()
28492 .unwrap_or_else(|p| p.into_inner())
28493 .len(),
28494 20
28495 );
28496 shared.sessionize_spawn(AgentInstanceId(7));
28497 let left: Vec<_> = shared
28498 .provisional_spawns
28499 .lock()
28500 .unwrap_or_else(|p| p.into_inner())
28501 .keys()
28502 .map(|id| id.0)
28503 .collect();
28504 assert!(!left.contains(&7));
28505 assert_eq!(left.len(), 19);
28506 }
28507}
28508
28509
28510#[cfg(unix)]
28511extern "C" {
28512 #[link_name = "geteuid"]
28513 fn libc_geteuid() -> u32;
28514}
28515
28516#[cfg(test)]
28517mod call_home_link_tests {
28518 use super::*;
28519
28520 #[test]
28521 fn call_home_backoff_is_capped_and_monotonic() {
28522 let mut last = Duration::ZERO;
28523 for attempt in 1..20 {
28524 let next = call_home_backoff(attempt);
28525 assert!(next >= last);
28526 assert!(next <= Duration::from_secs(8));
28527 last = next;
28528 }
28529 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28530 }
28531
28532 #[tokio::test]
28533 async fn call_home_socket_gets_keepalive() {
28534 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28535 let addr = listener.local_addr().unwrap();
28536 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28537 let client = client.unwrap();
28538 enable_call_home_keepalive(&client).unwrap();
28539 let sock = socket2::SockRef::from(&client);
28540 assert!(sock.keepalive().unwrap());
28541 #[cfg(any(target_os = "linux", target_os = "macos"))]
28542 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28543 }
28544}
28545
28546fn provider_session_exists(provider: &str, id: &str) -> bool {
28548 if provider != "claude" { return false; }
28549 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28550 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28551 let name = format!("{id}.jsonl");
28552 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())))
28553}
28554
28555fn overlay_with_extra_args(
28557 provider: &AgentId,
28558 mode: SessionMode,
28559 overlay: Option<PreparedNativeLaunchOverlay>,
28560 args: Vec<OsString>,
28561) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28562 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28563 Ok(match (overlay, args) {
28564 (prepared, args) if args.is_empty() => prepared,
28565 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28566 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28567 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28568 provider.clone(),
28569 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28570 Vec::new(),
28571 args,
28572 ).map_err(|_| bad())?)),
28573 })
28574}