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 #[cfg(unix)]
4171 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4172 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4173 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4174 let uid = unsafe { libc_geteuid() };
4175 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4176 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4177 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4178 Some(vec![
4179 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4180 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4181 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4182 ])
4183 }
4184 _ => None,
4185 };
4186 #[cfg(not(unix))]
4187 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4188 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4189 (SessionMode::Pty, _, _, Some(identity)) => {
4190 let merged = match environment_overlay {
4191 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4192 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4193 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4194 }
4195 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4196 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4197 }
4198 }
4199 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4200 Some(merged)
4201 }
4202 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4203 let session_key = self.m4a_session_key(instance_id);
4204 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4205 }
4206 (SessionMode::Pty, Some(_), None, _) => {
4207 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4208 environment_overlay
4209 }
4210 _ => environment_overlay,
4211 };
4212 let mut instance_overlay = environment_overlay
4213 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4214 .transpose()?
4215 .flatten();
4216 Ok(PreparedSpawn {
4217 instance_overlay,
4218 environment_selection,
4219 materialization_guard,
4220 harness_mcp_overlay,
4221 #[cfg(feature = "dig2-station-probe")]
4222 browser_station_lease_guard,
4223 materialization_id,
4224 })
4225 }
4226}
4227
4228struct SessionRecords {
4229 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4230}
4231
4232struct SpawnIdempotencyCache {
4233 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4234}
4235
4236struct SpawnIdempotencyEntry {
4237 value: SpawnIdempotencyValue,
4238 expires_at: Instant,
4239}
4240
4241enum SpawnIdempotencyValue {
4242 Standard {
4243 spec: SpawnSpec,
4244 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4245 },
4246 Managed {
4247 request: ManagedWorktreeSpawnRequest,
4248 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4249 },
4250 HarnessMcp,
4251}
4252
4253enum DurableManagedSpawnReplayDecision {
4254 Reserved,
4255 PendingLinked,
4256 Committed(ManagedWorktreeSpawnReceipt),
4257}
4258
4259impl SpawnIdempotencyValue {
4260 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4261 match self {
4262 Self::Standard { result, .. } => result
4263 .as_ref()
4264 .ok()
4265 .and_then(|receipt| receipt.context.as_ref()),
4266 Self::Managed { result, .. } => result
4267 .as_ref()
4268 .ok()
4269 .and_then(|receipt| receipt.spawn.context.as_ref()),
4270 Self::HarnessMcp => None,
4271 }
4272 .is_some_and(|receipt| &receipt.id == context_id)
4273 }
4274}
4275
4276#[derive(Clone, Copy)]
4277struct ControllerLease {
4278 connection_id: u64,
4279 expires_at: Instant,
4280}
4281
4282struct NodeEventHistory {
4283 last_sequence: u64,
4284 replay_floor_sequence: u64,
4285 events: VecDeque<NodeEventEnvelope>,
4286 record_providers: BTreeMap<SessionRecordId, AgentId>,
4287 removed_record_providers: BTreeMap<u64, AgentId>,
4288}
4289
4290impl NodeEventHistory {
4291 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4292 Self {
4293 last_sequence: 0,
4294 replay_floor_sequence: 1,
4295 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4296 record_providers,
4297 removed_record_providers: BTreeMap::new(),
4298 }
4299 }
4300}
4301
4302impl NodeShared {
4303 fn session_resume_journal(&self) -> Option<PathBuf> {
4307 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4308 }
4309
4310 async fn restore_journalled_sessions(&self) {
4313 let Some(file) = self.session_resume_journal() else { return };
4314 if !crate::pty_resume::enabled() { return; }
4315 for e in crate::pty_resume::load(&file) {
4316 crate::pty_resume::remove(&file, e.instance_id);
4317 if !e.session_key.is_empty() {
4319 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4320 }
4321 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4322 if e.acp {
4323 let record_id = {
4324 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4325 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())
4326 };
4327 let cur = self.next_instance_id.load(Ordering::Acquire);
4328 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4329 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4330 let resumed = match record_id {
4331 Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4332 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4333 };
4334 let r = match resumed {
4336 Ok(a) => Ok(a),
4337 Err(err) => {
4338 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4339 self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4340 SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4341 }
4342 };
4343 match r {
4344 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4345 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4346 }
4347 continue;
4348 }
4349 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4350 let cur = self.next_instance_id.load(Ordering::Acquire);
4352 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4353 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4354 match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4355 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4356 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4357 }
4358 continue;
4359 }
4360 let folder = std::path::Path::new(&e.cwd);
4361 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4362 let name = match (in_session_dir, folder.file_name()) {
4363 (true, Some(n)) => n.to_string_lossy().into_owned(),
4364 _ => format!("{} #{}", e.provider, e.instance_id),
4365 };
4366 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4367 let record = match self.index_provider_session(ws, provider, identity, name) {
4368 Ok(r) => r,
4369 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4370 };
4371 let cur = self.next_instance_id.load(Ordering::Acquire);
4372 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4373 let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4374 let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4375 match r {
4376 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4377 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4378 }
4379 }
4380 }
4381
4382 fn refresh_session_journal(&self) {
4383 let Some(file) = self.session_resume_journal() else { return };
4384 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4385 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4386 crate::pty_resume::refresh(&file, &homes);
4387 let ids: Vec<(u64, String)> = {
4388 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4389 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4390 };
4391 for (inst, sid) in ids {
4392 crate::pty_resume::set_session_id(&file, inst, &sid);
4393 }
4394 }
4395
4396 fn session_grok_leader_socket(
4397 &self,
4398 provider: &AgentId,
4399 mode: SessionMode,
4400 instance_id: AgentInstanceId,
4401 ) -> Option<std::path::PathBuf> {
4402 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4403 return None;
4404 }
4405 let root = self
4406 .m4a_accounts
4407 .as_ref()
4408 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4409 .unwrap_or_else(std::env::temp_dir);
4410 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4411 if let Some(previous) = self
4412 .grok_leaders
4413 .lock()
4414 .unwrap_or_else(|p| p.into_inner())
4415 .insert(instance_id, socket.clone())
4416 {
4417 crate::grok_leader::retire(&previous);
4418 }
4419 Some(socket)
4420 }
4421
4422 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4424 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4425 if let Some(socket) = socket {
4426 crate::grok_leader::retire(&socket);
4427 }
4428 }
4429
4430 fn new_with_incarnation(
4431 handle: Gate4AgentHandle,
4432 access_token: String,
4433 node_id: NodeId,
4434 incarnation_id: NodeIncarnationId,
4435 workspaces: Vec<WorkspaceConfig>,
4436 enabled_providers: Vec<AgentId>,
4437 provider_runtime_statuses: ProviderRuntimeStatuses,
4438 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4439 provider_contracts: Vec<ProviderContractSupport>,
4440 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4441 spawn_profiles: SpawnProfileRegistry,
4442 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4443 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4444 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4445 state_path: Option<PathBuf>,
4446 records: Vec<ManagedSessionRecord>,
4447 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4448 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4449 materializations: Vec<MaterializationOwnershipRecord>,
4450 persistence_warning: Option<String>,
4451 input_settle_timeout_ms: u64,
4452 ) -> Self {
4453 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4454 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4455 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4456 let (harness_mcp_proxy_event_tx, _) =
4457 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4458 let record_providers = records
4459 .iter()
4460 .map(|record| (record.record_id.clone(), record.provider.clone()))
4461 .collect();
4462 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4463 .iter()
4464 .map(|workspace| {
4465 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4466 })
4467 .collect();
4468 for lease in &managed_worktrees {
4469 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4470 }
4471 let managed_worktree_profiles = workspaces.iter()
4472 .map(|workspace| (
4473 workspace.workspace_id.clone(),
4474 workspace.managed_worktree_profiles.clone(),
4475 ))
4476 .collect();
4477 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4478 managed_worktrees,
4479 managed_worktree_tombstones,
4480 ).expect("durable managed worktree registry was validated while loading");
4481 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4482 managed_worktrees.reattach_record_holders(
4483 &managed_worktree_record_holders(&records),
4484 unix_time_ms(),
4485 );
4486 Self {
4487 handle,
4488 access_token,
4489 node_id,
4490 incarnation_id,
4491 started_at_unix_ms: std::time::SystemTime::now()
4492 .duration_since(std::time::UNIX_EPOCH)
4493 .unwrap_or_default()
4494 .as_millis()
4495 .min(u64::MAX as u128) as u64,
4496 workspaces: RwLock::new(
4497 workspaces
4498 .into_iter()
4499 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4500 .collect(),
4501 ),
4502 worktree_service_modes,
4503 managed_worktree_profiles,
4504 managed_worktrees: Mutex::new(managed_worktrees),
4505 enabled_providers,
4506 provider_runtime_statuses,
4507 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4508 provider_runtime_monitor,
4509 provider_contracts,
4510 provider_adapter_contracts,
4511 spawn_profiles,
4512 environment_profiles: RwLock::new(BTreeMap::new()),
4513 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4514 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4515 #[cfg(feature = "dig2-station-probe")]
4516 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4517 bundle_catalog: RwLock::new(BundleCatalog::default()),
4518 delivery_store: Mutex::new(None),
4519 context_pack_store: Mutex::new(None),
4520 context_catalog: RwLock::new(ContextPackCatalog::default()),
4521 native_launch_profile_control,
4522 harness_mcp_registry: None,
4523 native_session_catalog,
4524 session_environment_materializer,
4525 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4526 m4a_live: Mutex::new(BTreeMap::new()),
4527 m4a_keys: Mutex::new(BTreeMap::new()),
4528 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4529 grok_leaders: Mutex::new(BTreeMap::new()),
4530 #[cfg(unix)]
4531 #[cfg(unix)]
4532 signer_proxy: std::sync::OnceLock::new(),
4533 materializations: Mutex::new(
4534 materializations
4535 .into_iter()
4536 .map(|record| (record.id().clone(), record))
4537 .collect(),
4538 ),
4539 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4540 entries: BTreeMap::new(),
4541 }),
4542 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4543 controller: Mutex::new(None),
4544 provisional_spawns: Mutex::new(BTreeMap::new()),
4545 pending_acp_options: Mutex::new(BTreeMap::new()),
4546 history: Mutex::new(NodeEventHistory::new(record_providers)),
4547 event_tx,
4548 terminal_event_tx,
4549 agent_stream_event_tx,
4550 harness_mcp_proxy_event_tx,
4551 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4552 pty_input_wake: tokio::sync::Notify::new(),
4553 pty_input_marks: Mutex::new(BTreeMap::new()),
4554 next_connection_id: AtomicU64::new(1),
4555 next_instance_id: AtomicU64::new(1),
4556 next_command_id: AtomicU64::new(1),
4557 shutdown: AtomicBool::new(false),
4558 shutdown_notify: Notify::new(),
4559 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4560 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4561 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4562 mutation_gate: AsyncMutex::new(()),
4563 session_bindings: Mutex::new(BTreeMap::new()),
4564 session_records: Mutex::new(SessionRecords {
4565 records: records
4566 .into_iter()
4567 .map(|record| (record.record_id.clone(), record))
4568 .collect(),
4569 }),
4570 state_path,
4571 persistence_error: RwLock::new(
4572 persistence_warning
4573 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4574 ),
4575 state_transaction: Mutex::new(()),
4576 input_settle_timeout_ms,
4577 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4578 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4579 shell_efficiency_profile: OnceLock::new(),
4580 native_session_gauge: AtomicUsize::new(0),
4581 connection_loop_iterations: AtomicU64::new(0),
4582 connection_events_sent: AtomicU64::new(0),
4583 rejected_commands_total: AtomicU64::new(0),
4584 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4585 drive_loop_iterations_total: AtomicU64::new(0),
4586 drive_loop_iterations_idle: AtomicU64::new(0),
4587 session_record_retention: SessionRecordRetentionConfig::default(),
4588 retired_records_total: AtomicU64::new(0),
4589 #[cfg(feature = "fixture")]
4590 fixture_semantic_hook_policy: false,
4591 #[cfg(feature = "fixture")]
4592 fixture_spawn_managed_worktree_v2_failure_probe:
4593 SpawnManagedWorktreeV2FailureProbe::default(),
4594 }
4595 }
4596
4597 #[cfg(test)]
4598 fn new(
4599 handle: Gate4AgentHandle,
4600 access_token: String,
4601 node_id: NodeId,
4602 workspaces: Vec<WorkspaceConfig>,
4603 enabled_providers: Vec<AgentId>,
4604 ) -> Self {
4605 Self::new_with_incarnation(
4606 handle,
4607 access_token,
4608 node_id,
4609 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4610 workspaces,
4611 enabled_providers,
4612 ProviderRuntimeStatuses::default(),
4613 None,
4614 Vec::new(),
4615 Vec::new(),
4616 SpawnProfileRegistry::default(),
4617 None,
4618 None,
4619 None,
4620 None,
4621 Vec::new(),
4622 Vec::new(),
4623 Vec::new(),
4624 Vec::new(),
4625 None,
4626 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4627 )
4628 }
4629
4630 fn persistence_error(&self) -> Option<String> {
4631 self.persistence_error
4632 .read()
4633 .unwrap_or_else(|poisoned| poisoned.into_inner())
4634 .clone()
4635 }
4636
4637 async fn admit_provider_runtime(
4638 &self,
4639 provider: &AgentId,
4640 requirement: ProviderRuntimeRequirement,
4641 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4642 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4643 let pipe_transport = monitor.supports_pipe_transport(provider);
4644 let acp_transport = monitor.supports_acp_transport(provider);
4645 let provider = provider.clone();
4646 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4647 let (status, admission) = timeout(
4648 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4649 refresh,
4650 )
4651 .await
4652 .map_err(|_| failure(
4653 NodeFailureCode::BackendBusy,
4654 "provider runtime probe exceeded its bounded deadline",
4655 ))?
4656 .map_err(|_| failure(
4657 NodeFailureCode::BackendOperationFailed,
4658 "provider runtime probe task failed",
4659 ))?;
4660 if let Some(status) = status {
4661 self
4662 .provider_runtime_status_updates
4663 .write()
4664 .unwrap_or_else(|poisoned| poisoned.into_inner())
4665 .insert(status.provider().clone(), status);
4666 }
4667 admission.and_then(|policy| {
4668 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4669 })
4670 } else {
4671 crate::provider_runtime::admit_status(
4677 &self.provider_runtime_statuses,
4678 provider,
4679 false,
4680 false,
4681 requirement,
4682 )
4683 };
4684 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4685 admission.map_err(|error| match error {
4686 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4687 NodeFailureCode::BackendOperationFailed,
4688 "provider launcher is unavailable",
4689 ),
4690 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4691 NodeFailureCode::UnsupportedCapability,
4692 "provider semantic capability is not verified",
4693 ),
4694 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4695 NodeFailureCode::BackendBusy,
4696 "provider runtime probe is already in progress",
4697 ),
4698 })
4699 }
4700
4701 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4707 match self.provider_runtime_monitor.as_deref() {
4708 Some(monitor) => monitor.supports_pipe_transport(provider),
4709 None => false,
4710 }
4711 }
4712
4713 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4719 match self.provider_runtime_monitor.as_deref() {
4720 Some(monitor) => monitor.supports_acp_transport(provider),
4721 None => false,
4722 }
4723 }
4724
4725 fn admit_declared_hook_semantics(
4741 &self,
4742 provider: &AgentId,
4743 policy: ProviderRuntimePolicy,
4744 ) -> ProviderRuntimePolicy {
4745 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4746 return policy;
4747 }
4748 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4749 contract.provider == *provider && contract.family == AdapterFamily::Hook
4750 });
4751 if !declares_hook {
4752 return policy;
4753 }
4754 ProviderRuntimePolicy::new(
4755 policy.raw_pty_lifecycle,
4756 policy.semantic_readiness,
4757 policy.structured_prompt,
4758 policy.provider_session_identity,
4759 policy.semantic_resume,
4760 true,
4761 )
4762 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4763 }
4764
4765 fn resolve_network_allowlist_overlay_args(
4780 &self,
4781 provider: &AgentId,
4782 mode: SessionMode,
4783 approval_level: ApprovalLevel,
4784 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4785 provider_home: Option<&Path>,
4786 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4787 let Some(allowlist_id) = network_allowlist else {
4788 return Ok(Vec::new());
4789 };
4790 let catalog = self
4791 .network_allowlist_catalog
4792 .read()
4793 .unwrap_or_else(|poisoned| poisoned.into_inner());
4794 let Some(entry) = catalog.get(allowlist_id) else {
4795 return Err(failure(
4796 NodeFailureCode::UnknownNetworkAllowlist,
4797 "spawn network allowlist is unavailable on this node",
4798 ));
4799 };
4800
4801 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4803 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4804 |message| {
4805 failure(
4806 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4807 &message,
4808 )
4809 },
4810 )?;
4811 if !matches!(mode, SessionMode::Pty) {
4812 return Err(failure(
4813 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4814 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4815 ));
4816 }
4817 let Some(home) = provider_home else {
4818 return Err(failure(
4819 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4820 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4821 ));
4822 };
4823 let domains =
4824 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4825 if domains.is_empty() {
4826 return Err(failure(
4827 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4828 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4829 ));
4830 }
4831 let settings_path = home.join(
4832 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4833 );
4834 let json =
4835 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4836 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4837 |_| {
4838 failure(
4839 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4840 "Claude station network settings sidecar could not be written under ProviderHome",
4841 )
4842 },
4843 )?;
4844 let overlay =
4845 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4846 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4847 }
4848
4849 let Some(native) = entry.provider_native.as_ref() else {
4850 return Ok(Vec::new());
4851 };
4852 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4853 provider.as_str(),
4854 approval_level,
4855 native,
4856 )
4857 .map_err(|message| {
4858 failure(
4859 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4860 &message,
4861 )
4862 })?;
4863 if overlay.is_empty() {
4864 return Ok(Vec::new());
4865 }
4866 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4867 return Err(failure(
4868 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4869 "Codex network_access -c overlay requires PTY or ACP transport",
4870 ));
4871 }
4872 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4873 }
4874
4875 fn resolve_codex_network_access_overlay_args(
4877 &self,
4878 provider: &AgentId,
4879 mode: SessionMode,
4880 approval_level: ApprovalLevel,
4881 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4882 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4883 self.resolve_network_allowlist_overlay_args(
4884 provider,
4885 mode,
4886 approval_level,
4887 network_allowlist,
4888 None,
4889 )
4890 }
4891
4892 fn resolve_environment_profile(
4893 &self,
4894 resolved: &ResolvedSpawnSpec,
4895 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4896 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4902 let catalog = self
4903 .network_allowlist_catalog
4904 .read()
4905 .unwrap_or_else(|poisoned| poisoned.into_inner());
4906 let Some(entry) = catalog.get(allowlist_id) else {
4907 return Err(failure(
4908 NodeFailureCode::UnknownNetworkAllowlist,
4909 "spawn network allowlist is unavailable on this node",
4910 ));
4911 };
4912 if let Some(native) = entry.provider_native.as_ref() {
4915 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4916 resolved.provider.as_str(),
4917 resolved.approval_level,
4918 native,
4919 ) {
4920 return Err(failure(
4921 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4922 &message,
4923 ));
4924 }
4925 }
4926 }
4927 #[cfg(feature = "dig2-station-probe")]
4936 if resolved.browser_profile_id.is_some() {
4937 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4938 match probe_station_reachable() {
4939 Ok(()) => {}
4940 Err(StationProbeError::UnsupportedPlatform)
4941 | Err(StationProbeError::InvalidPipeSuffix) => {
4942 return Err(failure(
4943 NodeFailureCode::BrowserStationProbeUnavailable,
4944 "dig2browser station probe unavailable on this platform",
4945 ));
4946 }
4947 Err(StationProbeError::Unreachable) => {
4948 return Err(failure(
4949 NodeFailureCode::BrowserStationUnreachable,
4950 "dig2browser station named-pipe is unreachable",
4951 ));
4952 }
4953 }
4954 }
4955 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4956 return Ok(None);
4957 };
4958 let profiles = self
4959 .environment_profiles
4960 .read()
4961 .unwrap_or_else(|poisoned| poisoned.into_inner());
4962 let binding = profiles.get(profile_id).ok_or_else(|| {
4963 failure(
4964 NodeFailureCode::UnknownEnvironmentProfile,
4965 "spawn environment profile is unavailable on this node",
4966 )
4967 })?;
4968 if binding.provider != resolved.provider
4969 || binding.native_profile_id(resolved.mode).is_none()
4970 {
4971 return Err(failure(
4972 NodeFailureCode::EnvironmentProfileBindingMismatch,
4973 "spawn environment profile does not match the provider and mode",
4974 ));
4975 }
4976 Ok(Some(ResolvedEnvironmentProfileReceipt {
4980 profile_id: binding.id.clone(),
4981 profile_revision: binding.revision.clone(),
4982 network_allowlist: resolved.network_allowlist.clone(),
4983 browser_profile_id: resolved.browser_profile_id.clone(),
4984 }))
4985 }
4986
4987 fn resolve_bundle(
4988 &self,
4989 resolved: &ResolvedSpawnSpec,
4990 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4991 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4992 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4993 return Ok(None);
4994 };
4995 let catalog = self.bundle_catalog
4996 .read()
4997 .unwrap_or_else(|poisoned| poisoned.into_inner());
4998 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4999 failure(
5000 NodeFailureCode::UnknownBundle,
5001 "spawn bundle is unavailable on this node",
5002 )
5003 })?;
5004 self.bundle_layout(
5005 &resolved.provider,
5006 resolved.mode,
5007 environment_profile,
5008 bundle,
5009 )?;
5010 Ok(Some(bundle.receipt()))
5011 }
5012
5013 fn begin_delivery_stage(
5014 &self,
5015 manifest: DeliveryBundleManifestV2,
5016 ) -> Result<NodeResponse, NodeFailure> {
5017 let mut store = self
5018 .delivery_store
5019 .lock()
5020 .unwrap_or_else(|poisoned| poisoned.into_inner());
5021 let store = store.as_mut().ok_or_else(|| {
5022 failure(
5023 NodeFailureCode::DeliveryStageStorageFailed,
5024 "node delivery store is unavailable",
5025 )
5026 })?;
5027 let begun = store.begin(manifest).map_err(delivery_failure)?;
5028 Ok(NodeResponse::DeliveryStageBegun {
5029 stage_id: begun.stage_id,
5030 manifest_digest: begun.manifest_digest,
5031 missing_blobs: begun.missing_blobs,
5032 })
5033 }
5034
5035 fn put_delivery_blob_chunk(
5036 &self,
5037 stage_id: DeliveryStageId,
5038 blob_digest: DeliveryBlobDigestV1,
5039 offset: u64,
5040 chunk_hex: DeliveryBlobChunkHexV1,
5041 ) -> Result<NodeResponse, NodeFailure> {
5042 let mut store = self
5043 .delivery_store
5044 .lock()
5045 .unwrap_or_else(|poisoned| poisoned.into_inner());
5046 let store = store.as_mut().ok_or_else(|| {
5047 failure(
5048 NodeFailureCode::DeliveryStageStorageFailed,
5049 "node delivery store is unavailable",
5050 )
5051 })?;
5052 let accepted = store
5053 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5054 .map_err(delivery_failure)?;
5055 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5056 stage_id: accepted.stage_id,
5057 blob_digest: accepted.blob_digest,
5058 next_offset: accepted.next_offset,
5059 })
5060 }
5061
5062 fn commit_delivery_stage(
5063 &self,
5064 stage_id: DeliveryStageId,
5065 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5066 let mut store = self
5067 .delivery_store
5068 .lock()
5069 .unwrap_or_else(|poisoned| poisoned.into_inner());
5070 let store = store.as_mut().ok_or_else(|| {
5071 failure(
5072 NodeFailureCode::DeliveryStageStorageFailed,
5073 "node delivery store is unavailable",
5074 )
5075 })?;
5076 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5077 let mut catalog = self
5078 .bundle_catalog
5079 .write()
5080 .unwrap_or_else(|poisoned| poisoned.into_inner());
5081 let mut next_catalog = catalog.clone();
5082 next_catalog
5083 .insert_idempotent(prepared.bundle().clone())
5084 .map_err(|_| {
5085 failure(
5086 NodeFailureCode::DeliveryStageConflict,
5087 "delivery bundle identity conflicts with installed content",
5088 )
5089 })?;
5090 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5091 *catalog = next_catalog;
5092 Ok(receipt)
5093 }
5094
5095 fn abort_delivery_stage(
5096 &self,
5097 stage_id: &DeliveryStageId,
5098 ) -> Result<(), NodeFailure> {
5099 let mut store = self
5100 .delivery_store
5101 .lock()
5102 .unwrap_or_else(|poisoned| poisoned.into_inner());
5103 let store = store.as_mut().ok_or_else(|| {
5104 failure(
5105 NodeFailureCode::DeliveryStageStorageFailed,
5106 "node delivery store is unavailable",
5107 )
5108 })?;
5109 store.abort(stage_id).map_err(delivery_failure)
5110 }
5111
5112 fn resolve_context(
5113 &self,
5114 resolved: &ResolvedSpawnSpec,
5115 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5116 let Some(context_id) = resolved.context_id.as_ref() else {
5117 return Ok(None);
5118 };
5119 self.context_catalog
5120 .read()
5121 .unwrap_or_else(|poisoned| poisoned.into_inner())
5122 .get(context_id)
5123 .map(|context| context.receipt().clone())
5124 .ok_or_else(|| {
5125 failure(
5126 NodeFailureCode::UnknownContextPack,
5127 "spawn context pack is unavailable on this node",
5128 )
5129 })
5130 .map(Some)
5131 }
5132
5133 fn bundle_layout(
5134 &self,
5135 provider: &AgentId,
5136 mode: SessionMode,
5137 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5138 bundle: &NodeBundle,
5139 ) -> Result<BundleProviderLayout, NodeFailure> {
5140 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5141 failure(
5142 NodeFailureCode::BundleBindingMismatch,
5143 "spawn bundle does not match the provider and mode",
5144 )
5145 })?;
5146 if layout != BundleProviderLayout::Codex {
5147 return Ok(layout);
5148 }
5149 let profile = environment_profile.and_then(|receipt| {
5150 let profiles = self
5151 .environment_profiles
5152 .read()
5153 .unwrap_or_else(|poisoned| poisoned.into_inner());
5154 let binding = profiles.get(&receipt.profile_id)?;
5155 if binding.revision != receipt.profile_revision
5156 || &binding.provider != provider
5157 || binding.native_profile_id(mode).is_none()
5158 {
5159 return None;
5160 }
5161 self.environment_materialization_profiles
5162 .read()
5163 .unwrap_or_else(|poisoned| poisoned.into_inner())
5164 .get(&receipt.profile_id)
5165 .cloned()
5166 });
5167 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5168 return Err(failure(
5169 NodeFailureCode::BundleBindingMismatch,
5170 "Codex bundle requires an exact isolated CODEX_HOME profile",
5171 ));
5172 }
5173 Ok(layout)
5174 }
5175
5176 fn select_environment_profile(
5177 &self,
5178 instance_id: AgentInstanceId,
5179 provider: &AgentId,
5180 mode: SessionMode,
5181 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5182 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5183 let Some(environment_profile) = environment_profile else {
5184 return Ok(None);
5185 };
5186 let native_profile_id = {
5187 let profiles = self
5188 .environment_profiles
5189 .read()
5190 .unwrap_or_else(|poisoned| poisoned.into_inner());
5191 let binding = profiles
5192 .get(&environment_profile.profile_id)
5193 .ok_or_else(|| {
5194 failure(
5195 NodeFailureCode::UnknownEnvironmentProfile,
5196 "session environment profile is unavailable on this node",
5197 )
5198 })?;
5199 if &binding.revision != &environment_profile.profile_revision
5200 || &binding.provider != provider
5201 {
5202 return Err(failure(
5203 NodeFailureCode::EnvironmentProfileBindingMismatch,
5204 "session environment profile revision or provider changed",
5205 ));
5206 }
5207 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5208 failure(
5209 NodeFailureCode::EnvironmentProfileBindingMismatch,
5210 "session environment profile does not support this mode",
5211 )
5212 })?
5213 };
5214 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5215 failure(
5216 NodeFailureCode::BackendOperationFailed,
5217 "native environment profile controller is unavailable",
5218 )
5219 })?;
5220 control
5221 .select_native_launch_profile(instance_id, native_profile_id)
5222 .map_err(|_| {
5223 failure(
5224 NodeFailureCode::BackendOperationFailed,
5225 "native environment profile selection failed",
5226 )
5227 })?;
5228 Ok(Some(NativeEnvironmentSelectionGuard {
5229 control: Some(control),
5230 instance_id,
5231 }))
5232 }
5233
5234 fn install_prepared_launch_overlay(
5235 &self,
5236 instance_id: AgentInstanceId,
5237 overlay: PreparedNativeLaunchOverlay,
5238 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5239 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5240 failure(
5241 NodeFailureCode::BackendOperationFailed,
5242 "native launch profile controller is unavailable",
5243 )
5244 })?;
5245 match overlay {
5246 PreparedNativeLaunchOverlay::Environment(overlay) => {
5247 control
5248 .install_native_launch_environment_overlay(instance_id, overlay)
5249 .map_err(|_| failure(
5250 NodeFailureCode::BackendOperationFailed,
5251 "session environment overlay installation failed",
5252 ))?;
5253 Ok(None)
5254 }
5255 PreparedNativeLaunchOverlay::Instance(overlay) => {
5256 control
5257 .install_native_instance_launch_overlay(instance_id, overlay)
5258 .map_err(|_| failure(
5259 NodeFailureCode::BundleBindingMismatch,
5260 "session bundle launch overlay installation failed",
5261 ))?;
5262 Ok(Some(NativeInstanceOverlayGuard {
5263 control: Some(control),
5264 instance_id,
5265 }))
5266 }
5267 }
5268 }
5269
5270 fn environment_materialization_profile(
5271 &self,
5272 environment_profile: &ResolvedEnvironmentProfileReceipt,
5273 ) -> Option<NodeSessionMaterializationProfile> {
5274 self.environment_materialization_profiles
5275 .read()
5276 .unwrap_or_else(|poisoned| poisoned.into_inner())
5277 .get(&environment_profile.profile_id)
5278 .cloned()
5279 }
5280
5281 fn materialization_profile(
5282 &self,
5283 provider: &AgentId,
5284 mode: SessionMode,
5285 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5286 bundle: Option<&ResolvedBundleReceipt>,
5287 context: Option<&ResolvedContextPackReceipt>,
5288 ) -> Result<Option<(
5289 NodeSessionMaterializationProfile,
5290 bool,
5291 Option<BundleProviderLayout>,
5292 )>, NodeFailure> {
5293 let environment = environment_profile
5294 .and_then(|receipt| self.environment_materialization_profile(receipt));
5295 let (bundle, bundle_layout) = match bundle {
5296 Some(receipt) => {
5297 let catalog = self.bundle_catalog.read()
5298 .unwrap_or_else(|poisoned| poisoned.into_inner());
5299 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5300 NodeFailureCode::UnknownBundle,
5301 "session bundle is unavailable on this node",
5302 ))?;
5303 if installed.receipt() != *receipt {
5304 return Err(failure(
5305 NodeFailureCode::BundleBindingMismatch,
5306 "session bundle revision or digest changed",
5307 ));
5308 }
5309 let layout = self.bundle_layout(
5310 provider,
5311 mode,
5312 environment_profile,
5313 installed,
5314 )?;
5315 (Some(installed.clone()), Some(layout))
5316 }
5317 None => (None, None),
5318 };
5319 let has_environment_overlay = environment.is_some() || context.is_some();
5320 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5321 (None, None, None) => None,
5322 (Some(profile), None, None) => Some(Ok(profile)),
5323 (None, Some(bundle), Some(layout)) => {
5324 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5325 }
5326 (Some(profile), Some(bundle), Some(layout)) => {
5327 Some(profile.with_bundle(bundle, layout))
5328 }
5329 _ => {
5330 return Err(failure(
5331 NodeFailureCode::BundleBindingMismatch,
5332 "session bundle provider layout is unavailable",
5333 ));
5334 }
5335 };
5336 let profile = profile
5337 .transpose()
5338 .map_err(|_| failure(
5339 NodeFailureCode::BundleMaterializationFailed,
5340 "session bundle materialization profile is invalid",
5341 ))?;
5342 let profile = match (profile, context) {
5343 (Some(profile), Some(receipt)) => {
5344 let catalog = self.context_catalog
5345 .read()
5346 .unwrap_or_else(|poisoned| poisoned.into_inner());
5347 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5348 NodeFailureCode::UnknownContextPack,
5349 "session context pack is unavailable on this node",
5350 ))?;
5351 if pack.receipt() != receipt {
5352 return Err(failure(
5353 NodeFailureCode::ContextPackMaterializationFailed,
5354 "session context pack identity changed",
5355 ));
5356 }
5357 profile.with_context(pack)
5358 }
5359 (None, Some(receipt)) => {
5360 let catalog = self.context_catalog
5361 .read()
5362 .unwrap_or_else(|poisoned| poisoned.into_inner());
5363 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5364 NodeFailureCode::UnknownContextPack,
5365 "session context pack is unavailable on this node",
5366 ))?;
5367 if pack.receipt() != receipt {
5368 return Err(failure(
5369 NodeFailureCode::ContextPackMaterializationFailed,
5370 "session context pack identity changed",
5371 ));
5372 }
5373 NodeSessionMaterializationProfile::from_context(pack)
5374 }
5375 (Some(profile), None) => Ok(profile),
5376 (None, None) => return Ok(None),
5377 }
5378 .map_err(|_| failure(
5379 NodeFailureCode::ContextPackMaterializationFailed,
5380 "session context pack materialization profile is invalid",
5381 ))?;
5382 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5383 }
5384
5385 fn prepare_session_materialization<'a>(
5386 &'a self,
5387 address: &SessionAddress,
5388 provider: &AgentId,
5389 mode: SessionMode,
5390 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5391 bundle: Option<&ResolvedBundleReceipt>,
5392 context: Option<&ResolvedContextPackReceipt>,
5393 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5394 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5395 let Some((profile, has_environment_overlay, bundle_layout)) =
5396 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5397 else {
5398 return Ok(None);
5399 };
5400 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5401 failure(
5402 NodeFailureCode::BackendOperationFailed,
5403 "session environment materializer is unavailable",
5404 )
5405 })?;
5406 let materialization_failure_code = if context.is_some() {
5407 NodeFailureCode::ContextPackMaterializationFailed
5408 } else if bundle.is_some() {
5409 NodeFailureCode::BundleMaterializationFailed
5410 } else {
5411 NodeFailureCode::BackendOperationFailed
5412 };
5413 let id = MaterializationId::new(format!(
5414 "mat-{}-{}-{}",
5415 self.incarnation_id,
5416 address.session.instance_id.0,
5417 address.session.generation.0,
5418 ))
5419 .map_err(|_| {
5420 failure(
5421 NodeFailureCode::BackendOperationFailed,
5422 "session environment materialization identity failed",
5423 )
5424 })?;
5425 let owner = MaterializationOwner::Session {
5426 incarnation_id: self.incarnation_id,
5427 instance_id: address.session.instance_id,
5428 generation: address.session.generation,
5429 };
5430 let mut ownership = materializer
5431 .begin(
5432 id.clone(),
5433 environment_profile.cloned(),
5434 bundle.cloned(),
5435 context.cloned(),
5436 owner,
5437 managed_lease_id,
5438 &profile,
5439 unix_time_ms(),
5440 )
5441 .map_err(|_| {
5442 failure(
5443 materialization_failure_code,
5444 "session materialization preparation failed",
5445 )
5446 })?;
5447 {
5448 let _transaction = self
5449 .state_transaction
5450 .lock()
5451 .unwrap_or_else(|poisoned| poisoned.into_inner());
5452 let duplicate = self
5453 .materializations
5454 .lock()
5455 .unwrap_or_else(|poisoned| poisoned.into_inner())
5456 .insert(id.clone(), ownership.clone());
5457 if duplicate.is_some() {
5458 return Err(failure(
5459 NodeFailureCode::BackendOperationFailed,
5460 "session environment materialization identity conflict",
5461 ));
5462 }
5463 if let Err(error) = self.persist_state_locked() {
5464 self.materializations
5465 .lock()
5466 .unwrap_or_else(|poisoned| poisoned.into_inner())
5467 .remove(&id);
5468 return Err(persistence_failure(error));
5469 }
5470 }
5471 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5472 Ok(environment) => environment,
5473 Err(_) => {
5474 let transaction = self
5475 .state_transaction
5476 .lock()
5477 .unwrap_or_else(|poisoned| poisoned.into_inner());
5478 self.materializations
5479 .lock()
5480 .unwrap_or_else(|poisoned| poisoned.into_inner())
5481 .insert(id.clone(), ownership.clone());
5482 if self.persist_state_locked().is_err() {
5483 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5484 }
5485 drop(transaction);
5486 if ownership.state() == MaterializationState::CleanupRequired {
5487 let _ = self.cleanup_materialization(&id);
5488 }
5489 return Err(failure(
5490 materialization_failure_code,
5491 "session materialization failed",
5492 ));
5493 }
5494 };
5495 {
5496 let _transaction = self
5497 .state_transaction
5498 .lock()
5499 .unwrap_or_else(|poisoned| poisoned.into_inner());
5500 self.materializations
5501 .lock()
5502 .unwrap_or_else(|poisoned| poisoned.into_inner())
5503 .insert(id.clone(), ownership.clone());
5504 if let Err(error) = self.persist_state_locked() {
5505 drop(_transaction);
5506 let _ = self.cleanup_materialization(&id);
5507 return Err(persistence_failure(error));
5508 }
5509 }
5510 let guard = SessionMaterializationGuard {
5511 shared: self,
5512 id: Some(id),
5513 provider_home: Some(ownership.provider_home().to_path_buf()),
5514 };
5515 let bundle_arguments = match bundle {
5516 Some(receipt) => {
5517 let catalog = self.bundle_catalog.read()
5518 .unwrap_or_else(|poisoned| poisoned.into_inner());
5519 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5520 NodeFailureCode::UnknownBundle,
5521 "session bundle is unavailable on this node",
5522 ))?;
5523 if installed.receipt() != *receipt {
5524 return Err(failure(
5525 NodeFailureCode::BundleBindingMismatch,
5526 "session bundle revision or digest changed",
5527 ));
5528 }
5529 let layout = bundle_layout.ok_or_else(|| failure(
5530 NodeFailureCode::BundleBindingMismatch,
5531 "materialized bundle provider layout is unavailable",
5532 ))?;
5533 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5534 failure(
5535 NodeFailureCode::BundleMaterializationFailed,
5536 "session bundle byte revalidation failed",
5537 )
5538 })?;
5539 Some(bundle_launch_arguments(
5540 layout,
5541 ownership.bundle_root(),
5542 ownership.provider_home(),
5543 ).map_err(|_| failure(
5544 NodeFailureCode::BundleBindingMismatch,
5545 "session bundle launch binding is invalid",
5546 ))?)
5547 }
5548 None => None,
5549 };
5550 if let Some(receipt) = context {
5551 materializer
5552 .revalidate_context(&ownership, receipt)
5553 .map_err(|_| failure(
5554 NodeFailureCode::ContextPackMaterializationFailed,
5555 "session context pack byte revalidation failed",
5556 ))?;
5557 }
5558 let transport = match mode {
5559 SessionMode::Pty => TransportKind::Pty,
5560 SessionMode::Inline => TransportKind::Pipe,
5561 SessionMode::Acp => TransportKind::Acp,
5562 };
5563 let overlay = if let Some(extra_args) = bundle_arguments {
5564 Some(PreparedNativeLaunchOverlay::Instance(
5565 NativeInstanceLaunchOverlay::new(
5566 provider.clone(),
5567 transport,
5568 if has_environment_overlay {
5569 environment
5570 } else {
5571 Vec::new()
5572 },
5573 extra_args,
5574 )
5575 .map_err(|_| {
5576 failure(
5577 NodeFailureCode::BundleBindingMismatch,
5578 "session bundle launch overlay is invalid",
5579 )
5580 })?,
5581 ))
5582 } else if has_environment_overlay {
5583 let context_only = environment_profile.is_none()
5584 && bundle.is_none()
5585 && context.is_some();
5586 let overlay = if context_only {
5587 NativeLaunchEnvironmentOverlay::new_context_root(
5588 provider.clone(),
5589 transport,
5590 environment,
5591 )
5592 .map_err(|_| {
5593 failure(
5594 NodeFailureCode::ContextPackMaterializationFailed,
5595 "context-only launch overlay is invalid",
5596 )
5597 })?
5598 } else {
5599 NativeLaunchEnvironmentOverlay::new(
5600 provider.clone(),
5601 transport,
5602 environment,
5603 )
5604 .map_err(|_| {
5605 failure(
5606 NodeFailureCode::BackendOperationFailed,
5607 "session environment overlay is invalid",
5608 )
5609 })?
5610 };
5611 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5612 } else {
5613 None
5614 };
5615 Ok(Some((overlay, guard)))
5616 }
5617
5618 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5619 let Some(mut ownership) = self
5620 .materializations
5621 .lock()
5622 .unwrap_or_else(|poisoned| poisoned.into_inner())
5623 .get(id)
5624 .cloned()
5625 else {
5626 return Ok(());
5627 };
5628 if ownership.state() != MaterializationState::CleanupRequired {
5629 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5630 failure(
5631 NodeFailureCode::BackendOperationFailed,
5632 "session environment cleanup state is invalid",
5633 )
5634 })?;
5635 }
5636 {
5637 let _transaction = self
5638 .state_transaction
5639 .lock()
5640 .unwrap_or_else(|poisoned| poisoned.into_inner());
5641 let previous = self
5642 .materializations
5643 .lock()
5644 .unwrap_or_else(|poisoned| poisoned.into_inner())
5645 .insert(id.clone(), ownership.clone());
5646 if let Err(error) = self.persist_state_locked() {
5647 let mut materializations = self
5648 .materializations
5649 .lock()
5650 .unwrap_or_else(|poisoned| poisoned.into_inner());
5651 if let Some(previous) = previous {
5652 materializations.insert(id.clone(), previous);
5653 }
5654 return Err(persistence_failure(error));
5655 }
5656 }
5657 let cleanup = self
5658 .session_environment_materializer
5659 .as_ref()
5660 .ok_or(())
5661 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5662 if cleanup.is_err() {
5663 let _ = ownership.mark_recovery_required(unix_time_ms());
5664 self.materializations
5665 .lock()
5666 .unwrap_or_else(|poisoned| poisoned.into_inner())
5667 .insert(id.clone(), ownership);
5668 if self.persist_state().is_err() {
5669 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5670 }
5671 return Err(failure(
5672 NodeFailureCode::BackendOperationFailed,
5673 "session environment cleanup requires recovery",
5674 ));
5675 }
5676 let _transaction = self
5677 .state_transaction
5678 .lock()
5679 .unwrap_or_else(|poisoned| poisoned.into_inner());
5680 self.materializations
5681 .lock()
5682 .unwrap_or_else(|poisoned| poisoned.into_inner())
5683 .remove(id);
5684 if let Err(error) = self.persist_state_locked() {
5685 self.materializations
5686 .lock()
5687 .unwrap_or_else(|poisoned| poisoned.into_inner())
5688 .insert(id.clone(), ownership);
5689 return Err(persistence_failure(error));
5690 }
5691 Ok(())
5692 }
5693
5694 fn resolve_record_materialization(
5695 &self,
5696 record_id: &SessionRecordId,
5697 provider: &AgentId,
5698 mode: SessionMode,
5699 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5700 bundle: Option<&ResolvedBundleReceipt>,
5701 context: Option<&ResolvedContextPackReceipt>,
5702 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5703 let ownership = {
5704 let materializations = self
5705 .materializations
5706 .lock()
5707 .unwrap_or_else(|poisoned| poisoned.into_inner());
5708 let mut matches = materializations.values().filter(|record| {
5709 record.owner()
5710 == &MaterializationOwner::Record {
5711 record_id: record_id.clone(),
5712 }
5713 });
5714 let ownership = matches.next().cloned();
5715 if matches.next().is_some() {
5716 return Err(failure(
5717 NodeFailureCode::BackendOperationFailed,
5718 "managed session has conflicting environment ownership",
5719 ));
5720 }
5721 ownership
5722 };
5723 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5724 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5725 failure(
5726 NodeFailureCode::ContextPackMaterializationFailed,
5727 "session context materializer is unavailable",
5728 )
5729 })?;
5730 let pack = materializer
5731 .revalidate_context(ownership, receipt)
5732 .map_err(|_| {
5733 self.mark_materialization_recovery_required(ownership.id());
5734 failure(
5735 NodeFailureCode::ContextPackMaterializationFailed,
5736 "managed session context pack revalidation failed",
5737 )
5738 })?;
5739 self.context_catalog
5740 .write()
5741 .unwrap_or_else(|poisoned| poisoned.into_inner())
5742 .insert(pack)
5743 .map_err(|_| failure(
5744 NodeFailureCode::ContextPackMaterializationFailed,
5745 "managed session context pack catalog restore failed",
5746 ))?;
5747 }
5748 let materialization = self.materialization_profile(
5749 provider,
5750 mode,
5751 environment_profile,
5752 bundle,
5753 context,
5754 )?;
5755 match (ownership, materialization) {
5756 (None, None) => Ok(None),
5757 (None, Some(_)) | (Some(_), None) => Err(failure(
5758 NodeFailureCode::BackendOperationFailed,
5759 "managed session materialization is unavailable",
5760 )),
5761 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5762 if ownership.environment_profile() != environment_profile
5763 || ownership.bundle() != bundle
5764 || ownership.context() != context
5765 || ownership.state() != MaterializationState::Ready
5766 {
5767 self.mark_materialization_recovery_required(ownership.id());
5768 return Err(failure(
5769 NodeFailureCode::BackendOperationFailed,
5770 "managed session environment ownership requires recovery",
5771 ));
5772 }
5773 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5774 failure(
5775 NodeFailureCode::BackendOperationFailed,
5776 "session environment materializer is unavailable",
5777 )
5778 })?;
5779 if materializer.revalidate(&ownership).is_err() {
5780 self.mark_materialization_recovery_required(ownership.id());
5781 return Err(failure(
5782 NodeFailureCode::BackendOperationFailed,
5783 "managed session environment revalidation failed",
5784 ));
5785 }
5786 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5787 let catalog = self.bundle_catalog.read()
5788 .unwrap_or_else(|poisoned| poisoned.into_inner());
5789 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5790 return Err(failure(
5791 NodeFailureCode::UnknownBundle,
5792 "managed session bundle is unavailable",
5793 ));
5794 };
5795 let layout = bundle_layout.ok_or_else(|| failure(
5796 NodeFailureCode::BundleBindingMismatch,
5797 "record bundle provider layout is unavailable",
5798 ))?;
5799 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5800 drop(catalog);
5801 self.mark_materialization_recovery_required(ownership.id());
5802 return Err(failure(
5803 NodeFailureCode::BundleMaterializationFailed,
5804 "managed session bundle revalidation failed",
5805 ));
5806 }
5807 Some(bundle_launch_arguments(
5808 layout,
5809 ownership.bundle_root(),
5810 ownership.provider_home(),
5811 ).map_err(|_| failure(
5812 NodeFailureCode::BundleBindingMismatch,
5813 "managed session bundle launch binding is invalid",
5814 ))?)
5815 } else {
5816 None
5817 };
5818 if !has_environment_materialization {
5819 let overlay = bundle_arguments.map(|extra_args| {
5820 NativeInstanceLaunchOverlay::new(
5821 provider.clone(),
5822 TransportKind::Pty,
5823 Vec::new(),
5824 extra_args,
5825 )
5826 .map(PreparedNativeLaunchOverlay::Instance)
5827 .map_err(|_| failure(
5828 NodeFailureCode::BundleBindingMismatch,
5829 "managed session bundle launch overlay is invalid",
5830 ))
5831 }).transpose()?;
5832 return Ok(Some((ownership.id().clone(), overlay)));
5833 }
5834 let environment = match materializer.resolve_environment(&ownership, &profile) {
5835 Ok(environment) => environment,
5836 Err(
5837 SessionEnvironmentMaterializeError::SecretUnavailable
5838 | SessionEnvironmentMaterializeError::SecretDenied
5839 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5840 ) => {
5841 return Err(failure(
5842 NodeFailureCode::BackendOperationFailed,
5843 "managed session environment resolution failed",
5844 ));
5845 }
5846 Err(
5847 SessionEnvironmentMaterializeError::InvalidRoot
5848 | SessionEnvironmentMaterializeError::OwnershipMismatch
5849 | SessionEnvironmentMaterializeError::Filesystem(_)
5850 | SessionEnvironmentMaterializeError::Record(_),
5851 ) => {
5852 self.mark_materialization_recovery_required(ownership.id());
5853 return Err(failure(
5854 NodeFailureCode::BackendOperationFailed,
5855 "managed session environment resolution failed",
5856 ));
5857 }
5858 };
5859 let transport = match mode {
5860 SessionMode::Pty => TransportKind::Pty,
5861 SessionMode::Inline => TransportKind::Pipe,
5862 SessionMode::Acp => TransportKind::Acp,
5863 };
5864 let overlay = match bundle_arguments {
5865 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5866 NativeInstanceLaunchOverlay::new(
5867 provider.clone(),
5868 transport,
5869 environment,
5870 extra_args,
5871 )
5872 .map_err(|_| failure(
5873 NodeFailureCode::BundleBindingMismatch,
5874 "managed session bundle launch overlay is invalid",
5875 ))?,
5876 ),
5877 None => PreparedNativeLaunchOverlay::Environment(
5878 NativeLaunchEnvironmentOverlay::new(
5879 provider.clone(),
5880 transport,
5881 environment,
5882 )
5883 .map_err(|_| {
5884 failure(
5885 NodeFailureCode::BackendOperationFailed,
5886 "managed session environment overlay is invalid",
5887 )
5888 })?,
5889 ),
5890 };
5891 Ok(Some((ownership.id().clone(), Some(overlay))))
5892 }
5893 }
5894 }
5895
5896 fn destroy_record_materialization(
5897 &self,
5898 record_id: &SessionRecordId,
5899 ) -> Result<bool, NodeFailure> {
5900 let ids = self
5901 .materializations
5902 .lock()
5903 .unwrap_or_else(|poisoned| poisoned.into_inner())
5904 .values()
5905 .filter_map(|record| {
5906 (record.owner()
5907 == &MaterializationOwner::Record {
5908 record_id: record_id.clone(),
5909 })
5910 .then(|| record.id().clone())
5911 })
5912 .collect::<Vec<_>>();
5913 if ids.len() > 1 {
5914 return Err(failure(
5915 NodeFailureCode::BackendOperationFailed,
5916 "managed session has conflicting environment ownership",
5917 ));
5918 }
5919 let Some(id) = ids.first().cloned() else {
5920 return Ok(false);
5921 };
5922 let (terminal_record, mut cleanup_ownership) = {
5923 let _transaction = self
5924 .state_transaction
5925 .lock()
5926 .unwrap_or_else(|poisoned| poisoned.into_inner());
5927 let original_record = self.record(record_id)?;
5928 let mut terminal_record = original_record.clone();
5929 terminal_record.provider_session = None;
5930 terminal_record.active_session = None;
5931 terminal_record.state = ManagedSessionState::Unavailable;
5932 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5933 terminal_record.updated_at_unix_ms = unix_time_ms();
5934 let original_ownership = self
5935 .materializations
5936 .lock()
5937 .unwrap_or_else(|poisoned| poisoned.into_inner())
5938 .get(&id)
5939 .cloned()
5940 .ok_or_else(|| {
5941 failure(
5942 NodeFailureCode::BackendOperationFailed,
5943 "managed session environment ownership disappeared during cleanup",
5944 )
5945 })?;
5946 let mut cleanup_ownership = original_ownership.clone();
5947 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5948 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5949 failure(
5950 NodeFailureCode::BackendOperationFailed,
5951 "managed session environment cleanup state is invalid",
5952 )
5953 })?;
5954 }
5955 self.session_records
5956 .lock()
5957 .unwrap_or_else(|poisoned| poisoned.into_inner())
5958 .records
5959 .insert(record_id.clone(), terminal_record.clone());
5960 self.materializations
5961 .lock()
5962 .unwrap_or_else(|poisoned| poisoned.into_inner())
5963 .insert(id.clone(), cleanup_ownership.clone());
5964 if let Err(error) = self.persist_state_locked() {
5965 self.session_records
5966 .lock()
5967 .unwrap_or_else(|poisoned| poisoned.into_inner())
5968 .records
5969 .insert(record_id.clone(), original_record);
5970 self.materializations
5971 .lock()
5972 .unwrap_or_else(|poisoned| poisoned.into_inner())
5973 .insert(id.clone(), original_ownership);
5974 return Err(persistence_failure(error));
5975 }
5976 (terminal_record, cleanup_ownership)
5977 };
5978 let cleanup = self
5979 .session_environment_materializer
5980 .as_ref()
5981 .ok_or(())
5982 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5983 if cleanup.is_err() {
5984 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5985 self.materializations
5986 .lock()
5987 .unwrap_or_else(|poisoned| poisoned.into_inner())
5988 .insert(id.clone(), cleanup_ownership);
5989 if self.persist_state().is_err() {
5990 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5991 }
5992 return Err(failure(
5993 NodeFailureCode::BackendOperationFailed,
5994 "managed session environment cleanup requires recovery",
5995 ));
5996 }
5997 let _transaction = self
5998 .state_transaction
5999 .lock()
6000 .unwrap_or_else(|poisoned| poisoned.into_inner());
6001 self.remove_record_memory(record_id);
6002 self.materializations
6003 .lock()
6004 .unwrap_or_else(|poisoned| poisoned.into_inner())
6005 .remove(&id);
6006 if let Err(error) = self.persist_state_locked() {
6007 self.session_records
6008 .lock()
6009 .unwrap_or_else(|poisoned| poisoned.into_inner())
6010 .records
6011 .insert(record_id.clone(), terminal_record);
6012 self.materializations
6013 .lock()
6014 .unwrap_or_else(|poisoned| poisoned.into_inner())
6015 .insert(id, cleanup_ownership);
6016 return Err(persistence_failure(error));
6017 }
6018 Ok(true)
6019 }
6020
6021 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6022 let changed = {
6023 let mut materializations = self
6024 .materializations
6025 .lock()
6026 .unwrap_or_else(|poisoned| poisoned.into_inner());
6027 let Some(ownership) = materializations.get_mut(id) else {
6028 return;
6029 };
6030 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6031 };
6032 if changed && self.persist_state().is_err() {
6033 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6034 }
6035 }
6036
6037 fn reconcile_materializations(&self) {
6038 let records = self
6039 .materializations
6040 .lock()
6041 .unwrap_or_else(|poisoned| poisoned.into_inner())
6042 .values()
6043 .cloned()
6044 .collect::<Vec<_>>();
6045 for mut ownership in records {
6046 let result = match (ownership.state(), ownership.owner()) {
6047 (MaterializationState::Preparing, _)
6048 | (MaterializationState::CleanupRequired, _) => {
6049 self.cleanup_materialization(ownership.id())
6050 }
6051 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6052 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6053 self.materializations
6054 .lock()
6055 .unwrap_or_else(|poisoned| poisoned.into_inner())
6056 .insert(ownership.id().clone(), ownership.clone());
6057 self.persist_state().map_err(persistence_failure)
6058 } else {
6059 Err(failure(
6060 NodeFailureCode::BackendOperationFailed,
6061 "session environment reconciliation failed",
6062 ))
6063 }
6064 }
6065 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6066 .session_environment_materializer
6067 .as_ref()
6068 .ok_or_else(|| {
6069 failure(
6070 NodeFailureCode::BackendOperationFailed,
6071 "session environment materializer is unavailable",
6072 )
6073 })
6074 .and_then(|materializer| {
6075 materializer.revalidate(&ownership).map_err(|_| failure(
6076 NodeFailureCode::BackendOperationFailed,
6077 "session materialization revalidation failed",
6078 ))?;
6079 let record = self.record(record_id)?;
6080 if record.environment_profile.as_ref() != ownership.environment_profile()
6081 || record.bundle.as_ref() != ownership.bundle()
6082 || record.context.as_ref() != ownership.context()
6083 {
6084 return Err(failure(
6085 NodeFailureCode::BackendOperationFailed,
6086 "session materialization ownership changed after restart",
6087 ));
6088 }
6089 if let Some(receipt) = ownership.bundle() {
6090 let catalog = self.bundle_catalog.read()
6091 .unwrap_or_else(|poisoned| poisoned.into_inner());
6092 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6093 NodeFailureCode::UnknownBundle,
6094 "session bundle is unavailable after restart",
6095 ))?;
6096 if bundle.receipt() != *receipt {
6097 return Err(failure(
6098 NodeFailureCode::BundleBindingMismatch,
6099 "session bundle changed after restart",
6100 ));
6101 }
6102 let layout = self.bundle_layout(
6103 &record.provider,
6104 record.mode,
6105 record.environment_profile.as_ref(),
6106 bundle,
6107 )?;
6108 materializer
6109 .revalidate_bundle(&ownership, bundle, layout)
6110 .map_err(|_| failure(
6111 NodeFailureCode::BundleMaterializationFailed,
6112 "session bundle revalidation failed",
6113 ))?;
6114 }
6115 if let Some(receipt) = ownership.context() {
6116 let pack = materializer
6117 .revalidate_context(&ownership, receipt)
6118 .map_err(|_| {
6119 failure(
6120 NodeFailureCode::ContextPackMaterializationFailed,
6121 "session context revalidation failed",
6122 )
6123 })?;
6124 self.context_catalog
6125 .write()
6126 .unwrap_or_else(|poisoned| poisoned.into_inner())
6127 .insert(pack)
6128 .map_err(|_| {
6129 failure(
6130 NodeFailureCode::ContextPackMaterializationFailed,
6131 "session context catalog restore failed",
6132 )
6133 })?;
6134 }
6135 Ok(())
6136 }),
6137 (MaterializationState::RecoveryRequired, _) => Ok(()),
6138 };
6139 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6140 let _ = ownership.mark_recovery_required(unix_time_ms());
6141 self.materializations
6142 .lock()
6143 .unwrap_or_else(|poisoned| poisoned.into_inner())
6144 .insert(ownership.id().clone(), ownership);
6145 if self.persist_state().is_err() {
6146 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6147 }
6148 }
6149 }
6150 let materialized_profiles = self
6151 .environment_materialization_profiles
6152 .read()
6153 .unwrap_or_else(|poisoned| poisoned.into_inner())
6154 .keys()
6155 .cloned()
6156 .collect::<Vec<_>>();
6157 let ownership = self
6158 .materializations
6159 .lock()
6160 .unwrap_or_else(|poisoned| poisoned.into_inner())
6161 .values()
6162 .cloned()
6163 .collect::<Vec<_>>();
6164 let changed = {
6165 let mut session_records = self
6166 .session_records
6167 .lock()
6168 .unwrap_or_else(|poisoned| poisoned.into_inner());
6169 let mut changed = false;
6170 for record in session_records.records.values_mut() {
6171 let environment_profile = record.environment_profile.as_ref();
6172 if environment_profile.is_none()
6173 && record.bundle.is_none()
6174 && record.context.is_none()
6175 {
6176 continue;
6177 }
6178 if record.bundle.is_none()
6179 && environment_profile.is_some_and(|profile| {
6180 !materialized_profiles.contains(&profile.profile_id)
6181 })
6182 {
6183 continue;
6184 }
6185 let has_exact_ownership = ownership.iter().any(|candidate| {
6186 candidate.state() == MaterializationState::Ready
6187 && candidate.environment_profile() == environment_profile
6188 && candidate.bundle() == record.bundle.as_ref()
6189 && candidate.context() == record.context.as_ref()
6190 && candidate.owner()
6191 == &MaterializationOwner::Record {
6192 record_id: record.record_id.clone(),
6193 }
6194 });
6195 if !has_exact_ownership {
6196 record.provider_session = None;
6197 record.active_session = None;
6198 record.state = ManagedSessionState::Unavailable;
6199 record.last_error = Some(if record.bundle.is_some() {
6200 "bundle-unavailable".to_owned()
6201 } else if record.context.is_some() {
6202 "context-unavailable".to_owned()
6203 } else {
6204 "environment-profile-unavailable".to_owned()
6205 });
6206 record.updated_at_unix_ms = unix_time_ms();
6207 changed = true;
6208 }
6209 }
6210 changed
6211 };
6212 if changed && self.persist_state().is_err() {
6213 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6214 }
6215 }
6216
6217 fn replay_spawn_spec(
6218 &self,
6219 spec: &SpawnSpec,
6220 now: Instant,
6221 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6222 let mut cache = self
6223 .spawn_idempotency
6224 .lock()
6225 .unwrap_or_else(|poisoned| poisoned.into_inner());
6226 cache.entries.retain(|_, entry| entry.expires_at > now);
6227 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6228 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6229 return Err(failure(
6230 NodeFailureCode::SpawnIdempotencyConflict,
6231 "spawn idempotency key was reused across spawn kinds",
6232 ));
6233 };
6234 if existing != spec {
6235 return Err(failure(
6236 NodeFailureCode::SpawnIdempotencyConflict,
6237 "spawn idempotency key was reused with a different specification",
6238 ));
6239 }
6240 return Ok(Some(result.clone()));
6241 }
6242 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6243 return Err(failure(
6244 NodeFailureCode::SpawnIdempotencyCapacity,
6245 "spawn idempotency cache reached its bounded capacity",
6246 ));
6247 }
6248 Ok(None)
6249 }
6250
6251 fn remember_spawn_spec(
6252 &self,
6253 spec: SpawnSpec,
6254 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6255 accepted_at: Instant,
6256 ) {
6257 self
6258 .spawn_idempotency
6259 .lock()
6260 .unwrap_or_else(|poisoned| poisoned.into_inner())
6261 .entries
6262 .insert(
6263 spec.idempotency_key.clone(),
6264 SpawnIdempotencyEntry {
6265 value: SpawnIdempotencyValue::Standard { spec, result },
6266 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6267 },
6268 );
6269 }
6270
6271 fn claim_harness_mcp_spawn(
6272 &self,
6273 _reservation_id: &HarnessMcpReservationId,
6274 _activation_digest: &HarnessMcpActivationDigest,
6275 spec: &SpawnSpec,
6276 now: Instant,
6277 ) -> Result<(), NodeFailure> {
6278 let mut cache = self.spawn_idempotency.lock()
6279 .unwrap_or_else(|poisoned| poisoned.into_inner());
6280 cache.entries.retain(|_, entry| entry.expires_at > now);
6281 if cache.entries.contains_key(&spec.idempotency_key) {
6282 return Err(failure(
6283 NodeFailureCode::SpawnIdempotencyConflict,
6284 "spawn idempotency key was reused across spawn kinds",
6285 ));
6286 }
6287 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6288 return Err(failure(
6289 NodeFailureCode::SpawnIdempotencyCapacity,
6290 "spawn idempotency cache reached its bounded capacity",
6291 ));
6292 }
6293 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6294 value: SpawnIdempotencyValue::HarnessMcp,
6295 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6296 });
6297 Ok(())
6298 }
6299
6300 fn replay_managed_spawn(
6301 &self,
6302 request: &ManagedWorktreeSpawnRequest,
6303 now: Instant,
6304 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6305 let mut cache = self.spawn_idempotency.lock()
6306 .unwrap_or_else(|poisoned| poisoned.into_inner());
6307 cache.entries.retain(|_, entry| entry.expires_at > now);
6308 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6309 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6310 return Err(failure(
6311 NodeFailureCode::SpawnIdempotencyConflict,
6312 "spawn idempotency key was reused across spawn kinds",
6313 ));
6314 };
6315 if existing != request {
6316 return Err(failure(
6317 NodeFailureCode::SpawnIdempotencyConflict,
6318 "spawn idempotency key was reused with a different managed specification",
6319 ));
6320 }
6321 return Ok(Some(result.clone()));
6322 }
6323 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6324 return Err(failure(
6325 NodeFailureCode::SpawnIdempotencyCapacity,
6326 "spawn idempotency cache reached its bounded capacity",
6327 ));
6328 }
6329 Ok(None)
6330 }
6331
6332 fn remember_managed_spawn(
6333 &self,
6334 request: ManagedWorktreeSpawnRequest,
6335 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6336 accepted_at: Instant,
6337 ) {
6338 self.spawn_idempotency.lock()
6339 .unwrap_or_else(|poisoned| poisoned.into_inner())
6340 .entries
6341 .insert(
6342 request.spawn_spec.idempotency_key.clone(),
6343 SpawnIdempotencyEntry {
6344 value: SpawnIdempotencyValue::Managed { request, result },
6345 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6346 },
6347 );
6348 }
6349
6350 fn remember_managed_spawn_attempt(
6351 &self,
6352 durable_v2_key: Option<&SpawnIdempotencyKey>,
6353 request: ManagedWorktreeSpawnRequest,
6354 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6355 accepted_at: Instant,
6356 ) {
6357 if durable_v2_key.is_none() {
6358 self.remember_managed_spawn(request, result, accepted_at);
6359 }
6360 }
6361
6362 fn acquire_durable_managed_spawn_v2(
6363 &self,
6364 request: &ManagedWorktreeSpawnRequestV2,
6365 request_digest: String,
6366 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6367 if self.state_path.is_none() {
6368 return Err(failure(
6369 NodeFailureCode::BackendOperationFailed,
6370 "managed worktree V2 requires durable node state",
6371 ));
6372 }
6373 let _transaction = self.state_transaction
6374 .lock()
6375 .unwrap_or_else(|poisoned| poisoned.into_inner());
6376 let key = request.spawn_spec.idempotency_key.clone();
6377 let mut previous = None;
6378 {
6379 let mut replays = self.managed_spawn_replays
6380 .lock()
6381 .unwrap_or_else(|poisoned| poisoned.into_inner());
6382 if let Some(record) = replays.get_mut(&key) {
6383 if record.request_digest != request_digest {
6384 return Err(failure(
6385 NodeFailureCode::SpawnIdempotencyConflict,
6386 "managed worktree V2 idempotency key was reused with a different request",
6387 ));
6388 }
6389 match &record.state {
6390 ManagedWorktreeSpawnReplayStateV10::Pending {
6391 owner_incarnation_id,
6392 lease_id: None,
6393 } if owner_incarnation_id != &self.incarnation_id => {
6394 previous = Some(record.clone());
6395 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6396 owner_incarnation_id: self.incarnation_id,
6397 lease_id: None,
6398 };
6399 record.updated_at_unix_ms = unix_time_ms();
6400 }
6401 ManagedWorktreeSpawnReplayStateV10::Pending {
6402 lease_id: None,
6403 ..
6404 } => {
6405 return Err(failure(
6406 NodeFailureCode::BackendBusy,
6407 "managed worktree V2 reservation is already active",
6408 ));
6409 }
6410 ManagedWorktreeSpawnReplayStateV10::Pending {
6411 lease_id: Some(_),
6412 ..
6413 } => {
6414 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6415 }
6416 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6417 return Ok(DurableManagedSpawnReplayDecision::Committed(
6418 receipt.clone(),
6419 ));
6420 }
6421 }
6422 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6423 return Err(failure(
6424 NodeFailureCode::SpawnIdempotencyCapacity,
6425 "durable managed worktree replay ledger reached its bounded capacity",
6426 ));
6427 } else {
6428 let now = unix_time_ms();
6429 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6430 idempotency_key: key.clone(),
6431 request_digest,
6432 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6433 profile_id: request.worktree_profile_id.clone(),
6434 expected_profile_revision: request.expected_profile_revision.clone(),
6435 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6436 owner_incarnation_id: self.incarnation_id,
6437 lease_id: None,
6438 },
6439 created_at_unix_ms: now,
6440 updated_at_unix_ms: now,
6441 });
6442 }
6443 }
6444 if let Err(error) = self.persist_state_locked() {
6445 let mut replays = self.managed_spawn_replays
6446 .lock()
6447 .unwrap_or_else(|poisoned| poisoned.into_inner());
6448 match previous {
6449 Some(previous) => {
6450 replays.insert(key, previous);
6451 }
6452 None => {
6453 replays.remove(&key);
6454 }
6455 }
6456 return Err(persistence_failure(error));
6457 }
6458 Ok(DurableManagedSpawnReplayDecision::Reserved)
6459 }
6460
6461 fn link_durable_managed_spawn_v2_lease_locked(
6462 &self,
6463 key: &SpawnIdempotencyKey,
6464 lease_id: &ManagedWorktreeLeaseId,
6465 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6466 let mut replays = self.managed_spawn_replays
6467 .lock()
6468 .unwrap_or_else(|poisoned| poisoned.into_inner());
6469 let record = replays.get_mut(key).ok_or_else(|| {
6470 failure(
6471 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6472 "managed worktree V2 reservation disappeared before allocation",
6473 )
6474 })?;
6475 let previous = record.clone();
6476 match &mut record.state {
6477 ManagedWorktreeSpawnReplayStateV10::Pending {
6478 lease_id: current,
6479 ..
6480 }
6481 if current.is_none() =>
6482 {
6483 *current = Some(lease_id.clone());
6484 record.updated_at_unix_ms = unix_time_ms();
6485 Ok(previous)
6486 }
6487 _ => Err(failure(
6488 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6489 "managed worktree V2 reservation is not allocation-ready",
6490 )),
6491 }
6492 }
6493
6494 fn restore_durable_managed_spawn_v2_locked(
6495 &self,
6496 key: &SpawnIdempotencyKey,
6497 previous: ManagedWorktreeSpawnReplayRecordV10,
6498 ) {
6499 self.managed_spawn_replays
6500 .lock()
6501 .unwrap_or_else(|poisoned| poisoned.into_inner())
6502 .insert(key.clone(), previous);
6503 }
6504
6505 fn commit_durable_managed_spawn_v2(
6506 &self,
6507 request: &ManagedWorktreeSpawnRequestV2,
6508 receipt: ManagedWorktreeSpawnReceipt,
6509 ) -> Result<(), NodeFailure> {
6510 let _transaction = self.state_transaction
6511 .lock()
6512 .unwrap_or_else(|poisoned| poisoned.into_inner());
6513 let key = &request.spawn_spec.idempotency_key;
6514 let previous = {
6515 let mut replays = self.managed_spawn_replays
6516 .lock()
6517 .unwrap_or_else(|poisoned| poisoned.into_inner());
6518 let record = replays.get_mut(key).ok_or_else(|| {
6519 failure(
6520 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6521 "managed worktree V2 reservation disappeared before commit",
6522 )
6523 })?;
6524 let previous = record.clone();
6525 match &record.state {
6526 ManagedWorktreeSpawnReplayStateV10::Pending {
6527 lease_id: Some(lease_id),
6528 ..
6529 } if lease_id == &receipt.lease.lease_id => {}
6530 _ => {
6531 return Err(failure(
6532 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6533 "managed worktree V2 reservation is not linked to the spawned lease",
6534 ));
6535 }
6536 }
6537 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6538 record.updated_at_unix_ms = unix_time_ms();
6539 previous
6540 };
6541 if let Err(error) = self.persist_state_locked() {
6542 self.restore_durable_managed_spawn_v2_locked(key, previous);
6543 return Err(persistence_failure(error));
6544 }
6545 Ok(())
6546 }
6547
6548 fn remove_unallocated_durable_managed_spawn_v2(
6549 &self,
6550 request: &ManagedWorktreeSpawnRequestV2,
6551 ) -> Result<(), NodeFailure> {
6552 let _transaction = self.state_transaction
6553 .lock()
6554 .unwrap_or_else(|poisoned| poisoned.into_inner());
6555 let key = &request.spawn_spec.idempotency_key;
6556 let removed = {
6557 let mut replays = self.managed_spawn_replays
6558 .lock()
6559 .unwrap_or_else(|poisoned| poisoned.into_inner());
6560 match replays.get(key).map(|record| &record.state) {
6561 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6562 lease_id: None,
6563 ..
6564 }) => {
6565 replays.remove(key)
6566 }
6567 _ => None,
6568 }
6569 };
6570 let Some(removed) = removed else {
6571 return Ok(());
6572 };
6573 if let Err(error) = self.persist_state_locked() {
6574 self.managed_spawn_replays
6575 .lock()
6576 .unwrap_or_else(|poisoned| poisoned.into_inner())
6577 .insert(key.clone(), removed);
6578 return Err(persistence_failure(error));
6579 }
6580 Ok(())
6581 }
6582
6583 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6584 if spec.target.node_id != self.node_id {
6585 return Err(failure(
6586 NodeFailureCode::SpawnTargetMismatch,
6587 "spawn target does not match this node",
6588 ));
6589 }
6590 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6591 failure(
6592 NodeFailureCode::UnknownSpawnProfile,
6593 "spawn profile is unavailable on this node",
6594 )
6595 })?;
6596 let resolved = spec.resolve(defaults).map_err(|error| match error {
6597 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6598 NodeFailureCode::SpawnProfileRevisionMismatch,
6599 "spawn profile revision does not match the loaded profile",
6600 ),
6601 _ => failure(
6602 NodeFailureCode::InvalidRequest,
6603 "spawn specification could not be resolved",
6604 ),
6605 })?;
6606 if resolved.target.worktree_id.is_some() {
6607 self.require_worktree_service(&resolved.target.workspace_id)?;
6608 }
6609 let environment_profile = self.resolve_environment_profile(&resolved)?;
6610 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6611 self.resolve_context(&resolved)?;
6612 Ok(resolved)
6613 }
6614
6615 async fn resolve_spawn_workspace(
6616 &self,
6617 resolved: &ResolvedSpawnSpec,
6618 deadline: Instant,
6619 ) -> Result<WorkspaceId, NodeFailure> {
6620 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6621 return Ok(resolved.target.workspace_id.clone());
6622 };
6623 if self.managed_worktrees.lock()
6624 .unwrap_or_else(|poisoned| poisoned.into_inner())
6625 .lease_for_workspace(worktree_id)
6626 .is_some()
6627 {
6628 return Err(failure(
6629 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6630 "managed worktree targets may only be spawned through their lease request",
6631 ));
6632 }
6633 self.require_worktree_service(&resolved.target.workspace_id)?;
6634 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6635 let selected_root = self.workspace_root(worktree_id)?;
6636 let remaining = spawn_deadline_remaining(deadline)?;
6637 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6638 .await
6639 .map_err(|_| {
6640 failure(
6641 NodeFailureCode::SpawnDeadlineExceeded,
6642 "Git worktree selection exceeded the spawn deadline",
6643 )
6644 })?
6645 .map_err(git_worktree_failure)?;
6646 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6647 Ok(worktree_id.clone())
6648 }
6649
6650 async fn spawn_from_spec(
6651 &self,
6652 spec: SpawnSpec,
6653 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6654 self.spawn_from_spec_with_proxy(spec, None).await
6655 }
6656
6657 async fn spawn_from_spec_with_proxy(
6658 &self,
6659 spec: SpawnSpec,
6660 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6661 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6662 let accepted_at = Instant::now();
6663 if harness_mcp.is_none() {
6664 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6665 return replayed;
6666 }
6667 }
6668 let resolved = self.resolve_spawn_spec(&spec)?;
6669 let environment_profile = self.resolve_environment_profile(&resolved)?;
6670 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6671 let context = self.resolve_context(&resolved)?;
6672 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6673 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6674 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6675 let spawn_workspace_id = match spawn_workspace_id {
6676 Ok(workspace_id) => workspace_id,
6677 Err(error) => {
6678 let result = Err(error);
6679 if harness_mcp.is_none() {
6680 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6681 }
6682 return result;
6683 }
6684 };
6685 if self.managed_worktrees.lock()
6686 .unwrap_or_else(|poisoned| poisoned.into_inner())
6687 .lease_for_workspace(&spawn_workspace_id)
6688 .is_some()
6689 {
6690 let result = Err(failure(
6691 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6692 "managed worktree targets may only be spawned through their lease request",
6693 ));
6694 if harness_mcp.is_none() {
6695 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6696 }
6697 return result;
6698 }
6699 let result = self
6700 .spawn_session_with_deadline(
6701 spawn_workspace_id,
6702 resolved.provider.clone(),
6703 resolved.mode,
6704 resolved.terminal_size,
6705 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6706 environment_profile.clone(),
6707 bundle.clone(),
6708 context.clone(),
6709 None,
6710 None,
6711 SpawnRecordPolicy::Always,
6712 Some(deadline),
6713 &required_capabilities,
6714 harness_mcp,
6715 resolved.approval_level,
6716 resolved.network_allowlist.clone(),
6717 resolved.browser_profile_id.clone(),
6718 )
6719 .await
6720 .map(|(session, _runtime_policy)| {
6721 resolved.receipt_with_materialization(
6722 self.incarnation_id,
6723 session,
6724 environment_profile,
6725 bundle,
6726 context,
6727 )
6728 });
6729 if harness_mcp.is_none() {
6730 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6731 }
6732 result
6733 }
6734
6735 async fn spawn_from_spec_with_harness_mcp(
6736 &self,
6737 reservation_id: HarnessMcpReservationId,
6738 activation_digest: HarnessMcpActivationDigest,
6739 spec: SpawnSpec,
6740 deadline_unix_ms: u64,
6741 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6742 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6743 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6744 })?;
6745 if let Some(receipt) = registry
6746 .replay_spawn(&reservation_id, &activation_digest, &spec)
6747 .map_err(harness_mcp_failure)?
6748 {
6749 return Ok(receipt);
6750 }
6751 let resolved = self.resolve_spawn_spec(&spec)?;
6752 let prepared = registry
6753 .prepare_spawn(
6754 &reservation_id,
6755 &activation_digest,
6756 &spec,
6757 deadline_unix_ms,
6758 resolved.provider.clone(),
6759 )
6760 .map_err(harness_mcp_failure)?;
6761 self.claim_harness_mcp_spawn(
6762 &reservation_id,
6763 &activation_digest,
6764 &spec,
6765 Instant::now(),
6766 )?;
6767 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6768 let mut receipt = match spawn {
6769 Ok(receipt) => receipt,
6770 Err(error) => {
6771 let _ = registry.abort(&reservation_id, &activation_digest);
6772 return Err(error);
6773 }
6774 };
6775 let record_id = self
6776 .session_bindings
6777 .lock()
6778 .unwrap_or_else(|poisoned| poisoned.into_inner())
6779 .get(&receipt.session.session.instance_id)
6780 .and_then(|binding| binding.record_id.clone());
6781 let record_id = match record_id {
6782 Some(record_id) => record_id,
6783 None => {
6784 let rollback = self.remove_session(&receipt.session).await;
6785 let _ = registry.abort(&reservation_id, &activation_digest);
6786 if let Err(rollback_error) = rollback {
6787 return Err(rollback_error);
6788 }
6789 return Err(failure(
6790 NodeFailureCode::BindingMismatch,
6791 "harness MCP spawn did not retain its exact managed record",
6792 ));
6793 }
6794 };
6795 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6796 reservation_id: reservation_id.clone(),
6797 activation_digest: activation_digest.clone(),
6798 });
6799 if let Err(error) = registry.mark_spawned(
6800 &prepared,
6801 receipt.session.clone(),
6802 record_id,
6803 receipt.clone(),
6804 ) {
6805 let rollback = self.remove_session(&receipt.session).await;
6806 let _ = registry.abort(&reservation_id, &activation_digest);
6807 if let Err(rollback_error) = rollback {
6808 return Err(rollback_error);
6809 }
6810 return Err(harness_mcp_failure(error));
6811 }
6812 Ok(receipt)
6813 }
6814
6815 async fn spawn_managed_worktree(
6816 &self,
6817 request: ManagedWorktreeSpawnRequest,
6818 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6819 self.spawn_managed_worktree_inner(request, None).await
6820 }
6821
6822 async fn spawn_managed_worktree_inner(
6823 &self,
6824 request: ManagedWorktreeSpawnRequest,
6825 durable_v2_key: Option<&SpawnIdempotencyKey>,
6826 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6827 let accepted_at = Instant::now();
6828 if durable_v2_key.is_none() {
6829 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6830 return replayed;
6831 }
6832 }
6833 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6834 if request.spawn_spec.target.worktree_id.is_some() {
6835 let result = Err(failure(
6836 NodeFailureCode::InvalidRequest,
6837 "managed worktree spawn must not select a caller-provided worktree",
6838 ));
6839 self.remember_managed_spawn_attempt(
6840 durable_v2_key,
6841 request,
6842 result.clone(),
6843 accepted_at,
6844 );
6845 return result;
6846 }
6847 let environment_profile = self.resolve_environment_profile(&resolved)?;
6848 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6849 let context = self.resolve_context(&resolved)?;
6850 let source_workspace_id = resolved.target.workspace_id.clone();
6851 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6852 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6853 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6854 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6855 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6856 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6857 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6858 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6859 };
6860 let runtime_policy = timeout(
6861 spawn_deadline_remaining(deadline)?,
6862 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6863 ).await.map_err(|_| failure(
6864 NodeFailureCode::SpawnDeadlineExceeded,
6865 "provider admission exceeded the managed spawn deadline",
6866 ))??;
6867 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6868 return Err(failure(
6869 NodeFailureCode::UnsupportedSpawnCapability,
6870 "provider runtime does not admit a required spawn capability",
6871 ));
6872 }
6873 self.ensure_binding_capacity()?;
6874 let source_root = self.workspace_root(&source_workspace_id)?;
6875 profile.validate_for_workspace(&source_root)
6876 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6877 let base_timeout_ms = u64::try_from(
6878 spawn_deadline_remaining(deadline)?.as_millis(),
6879 ).unwrap_or(u64::MAX).max(1);
6880 let base_commit = resolve_base_commit_with_timeout(
6881 &source_root,
6882 profile.base(),
6883 base_timeout_ms,
6884 ).await.map_err(|error| {
6885 if Instant::now() >= deadline || error.message.contains("timed out") {
6886 failure(
6887 NodeFailureCode::SpawnDeadlineExceeded,
6888 "managed worktree base resolution exceeded the spawn deadline",
6889 )
6890 } else {
6891 managed_git_worktree_failure(error)
6892 }
6893 })?;
6894
6895 let lease = {
6896 let _transaction = self.state_transaction.lock()
6897 .unwrap_or_else(|poisoned| poisoned.into_inner());
6898 let previous = self.managed_worktrees.lock()
6899 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6900 let lease = self.managed_worktrees.lock()
6901 .unwrap_or_else(|poisoned| poisoned.into_inner())
6902 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6903 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6904 let previous_replay = match durable_v2_key
6905 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6906 .transpose()
6907 {
6908 Ok(previous) => previous,
6909 Err(error) => {
6910 *self.managed_worktrees.lock()
6911 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6912 return Err(error);
6913 }
6914 };
6915 if let Err(error) = self.persist_state_locked() {
6916 *self.managed_worktrees.lock()
6917 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6918 if let (Some(key), Some(previous_replay)) =
6919 (durable_v2_key, previous_replay)
6920 {
6921 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6922 }
6923 return Err(persistence_failure(error));
6924 }
6925 lease
6926 };
6927 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6928 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6929 self.managed_worktrees.lock()
6930 .unwrap_or_else(|poisoned| poisoned.into_inner())
6931 .remove_unmutated(&lease.lease_id);
6932 if self.persist_state().is_err() {
6933 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6934 }
6935 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6936 self.remember_managed_spawn_attempt(
6937 durable_v2_key,
6938 request,
6939 result.clone(),
6940 accepted_at,
6941 );
6942 return result;
6943 }
6944 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6945 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6946 Err(error) => {
6947 self.reconcile_failed_allocation(&lease, &source_root).await;
6948 let result = Err(error);
6949 self.remember_managed_spawn_attempt(
6950 durable_v2_key,
6951 request,
6952 result.clone(),
6953 accepted_at,
6954 );
6955 return result;
6956 }
6957 };
6958 let created = match create_git_worktree_with_timeout(
6959 &source_root,
6960 &lease.target_root,
6961 &lease.branch,
6962 Some(&base_commit),
6963 mutation_timeout_ms,
6964 ).await {
6965 Ok(created) => created,
6966 Err(error) => {
6967 self.reconcile_failed_allocation(&lease, &source_root).await;
6968 let result = Err(if Instant::now() >= deadline
6969 || error.message.contains("timed out")
6970 || error.message.contains("deadline")
6971 {
6972 failure(
6973 NodeFailureCode::SpawnDeadlineExceeded,
6974 "managed worktree creation exceeded the spawn deadline",
6975 )
6976 } else {
6977 managed_git_worktree_failure(error)
6978 });
6979 self.remember_managed_spawn_attempt(
6980 durable_v2_key,
6981 request,
6982 result.clone(),
6983 accepted_at,
6984 );
6985 return result;
6986 }
6987 };
6988 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6989 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6990 let result = Err(failure(
6991 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6992 "created worktree identity did not match its durable lease",
6993 ));
6994 self.remember_managed_spawn_attempt(
6995 durable_v2_key,
6996 request,
6997 result.clone(),
6998 accepted_at,
6999 );
7000 return result;
7001 }
7002 {
7003 let mut registry = self.managed_worktrees.lock()
7004 .unwrap_or_else(|poisoned| poisoned.into_inner());
7005 let current = registry.get_mut(&lease.lease_id)
7006 .expect("allocated managed worktree lease remains present");
7007 current.expected_head = Some(created.head.clone());
7008 current.state = ManagedWorktreeLeaseState::Ready;
7009 current.updated_at_unix_ms = unix_time_ms();
7010 }
7011 if let Err(error) = self.persist_state() {
7012 self.mark_managed_recovery_required(
7013 &lease.lease_id,
7014 ManagedWorktreeCleanupFailure::Backend,
7015 );
7016 let result = Err(persistence_failure(error));
7017 self.remember_managed_spawn_attempt(
7018 durable_v2_key,
7019 request,
7020 result.clone(),
7021 accepted_at,
7022 );
7023 return result;
7024 }
7025 let ready = self.managed_worktrees.lock()
7026 .unwrap_or_else(|poisoned| poisoned.into_inner())
7027 .get(&lease.lease_id)
7028 .expect("ready managed worktree lease remains present")
7029 .snapshot();
7030 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7031 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7032 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7033 let result = Err(error);
7034 self.remember_managed_spawn_attempt(
7035 durable_v2_key,
7036 request,
7037 result.clone(),
7038 accepted_at,
7039 );
7040 return result;
7041 }
7042 let spawn = self.spawn_session_with_deadline(
7043 lease.workspace_id.clone(),
7044 resolved.provider.clone(),
7045 resolved.mode,
7046 resolved.terminal_size,
7047 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7048 environment_profile.clone(),
7049 bundle.clone(),
7050 context.clone(),
7051 Some(lease.lease_id.clone()),
7052 Some(runtime_policy),
7053 SpawnRecordPolicy::Always,
7054 Some(deadline),
7055 &required_capabilities,
7056 None,
7057 resolved.approval_level,
7058 resolved.network_allowlist.clone(),
7059 resolved.browser_profile_id.clone(),
7060 ).await;
7061 let (session, effective_runtime_policy) = match spawn {
7062 Ok(spawned) => spawned,
7063 Err(error) => {
7064 if self
7065 .cleanup_managed_worktree(&lease.lease_id, true)
7066 .await
7067 .is_err()
7068 {
7069 self.mark_managed_recovery_required(
7070 &lease.lease_id,
7071 ManagedWorktreeCleanupFailure::Backend,
7072 );
7073 }
7074 let result = Err(error);
7075 self.remember_managed_spawn_attempt(
7076 durable_v2_key,
7077 request,
7078 result.clone(),
7079 accepted_at,
7080 );
7081 return result;
7082 }
7083 };
7084 let (record_id, raw_inventory_record_id) = {
7085 let mut bindings = self.session_bindings.lock()
7086 .unwrap_or_else(|poisoned| poisoned.into_inner());
7087 let binding = bindings.get_mut(&session.session.instance_id)
7088 .expect("successful spawn retains its session binding");
7089 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7090 managed_spawn_record_ownership(binding, effective_runtime_policy)
7091 };
7092 let bound = self.managed_worktrees.lock()
7093 .unwrap_or_else(|poisoned| poisoned.into_inner())
7094 .bind_session(
7095 &lease.lease_id,
7096 ManagedWorktreeSessionHolder {
7097 incarnation_id: self.incarnation_id,
7098 instance_id: session.session.instance_id,
7099 generation: session.session.generation,
7100 },
7101 record_id,
7102 unix_time_ms(),
7103 )
7104 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7105 let snapshot = match bound {
7106 Ok(snapshot) => snapshot,
7107 Err(error) => {
7108 if raw_inventory_record_id
7109 .as_ref()
7110 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7111 {
7112 self.mark_managed_recovery_required(
7113 &lease.lease_id,
7114 ManagedWorktreeCleanupFailure::Backend,
7115 );
7116 }
7117 let _ = self.remove_session(&session).await;
7118 self.mark_managed_recovery_required(
7119 &lease.lease_id,
7120 ManagedWorktreeCleanupFailure::Backend,
7121 );
7122 let result = Err(error);
7123 self.remember_managed_spawn_attempt(
7124 durable_v2_key,
7125 request,
7126 result.clone(),
7127 accepted_at,
7128 );
7129 return result;
7130 }
7131 };
7132 if let Err(error) = self.persist_state() {
7133 if raw_inventory_record_id
7134 .as_ref()
7135 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7136 {
7137 self.mark_managed_recovery_required(
7138 &lease.lease_id,
7139 ManagedWorktreeCleanupFailure::Backend,
7140 );
7141 }
7142 let _ = self.remove_session(&session).await;
7143 if self
7144 .cleanup_managed_worktree(&lease.lease_id, true)
7145 .await
7146 .is_err()
7147 {
7148 self.mark_managed_recovery_required(
7149 &lease.lease_id,
7150 ManagedWorktreeCleanupFailure::Backend,
7151 );
7152 }
7153 let result = Err(persistence_failure(error));
7154 self.remember_managed_spawn_attempt(
7155 durable_v2_key,
7156 request,
7157 result.clone(),
7158 accepted_at,
7159 );
7160 return result;
7161 }
7162 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7163 let receipt = managed_spawn_receipt(
7164 &resolved,
7165 self.incarnation_id,
7166 session,
7167 snapshot,
7168 environment_profile,
7169 bundle,
7170 context,
7171 );
7172 let result = Ok(receipt);
7173 self.remember_managed_spawn_attempt(
7174 durable_v2_key,
7175 request,
7176 result.clone(),
7177 accepted_at,
7178 );
7179 result
7180 }
7181
7182 async fn spawn_managed_worktree_v2(
7183 &self,
7184 request: ManagedWorktreeSpawnRequestV2,
7185 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7186 if request.spawn_spec.target.node_id != self.node_id {
7187 return Err(failure(
7188 NodeFailureCode::SpawnTargetMismatch,
7189 "spawn target does not match this node",
7190 ));
7191 }
7192 if request.spawn_spec.target.worktree_id.is_some() {
7193 return Err(failure(
7194 NodeFailureCode::InvalidRequest,
7195 "managed worktree spawn must not select a caller-provided worktree",
7196 ));
7197 }
7198 let request_digest = managed_spawn_request_digest_v2(&request)?;
7199 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7200 match replay {
7201 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7202 DurableManagedSpawnReplayDecision::PendingLinked => {
7203 return Err(failure(
7204 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7205 "managed worktree V2 reservation has an incomplete durable allocation",
7206 ));
7207 }
7208 DurableManagedSpawnReplayDecision::Reserved => {}
7209 }
7210 let validated = (|| {
7211 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7212 let profile = self.managed_profile(
7213 &resolved.target.workspace_id,
7214 &request.worktree_profile_id,
7215 )?;
7216 if profile.revision() != &request.expected_profile_revision {
7217 return Err(failure(
7218 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7219 "managed worktree profile revision does not match the loaded profile",
7220 ));
7221 }
7222 Ok(())
7223 })();
7224 match validated {
7225 Ok(()) => {}
7226 Err(error) => {
7227 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7228 return Err(error);
7229 }
7230 }
7231 let legacy = ManagedWorktreeSpawnRequest {
7232 spawn_spec: request.spawn_spec.clone(),
7233 worktree_profile_id: request.worktree_profile_id.clone(),
7234 };
7235 match self
7236 .spawn_managed_worktree_inner(
7237 legacy,
7238 Some(&request.spawn_spec.idempotency_key),
7239 )
7240 .await
7241 {
7242 Ok(receipt) => {
7243 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7244 Ok(receipt)
7245 }
7246 Err(error) => {
7247 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7248 Err(error)
7249 }
7250 }
7251 }
7252
7253 fn managed_profile(
7254 &self,
7255 workspace_id: &WorkspaceId,
7256 profile_id: &WorktreeProfileId,
7257 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7258 if self.worktree_service_modes.get(workspace_id).copied()
7259 != Some(WorktreeServiceMode::Managed)
7260 {
7261 return Err(failure(
7262 NodeFailureCode::UnsupportedSpawnCapability,
7263 "workspace does not enable managed worktrees",
7264 ));
7265 }
7266 self.managed_worktree_profiles.get(workspace_id)
7267 .and_then(|profiles| profiles.get(profile_id))
7268 .cloned()
7269 .ok_or_else(|| failure(
7270 NodeFailureCode::InvalidRequest,
7271 "managed worktree profile is unavailable for this workspace",
7272 ))
7273 }
7274
7275 async fn reconcile_failed_allocation(
7276 &self,
7277 lease: &ManagedWorktreeLeaseRecord,
7278 source_root: &str,
7279 ) {
7280 match list_git_worktrees(source_root).await {
7281 Ok(worktrees) => {
7282 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7283 None => {
7284 match std::fs::symlink_metadata(&lease.target_root) {
7285 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7286 self.managed_worktrees.lock()
7287 .unwrap_or_else(|poisoned| poisoned.into_inner())
7288 .remove_unmutated(&lease.lease_id);
7289 let _ = self.persist_state();
7290 }
7291 _ => self.mark_managed_recovery_required(
7292 &lease.lease_id,
7293 ManagedWorktreeCleanupFailure::OwnershipConflict,
7294 ),
7295 }
7296 }
7297 Some(item) if exact_owned_worktree(
7298 lease,
7299 &item.path,
7300 item.branch.as_deref(),
7301 &item.head,
7302 ) => {
7303 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7304 }
7305 Some(_) => self.mark_managed_recovery_required(
7306 &lease.lease_id,
7307 ManagedWorktreeCleanupFailure::OwnershipConflict,
7308 ),
7309 }
7310 }
7311 Err(_) => self.mark_managed_recovery_required(
7312 &lease.lease_id,
7313 ManagedWorktreeCleanupFailure::Backend,
7314 ),
7315 }
7316 }
7317
7318 async fn reconcile_managed_worktrees(&self) {
7319 let leases = self.managed_worktrees.lock()
7320 .unwrap_or_else(|poisoned| poisoned.into_inner())
7321 .active_records()
7322 .cloned()
7323 .collect::<Vec<_>>();
7324 for lease in leases {
7325 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7326 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7327 _ => {
7328 self.mark_managed_recovery_required(
7329 &lease.lease_id,
7330 ManagedWorktreeCleanupFailure::OwnershipConflict,
7331 );
7332 continue;
7333 }
7334 };
7335 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7336 self.mark_managed_recovery_required(
7337 &lease.lease_id,
7338 ManagedWorktreeCleanupFailure::OwnershipConflict,
7339 );
7340 continue;
7341 }
7342 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7343 Ok(root) => root,
7344 Err(_) => {
7345 self.mark_managed_recovery_required(
7346 &lease.lease_id,
7347 ManagedWorktreeCleanupFailure::OwnershipConflict,
7348 );
7349 continue;
7350 }
7351 };
7352 let worktrees = match list_git_worktrees(&source_root).await {
7353 Ok(worktrees) => worktrees,
7354 Err(_) => {
7355 self.mark_managed_recovery_required(
7356 &lease.lease_id,
7357 ManagedWorktreeCleanupFailure::Backend,
7358 );
7359 continue;
7360 }
7361 };
7362 let listed = worktrees.iter()
7363 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7364 let Some(listed) = listed else {
7365 match std::fs::symlink_metadata(&lease.target_root) {
7366 Err(error) if error.kind() == io::ErrorKind::NotFound
7367 && lease.state == ManagedWorktreeLeaseState::Allocating
7368 && !lease.has_holders() => {
7369 let removed = self.managed_worktrees.lock()
7370 .unwrap_or_else(|poisoned| poisoned.into_inner())
7371 .tombstone(&lease.lease_id, unix_time_ms());
7372 if self.persist_state().is_ok() && removed.is_some() {
7373 self.publish(NodeEvent::ManagedWorktreeRemoved {
7374 lease_id: lease.lease_id.clone(),
7375 });
7376 }
7377 }
7378 _ => self.mark_managed_recovery_required(
7379 &lease.lease_id,
7380 ManagedWorktreeCleanupFailure::OwnershipConflict,
7381 ),
7382 }
7383 continue;
7384 };
7385 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7386 &lease,
7387 &listed.path,
7388 listed.branch.as_deref(),
7389 &listed.head,
7390 ) {
7391 self.mark_managed_recovery_required(
7392 &lease.lease_id,
7393 ManagedWorktreeCleanupFailure::OwnershipConflict,
7394 );
7395 continue;
7396 }
7397 if !self.workspaces.read()
7398 .unwrap_or_else(|poisoned| poisoned.into_inner())
7399 .contains_key(&lease.workspace_id)
7400 {
7401 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7402 self.mark_managed_recovery_required(
7403 &lease.lease_id,
7404 ManagedWorktreeCleanupFailure::Backend,
7405 );
7406 continue;
7407 }
7408 }
7409 let snapshot = {
7410 let mut registry = self.managed_worktrees.lock()
7411 .unwrap_or_else(|poisoned| poisoned.into_inner());
7412 let current = registry.get_mut(&lease.lease_id)
7413 .expect("reconciled managed lease remains present");
7414 current.expected_head = Some(listed.head.clone());
7415 current.cleanup_failure = None;
7416 current.state = if current.has_holders() {
7417 ManagedWorktreeLeaseState::InUse
7418 } else if current.retention == ManagedWorktreeRetention::Retain {
7419 ManagedWorktreeLeaseState::Retained
7420 } else {
7421 ManagedWorktreeLeaseState::Ready
7422 };
7423 current.updated_at_unix_ms = unix_time_ms();
7424 current.snapshot()
7425 };
7426 if self.persist_state().is_err() {
7427 self.mark_managed_recovery_required(
7428 &lease.lease_id,
7429 ManagedWorktreeCleanupFailure::Backend,
7430 );
7431 continue;
7432 }
7433 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7434 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7435 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7436 }
7437 }
7438 }
7439
7440 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7460 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7461 .session_records
7462 .lock()
7463 .unwrap_or_else(|poisoned| poisoned.into_inner())
7464 .records
7465 .values()
7466 .filter(|record| {
7467 record.state == ManagedSessionState::Live
7468 && record.exported_context.is_none()
7469 && record.provider_session.is_some()
7470 })
7471 .filter_map(|record| {
7472 record
7473 .active_session
7474 .clone()
7475 .map(|session| (record.record_id.clone(), session))
7476 })
7477 .collect();
7478 for (record_id, session) in candidates {
7479 let shared = Arc::clone(self);
7480 tokio::spawn(async move {
7481 shared
7482 .reconcile_context_pack_export_for_record(&record_id, session)
7483 .await;
7484 });
7485 }
7486 }
7487
7488 async fn reconcile_context_pack_export_for_record(
7507 &self,
7508 record_id: &SessionRecordId,
7509 session: SessionAddress,
7510 ) {
7511 let Ok(record) = self.record(record_id) else {
7512 return;
7513 };
7514 if !matches!(
7515 record.state,
7516 ManagedSessionState::Live | ManagedSessionState::Dormant
7517 ) || record.exported_context.is_some()
7518 || record.provider_session.is_none()
7519 {
7520 return;
7521 }
7522 let Ok(receipt) = self
7529 .export_context_pack_for_session_record(&record.record_id, &session, true)
7530 .await
7531 else {
7532 return;
7533 };
7534 self.set_exported_context(&record.record_id, receipt);
7535 }
7536
7537 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7542 let transaction = self
7543 .state_transaction
7544 .lock()
7545 .unwrap_or_else(|poisoned| poisoned.into_inner());
7546 let (previous, updated) = {
7547 let mut records = self
7548 .session_records
7549 .lock()
7550 .unwrap_or_else(|poisoned| poisoned.into_inner());
7551 let Some(record) = records.records.get_mut(record_id) else {
7552 return;
7553 };
7554 if record.exported_context.is_some() {
7555 return;
7556 }
7557 let previous = record.clone();
7558 record.exported_context = Some(receipt);
7559 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7560 (previous, record.clone())
7561 };
7562 if self.persist_state_locked().is_err() {
7563 self.session_records
7564 .lock()
7565 .unwrap_or_else(|poisoned| poisoned.into_inner())
7566 .records
7567 .insert(record_id.clone(), previous);
7568 return;
7569 }
7570 drop(transaction);
7571 self.publish_record(updated);
7572 }
7573
7574 fn managed_record_export_target_for_instance(
7584 &self,
7585 instance_id: AgentInstanceId,
7586 generation: SessionGeneration,
7587 ) -> Option<(SessionRecordId, SessionAddress)> {
7588 let bindings = self
7589 .session_bindings
7590 .lock()
7591 .unwrap_or_else(|poisoned| poisoned.into_inner());
7592 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7593 let record_id = binding.record_id.clone()?;
7594 Some((
7595 record_id,
7596 SessionAddress {
7597 workspace_id: binding.workspace_id.clone(),
7598 session: SessionKey { instance_id, generation },
7599 },
7600 ))
7601 }
7602
7603 fn mark_managed_recovery_required(
7604 &self,
7605 lease_id: &ManagedWorktreeLeaseId,
7606 failure_kind: ManagedWorktreeCleanupFailure,
7607 ) {
7608 let snapshot = {
7609 let mut registry = self.managed_worktrees.lock()
7610 .unwrap_or_else(|poisoned| poisoned.into_inner());
7611 let Some(lease) = registry.get_mut(lease_id) else { return };
7612 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7613 lease.cleanup_failure = Some(failure_kind);
7614 lease.updated_at_unix_ms = unix_time_ms();
7615 lease.snapshot()
7616 };
7617 let _ = self.persist_state();
7618 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7619 }
7620
7621 fn set_persistence_error(&self, error: Option<String>) {
7622 *self
7623 .persistence_error
7624 .write()
7625 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7626 .map(|message| session_registry::sanitized_persistence_summary(&message));
7627 }
7628
7629 fn persist_state(&self) -> io::Result<()> {
7630 let _transaction = self
7631 .state_transaction
7632 .lock()
7633 .unwrap_or_else(|poisoned| poisoned.into_inner());
7634 self.persist_state_locked()
7635 }
7636
7637 fn persist_state_locked(&self) -> io::Result<()> {
7638 let workspaces = self
7639 .workspaces
7640 .read()
7641 .unwrap_or_else(|poisoned| poisoned.into_inner())
7642 .clone();
7643 let records = self
7644 .session_records
7645 .lock()
7646 .unwrap_or_else(|poisoned| poisoned.into_inner())
7647 .records
7648 .values()
7649 .cloned()
7650 .collect::<Vec<_>>();
7651 let mut managed = self.managed_worktrees
7652 .lock()
7653 .unwrap_or_else(|poisoned| poisoned.into_inner());
7654 managed.reattach_record_holders(
7655 &managed_worktree_record_holders(&records),
7656 unix_time_ms(),
7657 );
7658 let materializations = self
7659 .materializations
7660 .lock()
7661 .unwrap_or_else(|poisoned| poisoned.into_inner())
7662 .values()
7663 .cloned()
7664 .collect::<Vec<_>>();
7665 let managed_spawn_replays = self.managed_spawn_replays
7666 .lock()
7667 .unwrap_or_else(|poisoned| poisoned.into_inner())
7668 .values()
7669 .cloned()
7670 .collect::<Vec<_>>();
7671 let result = session_registry::save_v10(
7672 self.state_path.as_deref(),
7673 &self.node_id,
7674 &workspaces,
7675 &records,
7676 &managed.records(),
7677 &managed.tombstones(),
7678 &materializations,
7679 &managed_spawn_replays,
7680 );
7681 match &result {
7682 Ok(warning) => self.set_persistence_error(warning.clone()),
7683 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7684 }
7685 result.map(|_| ())
7686 }
7687
7688 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7689 let _mutation_guard = self.mutation_gate.lock().await;
7690 self.begin_shutdown_locked().await
7691 }
7692
7693 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7694 if self.shutdown.swap(true, Ordering::AcqRel) {
7695 return Ok(());
7696 }
7697 SessionLifecycleHook::on_shutdown(self);
7698 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7699 let mut first_error = None;
7700 for session in &self.handle.snapshot().sessions {
7701 let remaining = deadline.saturating_duration_since(Instant::now());
7702 if remaining.is_zero() {
7703 first_error.get_or_insert_with(|| {
7704 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7705 });
7706 break;
7707 }
7708 if let Err(error) = self
7709 .dispatch_bounded(
7710 ControlCommand::Stop {
7711 instance_id: session.instance_id,
7712 force: true,
7713 },
7714 remaining,
7715 )
7716 .await
7717 {
7718 first_error.get_or_insert(error);
7719 }
7720 }
7721 self.shutdown_notify.notify_waiters();
7722 match first_error {
7723 Some(error) => Err(error),
7724 None => Ok(()),
7725 }
7726 }
7727
7728 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7729 let bindings = self
7730 .session_bindings
7731 .lock()
7732 .unwrap_or_else(|poisoned| poisoned.into_inner());
7733 if bindings.len() >= CONTROL_SESSIONS_MAX {
7734 Err(failure(
7735 NodeFailureCode::BackendBusy,
7736 "node session-binding capacity is full; remove an existing session first",
7737 ))
7738 } else {
7739 Ok(())
7740 }
7741 }
7742
7743 fn ensure_workspace_environment_profile_compatible(
7750 &self,
7751 workspace_id: &WorkspaceId,
7752 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7753 ) -> Result<(), NodeFailure> {
7754 let bindings = self
7755 .session_bindings
7756 .lock()
7757 .unwrap_or_else(|poisoned| poisoned.into_inner());
7758 for binding in bindings.values() {
7759 if &binding.workspace_id != workspace_id {
7760 continue;
7761 }
7762 if binding.environment_profile.as_ref() != environment_profile {
7763 return Err(failure(
7764 NodeFailureCode::WorkspaceBusy,
7765 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7766 ));
7767 }
7768 }
7769 Ok(())
7770 }
7771
7772 #[cfg(test)]
7773 fn bind_session(
7774 &self,
7775 instance_id: AgentInstanceId,
7776 workspace_id: WorkspaceId,
7777 generation: SessionGeneration,
7778 ) {
7779 self.bind_session_with_policy(
7780 &SessionAddress {
7781 workspace_id,
7782 session: SessionKey { instance_id, generation },
7783 },
7784 ProviderRuntimePolicy::raw_pty(),
7785 None,
7786 );
7787 }
7788
7789 #[cfg(test)]
7790 fn bind_session_with_policy(
7791 &self,
7792 address: &SessionAddress,
7793 runtime_policy: ProviderRuntimePolicy,
7794 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7795 ) {
7796 self.bind_session_with_materialization(
7797 address,
7798 runtime_policy,
7799 environment_profile,
7800 None,
7801 None,
7802 None,
7803 );
7804 }
7805
7806 fn bind_session_with_materialization(
7807 &self,
7808 address: &SessionAddress,
7809 runtime_policy: ProviderRuntimePolicy,
7810 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7811 bundle: Option<ResolvedBundleReceipt>,
7812 context: Option<ResolvedContextPackReceipt>,
7813 materialization_id: Option<MaterializationId>,
7814 ) {
7815 let mut bindings = self
7816 .session_bindings
7817 .lock()
7818 .unwrap_or_else(|poisoned| poisoned.into_inner());
7819 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7820 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7821 bindings.insert(address.session.instance_id, SessionBinding {
7822 workspace_id: address.workspace_id.clone(),
7823 generation: address.session.generation,
7824 runtime_policy,
7825 pending_resume: None,
7826 record_id: None,
7827 managed_worktree_lease_id: None,
7828 environment_profile,
7829 bundle,
7830 context,
7831 materialization_id,
7832 });
7833 }
7834
7835 #[cfg(test)]
7836 fn bind_managed_session(
7837 &self,
7838 address: &SessionAddress,
7839 record_id: SessionRecordId,
7840 runtime_policy: ProviderRuntimePolicy,
7841 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7842 ) {
7843 self.bind_managed_session_with_materialization(
7844 address,
7845 record_id,
7846 runtime_policy,
7847 environment_profile,
7848 None,
7849 None,
7850 None,
7851 );
7852 }
7853
7854 fn bind_managed_session_with_materialization(
7855 &self,
7856 address: &SessionAddress,
7857 record_id: SessionRecordId,
7858 runtime_policy: ProviderRuntimePolicy,
7859 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7860 bundle: Option<ResolvedBundleReceipt>,
7861 context: Option<ResolvedContextPackReceipt>,
7862 materialization_id: Option<MaterializationId>,
7863 ) {
7864 let mut bindings = self
7865 .session_bindings
7866 .lock()
7867 .unwrap_or_else(|poisoned| poisoned.into_inner());
7868 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7869 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7870 bindings.insert(
7871 address.session.instance_id,
7872 SessionBinding {
7873 workspace_id: address.workspace_id.clone(),
7874 generation: address.session.generation,
7875 runtime_policy,
7876 pending_resume: None,
7877 record_id: Some(record_id),
7878 managed_worktree_lease_id: None,
7879 environment_profile,
7880 bundle,
7881 context,
7882 materialization_id,
7883 },
7884 );
7885 }
7886
7887 #[cfg(test)]
7888 fn bind_spawn_session(
7889 &self,
7890 address: &SessionAddress,
7891 provider: AgentId,
7892 mode: SessionMode,
7893 runtime_policy: ProviderRuntimePolicy,
7894 record_policy: SpawnRecordPolicy,
7895 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7896 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7897 self.bind_spawn_session_with_materialization(
7898 address,
7899 provider,
7900 mode,
7901 runtime_policy,
7902 record_policy,
7903 environment_profile,
7904 None,
7905 None,
7906 None,
7907 )
7908 }
7909
7910 fn bind_spawn_session_with_materialization(
7911 &self,
7912 address: &SessionAddress,
7913 provider: AgentId,
7914 mode: SessionMode,
7915 runtime_policy: ProviderRuntimePolicy,
7916 record_policy: SpawnRecordPolicy,
7917 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7918 bundle: Option<ResolvedBundleReceipt>,
7919 context: Option<ResolvedContextPackReceipt>,
7920 materialization_id: Option<MaterializationId>,
7921 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7922 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7923 && !runtime_policy.provider_session_identity
7924 {
7925 self.bind_session_with_materialization(
7926 address,
7927 runtime_policy,
7928 environment_profile,
7929 bundle,
7930 context,
7931 materialization_id,
7932 );
7933 return Ok(None);
7934 }
7935 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7936 let record = self.new_record(
7937 address,
7938 provider,
7939 mode,
7940 active_record_state(runtime_policy.provider_session_identity, false),
7941 None,
7942 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7943 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7944 record_owns_lifecycle.then(|| context.clone()).flatten(),
7945 )?;
7946 let record_id = record.record_id.clone();
7947 let transaction = self
7948 .state_transaction
7949 .lock()
7950 .unwrap_or_else(|poisoned| poisoned.into_inner());
7951 self.insert_record(record.clone())?;
7952 let previous_materialization = if record_owns_lifecycle {
7953 if let Some(materialization_id) = materialization_id.as_ref() {
7954 let mut materializations = self
7955 .materializations
7956 .lock()
7957 .unwrap_or_else(|poisoned| poisoned.into_inner());
7958 let Some(ownership) = materializations.get_mut(materialization_id) else {
7959 drop(materializations);
7960 self.remove_record_memory(&record_id);
7961 return Err(failure(
7962 NodeFailureCode::BackendOperationFailed,
7963 "session environment ownership disappeared before binding",
7964 ));
7965 };
7966 let previous = ownership.clone();
7967 if ownership
7968 .transfer_to_record(record_id.clone(), unix_time_ms())
7969 .is_err()
7970 {
7971 drop(materializations);
7972 self.remove_record_memory(&record_id);
7973 return Err(failure(
7974 NodeFailureCode::BackendOperationFailed,
7975 "session environment ownership transfer failed",
7976 ));
7977 }
7978 Some(previous)
7979 } else {
7980 None
7981 }
7982 } else {
7983 None
7984 };
7985 self.bind_managed_session_with_materialization(
7986 address,
7987 record_id.clone(),
7988 runtime_policy,
7989 environment_profile,
7990 bundle,
7991 context,
7992 materialization_id.clone(),
7993 );
7994 if let Err(error) = self.persist_state_locked() {
7995 self.remove_binding(address);
7996 self.remove_record_memory(&record_id);
7997 if let (Some(materialization_id), Some(previous)) =
7998 (materialization_id.as_ref(), previous_materialization)
7999 {
8000 self.materializations
8001 .lock()
8002 .unwrap_or_else(|poisoned| poisoned.into_inner())
8003 .insert(materialization_id.clone(), previous);
8004 }
8005 return Err(persistence_failure(error));
8006 }
8007 drop(transaction);
8008 self.publish_record(record);
8009 Ok(Some(record_id))
8010 }
8011
8012 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8013 for _ in 0..8 {
8014 let bytes = random_nonce().map_err(|error| {
8015 failure(
8016 NodeFailureCode::BackendOperationFailed,
8017 &format!("session record identity generation failed: {error}"),
8018 )
8019 })?;
8020 let mut value = String::with_capacity(27);
8021 value.push_str("sr-");
8022 for byte in &bytes[..12] {
8023 use std::fmt::Write as _;
8024 let _ = write!(&mut value, "{byte:02x}");
8025 }
8026 let record_id = SessionRecordId::new(value).map_err(|error| {
8027 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8028 })?;
8029 let records = self
8030 .session_records
8031 .lock()
8032 .unwrap_or_else(|poisoned| poisoned.into_inner());
8033 if !records.records.contains_key(&record_id) {
8034 return Ok(record_id);
8035 }
8036 }
8037 Err(failure(
8038 NodeFailureCode::BackendBusy,
8039 "could not allocate a unique session record ID",
8040 ))
8041 }
8042
8043 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8044 for _ in 0..8 {
8045 let bytes = random_nonce().map_err(|error| {
8046 failure(
8047 NodeFailureCode::BackendOperationFailed,
8048 &format!("task identity generation failed: {error}"),
8049 )
8050 })?;
8051 let mut nonce = [0_u8; 12];
8052 nonce.copy_from_slice(&bytes[..12]);
8053 let task_id = TaskId::from_nonce(nonce);
8054 let records = self
8055 .session_records
8056 .lock()
8057 .unwrap_or_else(|poisoned| poisoned.into_inner());
8058 if !records.records.values().any(|record| {
8059 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8060 == Some(&task_id)
8061 }) {
8062 return Ok(task_id);
8063 }
8064 }
8065 Err(failure(
8066 NodeFailureCode::BackendBusy,
8067 "could not allocate a unique task ID",
8068 ))
8069 }
8070
8071 fn default_record_name(&self, provider: &AgentId) -> String {
8072 let records = self
8073 .session_records
8074 .lock()
8075 .unwrap_or_else(|poisoned| poisoned.into_inner());
8076 let ordinal = records
8077 .records
8078 .values()
8079 .filter(|record| &record.provider == provider)
8080 .count()
8081 .saturating_add(1);
8082 format!("{} #{ordinal}", provider.as_str())
8083 }
8084
8085 fn new_record(
8086 &self,
8087 address: &SessionAddress,
8088 provider: AgentId,
8089 mode: SessionMode,
8090 state: ManagedSessionState,
8091 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8092 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8093 bundle: Option<ResolvedBundleReceipt>,
8094 context: Option<ResolvedContextPackReceipt>,
8095 ) -> Result<ManagedSessionRecord, NodeFailure> {
8096 let canonical_root = self.workspace_root(&address.workspace_id)?;
8097 let now = unix_time_ms();
8098 Ok(ManagedSessionRecord {
8099 record_id: self.allocate_record_id()?,
8100 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8101 provider,
8102 mode,
8103 state,
8104 workspace_id: address.workspace_id.clone(),
8105 canonical_root: opaque_windows_path(canonical_root),
8106 provider_session,
8107 active_session: Some(address.clone()),
8108 environment_profile,
8109 bundle,
8110 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8111 context,
8112 exported_context: None,
8113 task_binding: None,
8114 created_at_unix_ms: now,
8115 updated_at_unix_ms: now,
8116 last_error: None,
8117 })
8118 }
8119
8120 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8121 let mut records = self
8122 .session_records
8123 .lock()
8124 .unwrap_or_else(|poisoned| poisoned.into_inner());
8125 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8126 return Err(failure(
8127 NodeFailureCode::BackendBusy,
8128 "managed session record capacity is full; forget an inactive record first",
8129 ));
8130 }
8131 if let Some(identity) = record.provider_session.as_ref() {
8132 if records.records.values().any(|candidate| {
8133 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8134 session_registry::same_provider_session(
8135 &candidate.provider,
8136 candidate_identity,
8137 &record.provider,
8138 identity,
8139 )
8140 })
8141 }) {
8142 return Err(failure(
8143 NodeFailureCode::SessionRecordConflict,
8144 "provider session is already represented by another managed record",
8145 ));
8146 }
8147 }
8148 match records.records.entry(record.record_id.clone()) {
8149 std::collections::btree_map::Entry::Vacant(entry) => {
8150 entry.insert(record);
8151 }
8152 std::collections::btree_map::Entry::Occupied(_) => {
8153 return Err(failure(
8154 NodeFailureCode::SessionRecordConflict,
8155 "session record ID collision",
8156 ));
8157 }
8158 }
8159 Ok(())
8160 }
8161
8162 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8163 self.session_records
8164 .lock()
8165 .unwrap_or_else(|poisoned| poisoned.into_inner())
8166 .records
8167 .get(record_id)
8168 .cloned()
8169 .ok_or_else(|| {
8170 failure(
8171 NodeFailureCode::UnknownSessionRecord,
8172 "managed session record does not exist",
8173 )
8174 })
8175 }
8176
8177 fn revalidate_session_record_preview(
8178 &self,
8179 expected: &ManagedSessionRecord,
8180 identity: &ProviderSessionIdentity,
8181 ) -> Result<(), NodeFailure> {
8182 let current = self.record(&expected.record_id)?;
8183 let current_root = self.workspace_root(¤t.workspace_id)?;
8184 if current.provider != expected.provider
8185 || current.workspace_id != expected.workspace_id
8186 || current.canonical_root != expected.canonical_root
8187 || current.provider_session.as_ref() != Some(identity)
8188 || !platform::roots_equal(
8189 ¤t_root,
8190 windows_path_text(¤t.canonical_root),
8191 )
8192 {
8193 return Err(failure(
8194 NodeFailureCode::SessionRecordConflict,
8195 "managed session changed while its preview was loading",
8196 ));
8197 }
8198 Ok(())
8199 }
8200
8201 fn revalidate_session_record_context_export(
8202 &self,
8203 expected: &ManagedSessionRecord,
8204 identity: Option<&ProviderSessionIdentity>,
8205 session: &SessionAddress,
8206 allow_clean_detachment: bool,
8207 ) -> Result<(), NodeFailure> {
8208 let current = self.record(&expected.record_id)?;
8209 let current_root = self.workspace_root(¤t.workspace_id)?;
8210 let snapshot = self.internal_session_snapshot(session)?;
8211 let binding = self
8212 .session_bindings
8213 .lock()
8214 .unwrap_or_else(|poisoned| poisoned.into_inner())
8215 .get(&session.session.instance_id)
8216 .cloned()
8217 .ok_or_else(|| {
8218 failure(
8219 NodeFailureCode::SessionRecordConflict,
8220 "managed session runtime binding disappeared during context export",
8221 )
8222 })?;
8223 if !session_record_context_export_binding_is_exact(
8224 expected,
8225 ¤t,
8226 identity,
8227 session,
8228 &binding,
8229 &snapshot.agent_id,
8230 ¤t_root,
8231 allow_clean_detachment,
8232 ) || !session_record_context_export_source_is_usable(
8233 ¤t,
8234 &snapshot.status,
8235 allow_clean_detachment,
8236 ) {
8237 return Err(failure(
8238 NodeFailureCode::SessionRecordConflict,
8239 "managed session binding changed during context export",
8240 ));
8241 }
8242 Ok(())
8243 }
8244
8245 fn revalidate_loaded_history(
8246 &self,
8247 session: &SessionAddress,
8248 candidate_id: &str,
8249 loaded: &HistorySessionRecord,
8250 ) -> Result<(), NodeFailure> {
8251 let snapshot = self.internal_session_snapshot(session)?;
8252 if snapshot.history.pending.is_some()
8253 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8254 || snapshot.history.loaded.as_ref() != Some(loaded)
8255 {
8256 return Err(failure(
8257 NodeFailureCode::SessionRecordConflict,
8258 "managed session history changed during context export",
8259 ));
8260 }
8261 Ok(())
8262 }
8263
8264 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8265 self.session_records
8266 .lock()
8267 .unwrap_or_else(|poisoned| poisoned.into_inner())
8268 .records
8269 .remove(record_id)
8270 }
8271
8272 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8273 if self.destroy_record_materialization(record_id)? {
8274 self.publish(NodeEvent::SessionRecordRemoved {
8275 record_id: record_id.clone(),
8276 });
8277 return Ok(());
8278 }
8279 let transaction = self
8280 .state_transaction
8281 .lock()
8282 .unwrap_or_else(|poisoned| poisoned.into_inner());
8283 let Some(removed) = self.remove_record_memory(record_id) else {
8284 return Ok(());
8285 };
8286 if let Err(error) = self.persist_state_locked() {
8287 let _ = self.insert_record(removed);
8288 return Err(persistence_failure(error));
8289 }
8290 drop(transaction);
8291 self.publish(NodeEvent::SessionRecordRemoved {
8292 record_id: record_id.clone(),
8293 });
8294 Ok(())
8295 }
8296
8297 fn publish_record(&self, record: ManagedSessionRecord) {
8298 self.publish(NodeEvent::SessionRecordUpserted { record });
8299 }
8300
8301 fn mark_record_error(
8302 &self,
8303 record_id: &SessionRecordId,
8304 message: &str,
8305 ) -> Result<(), NodeFailure> {
8306 let transaction = self
8307 .state_transaction
8308 .lock()
8309 .unwrap_or_else(|poisoned| poisoned.into_inner());
8310 let previous = {
8311 let mut records = self
8312 .session_records
8313 .lock()
8314 .unwrap_or_else(|poisoned| poisoned.into_inner());
8315 let Some(record) = records.records.get_mut(record_id) else {
8316 return Ok(());
8317 };
8318 let previous = record.clone();
8319 record.state = ManagedSessionState::Unavailable;
8320 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8321 record.updated_at_unix_ms = unix_time_ms();
8322 previous
8323 };
8324 if let Err(error) = self.persist_state_locked() {
8325 self.session_records
8326 .lock()
8327 .unwrap_or_else(|poisoned| poisoned.into_inner())
8328 .records
8329 .insert(record_id.clone(), previous);
8330 return Err(persistence_failure(error));
8331 }
8332 let record = self.record(record_id)?;
8333 drop(transaction);
8334 self.publish_record(record);
8335 Ok(())
8336 }
8337
8338 fn rename_session_record(
8339 &self,
8340 record_id: &SessionRecordId,
8341 display_name: String,
8342 ) -> Result<ManagedSessionRecord, NodeFailure> {
8343 validate_display_name(&display_name)
8344 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8345 let _transaction = self
8346 .state_transaction
8347 .lock()
8348 .unwrap_or_else(|poisoned| poisoned.into_inner());
8349 let previous = {
8350 let mut records = self
8351 .session_records
8352 .lock()
8353 .unwrap_or_else(|poisoned| poisoned.into_inner());
8354 let record = records.records.get_mut(record_id).ok_or_else(|| {
8355 failure(
8356 NodeFailureCode::UnknownSessionRecord,
8357 "managed session record does not exist",
8358 )
8359 })?;
8360 let previous = record.clone();
8361 record.display_name = display_name;
8362 record.updated_at_unix_ms = unix_time_ms();
8363 previous
8364 };
8365 if let Err(error) = self.persist_state_locked() {
8366 self.session_records
8367 .lock()
8368 .unwrap_or_else(|poisoned| poisoned.into_inner())
8369 .records
8370 .insert(record_id.clone(), previous);
8371 return Err(persistence_failure(error));
8372 }
8373 let updated = self.record(record_id)?;
8374 self.publish_record(updated.clone());
8375 Ok(updated)
8376 }
8377
8378 fn set_session_task(
8379 &self,
8380 record_id: &SessionRecordId,
8381 expected_revision: u64,
8382 target: SessionTaskTargetV1,
8383 ) -> Result<ManagedSessionRecord, NodeFailure> {
8384 let transaction = self
8385 .state_transaction
8386 .lock()
8387 .unwrap_or_else(|poisoned| poisoned.into_inner());
8388 let current = self.record(record_id)?;
8389 let current_task_id = current
8390 .task_binding
8391 .as_ref()
8392 .and_then(|binding| binding.task_id.as_ref());
8393 let current_revision = current
8394 .task_binding
8395 .as_ref()
8396 .map_or(0, |binding| binding.revision);
8397 if expected_revision != current_revision {
8398 return Err(failure(
8399 NodeFailureCode::SessionRecordConflict,
8400 "managed session task binding revision changed",
8401 ));
8402 }
8403 let exact = match &target {
8404 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8405 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8406 SessionTaskTargetV1::New => false,
8407 };
8408 if exact {
8409 return Ok(current);
8410 }
8411 let revision = current_revision.checked_add(1).ok_or_else(|| {
8412 failure(
8413 NodeFailureCode::SessionRecordConflict,
8414 "managed session task binding revision is exhausted",
8415 )
8416 })?;
8417 let task_id = match target {
8418 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8419 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8420 SessionTaskTargetV1::Clear => None,
8421 };
8422 let changed_at_unix_ms = unix_time_ms()
8423 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8424 failure(
8425 NodeFailureCode::SessionRecordConflict,
8426 "managed session record timestamp is exhausted",
8427 )
8428 })?);
8429 let updated = {
8430 let mut records = self
8431 .session_records
8432 .lock()
8433 .unwrap_or_else(|poisoned| poisoned.into_inner());
8434 let record = records.records.get_mut(record_id).ok_or_else(|| {
8435 failure(
8436 NodeFailureCode::UnknownSessionRecord,
8437 "managed session record does not exist",
8438 )
8439 })?;
8440 record.task_binding = Some(SessionTaskBindingV1 {
8441 revision,
8442 task_id,
8443 changed_at_unix_ms,
8444 });
8445 record.updated_at_unix_ms = changed_at_unix_ms;
8446 record.clone()
8447 };
8448 if let Err(error) = self.persist_state_locked() {
8449 self.session_records
8450 .lock()
8451 .unwrap_or_else(|poisoned| poisoned.into_inner())
8452 .records
8453 .insert(record_id.clone(), current);
8454 return Err(persistence_failure(error));
8455 }
8456 drop(transaction);
8457 self.publish_record(updated.clone());
8458 Ok(updated)
8459 }
8460
8461 fn index_provider_session(
8462 &self,
8463 workspace_id: WorkspaceId,
8464 provider: AgentId,
8465 identity: ProviderSessionIdentity,
8466 display_name: String,
8467 ) -> Result<ManagedSessionRecord, NodeFailure> {
8468 self.index_provider_session_with_policy(
8469 workspace_id,
8470 provider,
8471 identity,
8472 display_name,
8473 false,
8474 )
8475 }
8476
8477 fn index_provider_session_with_policy(
8478 &self,
8479 workspace_id: WorkspaceId,
8480 provider: AgentId,
8481 identity: ProviderSessionIdentity,
8482 display_name: String,
8483 allow_validated_transcript_path: bool,
8484 ) -> Result<ManagedSessionRecord, NodeFailure> {
8485 validate_display_name(&display_name)
8486 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8487 identity
8488 .validate()
8489 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8490 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8491 return Err(failure(
8492 NodeFailureCode::InvalidRequest,
8493 "provider session reference index v1 does not accept transcript paths",
8494 ));
8495 }
8496 let canonical_root = self.workspace_root(&workspace_id)?;
8497 if !self.enabled_providers.contains(&provider) {
8498 return Err(failure(
8499 NodeFailureCode::InvalidRequest,
8500 "provider is not enabled on this node",
8501 ));
8502 }
8503
8504 let transaction = self
8505 .state_transaction
8506 .lock()
8507 .unwrap_or_else(|poisoned| poisoned.into_inner());
8508 {
8509 let records = self
8510 .session_records
8511 .lock()
8512 .unwrap_or_else(|poisoned| poisoned.into_inner());
8513 if let Some(existing) = records.records.values().find(|candidate| {
8514 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8515 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8516 })
8517 }) {
8518 if existing.provider == provider && existing.workspace_id == workspace_id {
8519 return Ok(existing.clone());
8520 }
8521 return Err(failure(
8522 NodeFailureCode::SessionRecordConflict,
8523 "provider session reference conflicts with another provider or workspace",
8524 ));
8525 }
8526 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8527 return Err(failure(
8528 NodeFailureCode::BackendBusy,
8529 "managed session record capacity is full; forget an inactive record first",
8530 ));
8531 }
8532 }
8533
8534 let now = unix_time_ms();
8535 let record = ManagedSessionRecord {
8536 record_id: self.allocate_record_id()?,
8537 display_name,
8538 provider,
8539 mode: SessionMode::Pty,
8540 state: ManagedSessionState::Dormant,
8541 workspace_id,
8542 canonical_root: opaque_windows_path(canonical_root),
8543 provider_session: Some(identity),
8544 active_session: None,
8545 environment_profile: None,
8546 bundle: None,
8547 context_id: None,
8548 context: None,
8549 exported_context: None,
8550 task_binding: None,
8551 created_at_unix_ms: now,
8552 updated_at_unix_ms: now,
8553 last_error: None,
8554 };
8555 self.insert_record(record.clone())?;
8556 if let Err(error) = self.persist_state_locked() {
8557 self.remove_record_memory(&record.record_id);
8558 return Err(persistence_failure(error));
8559 }
8560 drop(transaction);
8561 self.publish_record(record.clone());
8562 Ok(record)
8563 }
8564
8565 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8566 self.session_bindings
8567 .lock()
8568 .unwrap_or_else(|poisoned| poisoned.into_inner())
8569 .iter()
8570 .find_map(|(instance_id, binding)| {
8571 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8572 workspace_id: binding.workspace_id.clone(),
8573 session: SessionKey {
8574 instance_id: *instance_id,
8575 generation: binding.generation,
8576 },
8577 })
8578 })
8579 }
8580
8581 async fn forget_session_record(
8582 &self,
8583 record_id: &SessionRecordId,
8584 ) -> Result<(), NodeFailure> {
8585 let record = self.record(record_id)?;
8586 if record.active_session.is_some()
8587 || matches!(
8588 record.state,
8589 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8590 )
8591 {
8592 return Err(failure(
8593 NodeFailureCode::SessionRecordBusy,
8594 "stop the managed session before forgetting it",
8595 ));
8596 }
8597 if let Some(address) = self.bound_address_for_record(record_id) {
8598 self.remove_session(&address).await?;
8599 }
8600 self.remove_managed_session_record(record_id, &record.workspace_id).await
8601 }
8602
8603 async fn remove_managed_session_record(
8618 &self,
8619 record_id: &SessionRecordId,
8620 workspace_id: &WorkspaceId,
8621 ) -> Result<(), NodeFailure> {
8622 let managed_lease_id = self.managed_worktrees.lock()
8623 .unwrap_or_else(|poisoned| poisoned.into_inner())
8624 .lease_for_workspace(workspace_id);
8625 if !self.destroy_record_materialization(record_id)? {
8626 let transaction = self
8627 .state_transaction
8628 .lock()
8629 .unwrap_or_else(|poisoned| poisoned.into_inner());
8630 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8631 failure(
8632 NodeFailureCode::UnknownSessionRecord,
8633 "managed session record does not exist",
8634 )
8635 })?;
8636 if let Err(error) = self.persist_state_locked() {
8637 let _ = self.insert_record(removed);
8638 return Err(persistence_failure(error));
8639 }
8640 drop(transaction);
8641 }
8642 self.publish(NodeEvent::SessionRecordRemoved {
8643 record_id: record_id.clone(),
8644 });
8645 if let Some(lease_id) = managed_lease_id {
8646 if let Some(snapshot) = self.managed_worktrees.lock()
8647 .unwrap_or_else(|poisoned| poisoned.into_inner())
8648 .get(&lease_id)
8649 .map(ManagedWorktreeLeaseRecord::snapshot)
8650 {
8651 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8652 }
8653 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8654 }
8655 Ok(())
8656 }
8657
8658 async fn retire_unavailable_session_records(&self) {
8671 let policy = self.session_record_retention;
8672 let age_enabled = policy.age_ms > 0;
8673 let keep_enabled = policy.keep_per_workspace > 0;
8674 if !age_enabled && !keep_enabled {
8675 return;
8676 }
8677 let now = unix_time_ms();
8678 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8679 self.session_records
8680 .lock()
8681 .unwrap_or_else(|poisoned| poisoned.into_inner())
8682 .records
8683 .values()
8684 .filter(|record| record.state == ManagedSessionState::Unavailable)
8685 .map(|record| {
8686 (
8687 record.record_id.clone(),
8688 record.workspace_id.clone(),
8689 record.provider.clone(),
8690 record.updated_at_unix_ms,
8691 )
8692 })
8693 .collect()
8694 };
8695 if candidates.is_empty() {
8696 return;
8697 }
8698 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8699 BTreeMap::new();
8700 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8701 by_workspace
8702 .entry(workspace_id)
8703 .or_default()
8704 .push((record_id, provider, updated_at_unix_ms));
8705 }
8706 let reason = if age_enabled && keep_enabled {
8707 "age+keep-n"
8708 } else if age_enabled {
8709 "age"
8710 } else {
8711 "keep-n"
8712 };
8713 let mut retired: u64 = 0;
8714 for (workspace_id, mut records) in by_workspace {
8715 records.sort_by(|left, right| right.2.cmp(&left.2));
8718 for (index, (record_id, provider, updated_at_unix_ms)) in
8719 records.into_iter().enumerate()
8720 {
8721 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8722 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8723 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8724 let should_retire = if age_enabled && keep_enabled {
8725 age_hit && keep_hit
8726 } else {
8727 age_hit || keep_hit
8728 };
8729 if !should_retire {
8730 continue;
8731 }
8732 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8733 Ok(()) => {
8734 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8735 retired += 1;
8736 tracing::info!(
8737 node_id = %self.node_id,
8738 record_id = %record_id,
8739 workspace_id = %workspace_id,
8740 provider = %provider,
8741 age_ms = record_age_ms,
8742 reason,
8743 "managed session record retired"
8744 );
8745 }
8746 Err(error) => tracing::warn!(
8747 node_id = %self.node_id,
8748 record_id = %record_id,
8749 workspace_id = %workspace_id,
8750 code = ?error.code,
8751 cause = %error.message,
8752 "session record retention sweep failed to retire a record"
8753 ),
8754 }
8755 }
8756 }
8757 if retired > 0 {
8758 let remaining_unavailable = self
8759 .session_records
8760 .lock()
8761 .unwrap_or_else(|poisoned| poisoned.into_inner())
8762 .records
8763 .values()
8764 .filter(|record| record.state == ManagedSessionState::Unavailable)
8765 .count();
8766 tracing::info!(
8767 node_id = %self.node_id,
8768 retired,
8769 remaining_unavailable,
8770 "session record retention sweep completed"
8771 );
8772 }
8773 }
8774
8775 async fn resume_session_record(
8776 &self,
8777 record_id: &SessionRecordId,
8778 terminal_size: gate4agent_types::TerminalSize,
8779 initial_prompt: Option<String>,
8780 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8781 let mut record = self.record(record_id)?;
8782 if record.mode == SessionMode::Acp
8785 && record.provider.as_str() == "claude"
8786 && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8787 {
8788 record.mode = SessionMode::Pty;
8789 if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8790 r.mode = SessionMode::Pty;
8791 }
8792 tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8793 }
8794 if record.active_session.is_some()
8795 || matches!(
8796 record.state,
8797 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8798 )
8799 {
8800 return Err(failure(
8801 NodeFailureCode::SessionRecordBusy,
8802 "managed session already has a live runtime binding",
8803 ));
8804 }
8805 let identity = record.provider_session.clone().ok_or_else(|| {
8806 failure(
8807 NodeFailureCode::SessionRecordNotResumable,
8808 "managed session has no verified provider session identity",
8809 )
8810 })?;
8811 let working_directory = self.workspace_root(&record.workspace_id)?;
8812 if !platform::roots_equal(
8813 &working_directory,
8814 windows_path_text(&record.canonical_root),
8815 ) {
8816 return Err(failure(
8817 NodeFailureCode::SessionRecordConflict,
8818 "managed session workspace root changed; refusing to resume in another directory",
8819 ));
8820 }
8821 {
8822 let records = self
8823 .session_records
8824 .lock()
8825 .unwrap_or_else(|poisoned| poisoned.into_inner());
8826 if records.records.values().any(|candidate| {
8827 candidate.record_id != *record_id
8828 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8829 session_registry::same_provider_session(
8830 &candidate.provider,
8831 candidate_identity,
8832 &record.provider,
8833 &identity,
8834 )
8835 })
8836 && (candidate.active_session.is_some()
8837 || matches!(
8838 candidate.state,
8839 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8840 ))
8841 }) {
8842 return Err(failure(
8843 NodeFailureCode::SessionRecordConflict,
8844 "provider session is already active through another managed record",
8845 ));
8846 }
8847 }
8848 let runtime_requirement = if initial_prompt.is_some() {
8849 ProviderRuntimeRequirement::ResumeWithPrompt
8850 } else {
8851 ProviderRuntimeRequirement::Resume
8852 };
8853 let runtime_policy = self.admit_provider_runtime(
8854 &record.provider,
8855 runtime_requirement,
8856 )
8857 .await?;
8858 let working_directory = match self.m4a_accounts.as_ref() {
8860 Some(_) => {
8861 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8862 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8863 }
8864 None => working_directory,
8865 };
8866 let request = ResumeLaunchRequest {
8867 working_directory,
8868 terminal_size,
8869 initial_prompt,
8870 };
8871 request
8872 .validate()
8873 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8874 self.ensure_binding_capacity()?;
8875 let resolved_materialization = match self.resolve_record_materialization(
8876 record_id,
8877 &record.provider,
8878 record.mode,
8879 record.environment_profile.as_ref(),
8880 record.bundle.as_ref(),
8881 record.context.as_ref(),
8882 ) {
8883 Ok(materialization) => materialization,
8884 Err(error) => {
8885 let label = match error.code {
8886 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8887 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8888 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8889 _ => "environment-profile-unavailable",
8890 };
8891 self.mark_record_error(record_id, label)?;
8892 return Err(error);
8893 }
8894 };
8895 let (materialization_id, environment_overlay) = match resolved_materialization {
8896 Some((id, overlay)) => (Some(id), overlay),
8897 None => (None, None),
8898 };
8899 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8900 let environment_overlay = {
8902 let mut args: Vec<OsString> = Vec::new();
8903 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8904 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8905 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8906 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8907 }
8908 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8909 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8910 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8911 if let Some(sel) = options.as_ref() {
8912 args.push("--model".into()); args.push(sel.model.clone().into());
8913 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8914 args.push("--effort".into()); args.push(effort.to_owned().into());
8915 }
8916 }
8917 }
8918 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8919 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8920 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8921 instance_id: instance_id.0,
8922 provider: record.provider.as_str().to_owned(),
8923 workspace_id: record.workspace_id.as_str().to_owned(),
8924 provider_session_id: identity.id.clone(),
8925 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8926 cwd: String::new(),
8927 acp: record.mode == SessionMode::Acp,
8928 options,
8929 cols: 0,
8930 rows: 0,
8931 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8932 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8933 });
8934 }
8935 }
8936 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8937 };
8938 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8940 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8941 if let Some(old) = self.bound_address_for_record(record_id) {
8942 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8943 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8944 }
8945 }
8946 let session_key = self.m4a_session_key(instance_id);
8947 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8948 }
8949 _ => environment_overlay,
8950 };
8951 let mut environment_selection = match SpawnPreparer::select_environment(self,
8952 instance_id,
8953 &record.provider,
8954 record.mode,
8955 record.environment_profile.as_ref(),
8956 ) {
8957 Ok(selection) => selection,
8958 Err(error) => {
8959 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8960 return Err(error);
8961 }
8962 };
8963 let mut instance_overlay = environment_overlay
8964 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8965 .transpose()?
8966 .flatten();
8967 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8968 self.remove_session(&stale_address).await?;
8969 }
8970 let address = SessionAddress {
8971 workspace_id: record.workspace_id.clone(),
8972 session: SessionKey {
8973 instance_id,
8974 generation: SessionGeneration(1),
8975 },
8976 };
8977 let transaction = self
8978 .state_transaction
8979 .lock()
8980 .unwrap_or_else(|poisoned| poisoned.into_inner());
8981 let current = self.record(record_id)?;
8982 if current.active_session.is_some()
8983 || matches!(
8984 current.state,
8985 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8986 )
8987 {
8988 return Err(failure(
8989 NodeFailureCode::SessionRecordBusy,
8990 "managed session became active while resume was being prepared",
8991 ));
8992 }
8993 self.bind_managed_session_with_materialization(
8994 &address,
8995 record_id.clone(),
8996 runtime_policy,
8997 record.environment_profile.clone(),
8998 record.bundle.clone(),
8999 record.context.clone(),
9000 materialization_id,
9001 );
9002 if let Some(selection) = environment_selection.take() {
9003 selection.retain();
9004 }
9005 if let Some(overlay) = instance_overlay.take() {
9006 overlay.retain();
9007 }
9008 drop(transaction);
9009
9010 let transport = match record.mode {
9011 SessionMode::Pty => TransportKind::Pty,
9012 SessionMode::Inline => TransportKind::Pipe,
9013 SessionMode::Acp => TransportKind::Acp,
9014 };
9015 let agent_id = record.provider.clone();
9016 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9017 if let Err(error) = self
9018 .dispatch_bounded(
9019 ControlCommand::Register {
9020 instance_id,
9021 agent_id,
9022 transport,
9023 },
9024 dispatch_timeout,
9025 )
9026 .await
9027 {
9028 if let Some(binding) = self.remove_binding(&address) {
9029 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9030 }
9031 return Err(error);
9032 }
9033 let command = self.prepare_command(ControlCommand::Resume {
9034 instance_id,
9035 target: ResumeTarget::ProviderSession { identity },
9036 runtime_policy,
9037 request,
9038 });
9039 let started_after = self.current_sequence();
9040 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9041 self.rollback_spawn(&address, dispatch_timeout).await?;
9042 return Err(error);
9043 }
9044 if let Err(error) = self.dispatch_envelope(command) {
9045 self.clear_armed_resume(&address);
9046 self.rollback_spawn(&address, dispatch_timeout).await?;
9047 return Err(error);
9048 }
9049 match self
9050 .wait_for_record_resume(&address, started_after)
9051 .await
9052 {
9053 Ok(settled_address) => {
9054 let managed_lease_id = {
9055 self.managed_worktrees.lock()
9056 .unwrap_or_else(|poisoned| poisoned.into_inner())
9057 .lease_for_workspace(&record.workspace_id)
9058 };
9059 if let Some(lease_id) = managed_lease_id {
9060 {
9061 let mut bindings = self.session_bindings.lock()
9062 .unwrap_or_else(|poisoned| poisoned.into_inner());
9063 let binding = bindings.get_mut(&settled_address.session.instance_id)
9064 .ok_or_else(|| failure(
9065 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9066 "resumed managed session lost its exact binding",
9067 ))?;
9068 if binding.generation != settled_address.session.generation {
9069 return Err(failure(
9070 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071 "resumed managed session generation diverged",
9072 ));
9073 }
9074 binding.managed_worktree_lease_id = Some(lease_id.clone());
9075 }
9076 let snapshot = self.managed_worktrees.lock()
9077 .unwrap_or_else(|poisoned| poisoned.into_inner())
9078 .bind_session(
9079 &lease_id,
9080 ManagedWorktreeSessionHolder {
9081 incarnation_id: self.incarnation_id,
9082 instance_id: settled_address.session.instance_id,
9083 generation: settled_address.session.generation,
9084 },
9085 Some(record_id.clone()),
9086 unix_time_ms(),
9087 ).map_err(|message| failure(
9088 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9089 &message,
9090 ))?;
9091 if let Err(error) = self.persist_state() {
9092 let _ = self.remove_session(&settled_address).await;
9093 return Err(persistence_failure(error));
9094 }
9095 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9096 }
9097 Ok((self.record(record_id)?, settled_address))
9098 }
9099 Err(error) => {
9100 if matches!(
9101 error.code,
9102 NodeFailureCode::SessionRecordNotResumable
9103 | NodeFailureCode::BackendOperationFailed
9104 ) {
9105 if let Some(bound) = self.bound_address_for_record(record_id) {
9106 let _ = self.remove_session(&bound).await;
9107 }
9108 }
9109 Err(error)
9110 }
9111 }
9112 }
9113
9114 async fn wait_for_record_resume(
9115 &self,
9116 initial_address: &SessionAddress,
9117 after_sequence: u64,
9118 ) -> Result<SessionAddress, NodeFailure> {
9119 let deadline = Instant::now()
9120 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9121 let mut scan_after = after_sequence;
9122 loop {
9123 let events = self
9124 .history
9125 .lock()
9126 .unwrap_or_else(|poisoned| poisoned.into_inner())
9127 .events
9128 .iter()
9129 .filter(|event| event.sequence > scan_after)
9130 .cloned()
9131 .collect::<Vec<_>>();
9132 for envelope in events {
9133 scan_after = scan_after.max(envelope.sequence);
9134 let NodeEvent::Control { address, event } = envelope.event else {
9135 continue;
9136 };
9137 if address.session.instance_id != initial_address.session.instance_id {
9138 continue;
9139 }
9140 match event.event {
9141 ControlEventKind::Resumed { .. } => return Ok(address),
9142 ControlEventKind::ResumeDenied { reason } => {
9143 return Err(failure(
9144 NodeFailureCode::SessionRecordNotResumable,
9145 &reason,
9146 ));
9147 }
9148 ControlEventKind::ResumeFailed { message }
9149 | ControlEventKind::CommandRejected { message } => {
9150 return Err(failure(
9151 NodeFailureCode::BackendOperationFailed,
9152 &message,
9153 ));
9154 }
9155 _ => {}
9156 }
9157 }
9158 if Instant::now() >= deadline {
9159 return Err(failure(
9160 NodeFailureCode::BackendBusy,
9161 "managed resume did not settle before the bounded deadline",
9162 ));
9163 }
9164 sleep(Duration::from_millis(5)).await;
9165 }
9166 }
9167
9168 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9169 self.workspaces
9170 .read()
9171 .unwrap_or_else(|poisoned| poisoned.into_inner())
9172 .get(workspace_id)
9173 .cloned()
9174 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9175 }
9176
9177 fn workspace_roots(&self) -> Vec<PathBuf> {
9178 self.workspaces
9179 .read()
9180 .unwrap_or_else(|poisoned| poisoned.into_inner())
9181 .values()
9182 .map(PathBuf::from)
9183 .collect()
9184 }
9185
9186 async fn inspect_workspace(
9187 &self,
9188 workspace_id: WorkspaceId,
9189 ) -> Result<WorkspaceInspection, NodeFailure> {
9190 let _permit = self
9191 .inspection_slots
9192 .clone()
9193 .try_acquire_owned()
9194 .map_err(|_| {
9195 tracing::warn!(
9196 workspace_id = %workspace_id,
9197 "workspace inspection rejected: inspection capacity is busy",
9198 );
9199 failure(
9200 NodeFailureCode::BackendBusy,
9201 "workspace inspection capacity is busy",
9202 )
9203 })?;
9204 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9205 tracing::warn!(
9206 workspace_id = %workspace_id,
9207 code = ?error.code,
9208 "workspace inspection rejected: workspace is not registered",
9209 );
9210 error
9211 })?;
9212 let tree_root = canonical_root.clone();
9213 let started_at = Instant::now();
9214 let time_budget_ms = workspace_inspection_time_budget_ms();
9215 let entry_cap = workspace_inspection_entry_cap();
9216 let deadline = started_at + Duration::from_millis(time_budget_ms);
9217 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9218 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9219 })
9220 .await
9221 .map_err(|error| {
9222 tracing::warn!(
9223 workspace_id = %workspace_id,
9224 error = %error,
9225 "workspace inspection rejected: walk task failed",
9226 );
9227 failure(
9228 NodeFailureCode::BackendOperationFailed,
9229 &format!("workspace inspection task failed: {error}"),
9230 )
9231 })?;
9232 let (mut git, git_time_budget_exceeded) =
9233 inspect_git_workspace(&canonical_root, deadline).await;
9234 if !git.worktrees.is_empty() {
9235 let registered = self
9236 .workspaces
9237 .read()
9238 .unwrap_or_else(|poisoned| poisoned.into_inner())
9239 .iter()
9240 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9241 .collect::<Vec<_>>();
9242 for worktree in &mut git.worktrees {
9243 worktree.workspace_id = registered
9244 .iter()
9245 .find(|(_, root)| {
9246 worktree_paths_equal(root, windows_path_text(&worktree.path))
9247 })
9248 .map(|(workspace_id, _)| workspace_id.clone());
9249 }
9250 }
9251 if git.is_repository {
9252 git.managed_worktree = self
9253 .managed_worktrees
9254 .lock()
9255 .unwrap_or_else(|poisoned| poisoned.into_inner())
9256 .git_scope(&workspace_id, git.branch.as_deref());
9257 }
9258 let truncation = workspace_inspection_truncation(
9259 &walk_budget,
9260 git_time_budget_exceeded,
9261 started_at.elapsed(),
9262 );
9263 if let Some(truncation) = &truncation {
9264 tracing::info!(
9265 workspace_id = %workspace_id,
9266 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9267 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9268 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9269 entries_visited = truncation.entries_visited,
9270 elapsed_ms = truncation.elapsed_ms,
9271 "workspace inspection truncated by its inner budget",
9272 );
9273 }
9274 Ok(WorkspaceInspection {
9275 workspace_id,
9276 entries,
9277 tree_truncated,
9278 git,
9279 truncation,
9280 })
9281 }
9282
9283 async fn browse_host_directories(
9284 &self,
9285 directory: Option<OpaqueHostPath>,
9286 after: Option<OpaqueHostPath>,
9287 ) -> Result<HostDirectoryListing, NodeFailure> {
9288 let permit = self
9289 .inspection_slots
9290 .clone()
9291 .try_acquire_owned()
9292 .map_err(|_| {
9293 failure(
9294 NodeFailureCode::BackendBusy,
9295 "host directory browse capacity is busy",
9296 )
9297 })?;
9298 let task = tokio::task::spawn_blocking(move || {
9299 let _permit = permit;
9300 browse_host_directories(directory, after)
9301 });
9302 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9303 .await
9304 .map_err(|_| {
9305 failure(
9306 NodeFailureCode::HostDirectoryReadTimedOut,
9307 "host directory browse exceeded its bounded deadline",
9308 )
9309 })?
9310 .map_err(|_| {
9311 failure(
9312 NodeFailureCode::HostDirectoryReadFailed,
9313 "host directory browse task failed",
9314 )
9315 })?
9316 .map_err(host_directory_failure)
9317 }
9318
9319 async fn read_workspace_file(
9320 &self,
9321 workspace_id: WorkspaceId,
9322 path: RepositoryPath,
9323 ) -> Result<WorkspaceFileRead, NodeFailure> {
9324 let canonical_root = self.workspace_root(&workspace_id)?;
9325 let permit = self
9326 .inspection_slots
9327 .clone()
9328 .try_acquire_owned()
9329 .map_err(|_| {
9330 failure(
9331 NodeFailureCode::BackendBusy,
9332 "workspace file read capacity is busy",
9333 )
9334 })?;
9335 let path_to_read = path.clone();
9336 let task = tokio::task::spawn_blocking(move || {
9337 let _permit = permit;
9338 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9339 });
9340 let content = timeout(
9341 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9342 task,
9343 )
9344 .await
9345 .map_err(|_| {
9346 failure(
9347 NodeFailureCode::RepositoryFileReadTimedOut,
9348 "repository file read exceeded its bounded deadline",
9349 )
9350 })?
9351 .map_err(|_| {
9352 failure(
9353 NodeFailureCode::RepositoryFileReadFailed,
9354 "repository file read task failed",
9355 )
9356 })?
9357 .map_err(workspace_file_failure)?;
9358 let content = match content {
9359 WorkspaceFileBytes::Utf8(text) => {
9360 let revision = Some(workspace_file_revision(text.as_bytes()));
9361 return Ok(WorkspaceFileRead {
9362 workspace_id,
9363 path,
9364 content: WorkspaceFileContent::Utf8 {
9365 byte_len: u32::try_from(text.len())
9366 .expect("bounded workspace text length must fit u32"),
9367 text,
9368 },
9369 revision,
9370 });
9371 }
9372 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9373 WorkspaceFileContent::NonUtf8 {
9374 byte_len: u32::try_from(byte_length)
9375 .expect("bounded workspace file length must fit u32"),
9376 }
9377 }
9378 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9379 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9380 },
9381 };
9382 Ok(WorkspaceFileRead {
9383 workspace_id,
9384 path,
9385 content,
9386 revision: None,
9387 })
9388 }
9389
9390 async fn write_workspace_file(
9391 &self,
9392 workspace_id: WorkspaceId,
9393 path: RepositoryPath,
9394 expected_revision: WorkspaceFileRevision,
9395 text: String,
9396 ) -> Result<WorkspaceFileRead, NodeFailure> {
9397 let canonical_root = self.workspace_root(&workspace_id)?;
9398 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9399 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9400 })?;
9401 let path_to_write = path.clone();
9402 let expected = expected_revision.as_str().to_owned();
9403 let text_to_write = text.clone();
9404 let task = tokio::task::spawn_blocking(move || {
9405 let _permit = permit;
9406 write_workspace_file_to_disk(
9407 Path::new(&canonical_root),
9408 &path_to_write,
9409 &expected,
9410 &text_to_write,
9411 )
9412 });
9413 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9414 .await
9415 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9416 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9417 .map_err(workspace_file_write_failure)?;
9418 Ok(WorkspaceFileRead {
9419 workspace_id,
9420 path,
9421 content: WorkspaceFileContent::Utf8 {
9422 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9423 text,
9424 },
9425 revision: Some(WorkspaceFileRevision::new(revision)
9426 .expect("workspace writer returns a valid SHA-256 revision")),
9427 })
9428 }
9429
9430 async fn create_workspace_file(
9431 &self,
9432 workspace_id: WorkspaceId,
9433 path: RepositoryPath,
9434 ) -> Result<WorkspaceFileRead, NodeFailure> {
9435 let canonical_root = self.workspace_root(&workspace_id)?;
9436 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9437 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9438 })?;
9439 let path_to_create = path.clone();
9440 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9441 let task_commit_state = Arc::clone(&commit_state);
9442 let task = tokio::task::spawn_blocking(move || {
9443 let _permit = permit;
9444 create_workspace_file_on_disk(
9445 Path::new(&canonical_root),
9446 &path_to_create,
9447 &task_commit_state,
9448 )
9449 });
9450 let revision = settle_workspace_entry_create(
9451 task,
9452 commit_state,
9453 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9454 "repository file create exceeded its bounded deadline",
9455 "repository file create task failed",
9456 )
9457 .await?;
9458 Ok(WorkspaceFileRead {
9459 workspace_id,
9460 path,
9461 content: WorkspaceFileContent::Utf8 {
9462 text: String::new(),
9463 byte_len: 0,
9464 },
9465 revision: Some(WorkspaceFileRevision::new(revision)
9466 .expect("workspace creator returns a valid SHA-256 revision")),
9467 })
9468 }
9469
9470 async fn create_workspace_directory(
9471 &self,
9472 workspace_id: WorkspaceId,
9473 path: RepositoryPath,
9474 ) -> Result<WorkspaceEntry, NodeFailure> {
9475 let canonical_root = self.workspace_root(&workspace_id)?;
9476 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9477 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9478 })?;
9479 let path_to_create = path.clone();
9480 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9481 let task_commit_state = Arc::clone(&commit_state);
9482 let task = tokio::task::spawn_blocking(move || {
9483 let _permit = permit;
9484 create_workspace_directory_on_disk(
9485 Path::new(&canonical_root),
9486 &path_to_create,
9487 &task_commit_state,
9488 )
9489 });
9490 settle_workspace_entry_create(
9491 task,
9492 commit_state,
9493 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9494 "repository directory create exceeded its bounded deadline",
9495 "repository directory create task failed",
9496 )
9497 .await?;
9498 Ok(WorkspaceEntry {
9499 relative_path: path,
9500 kind: WorkspaceEntryKind::Directory,
9501 })
9502 }
9503
9504 async fn read_git_history(
9505 &self,
9506 workspace_id: WorkspaceId,
9507 path: Option<RepositoryPath>,
9508 before: Option<GitObjectId>,
9509 limit: u16,
9510 ) -> Result<GitHistoryPage, NodeFailure> {
9511 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9512 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9513 })?;
9514 let root = self.workspace_root(&workspace_id)?;
9515 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9516 }
9517
9518 async fn read_git_diff(
9519 &self,
9520 workspace_id: WorkspaceId,
9521 request: GitDiffRequest,
9522 ) -> Result<GitDiff, NodeFailure> {
9523 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9524 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9525 })?;
9526 let root = self.workspace_root(&workspace_id)?;
9527 read_git_diff_bounded(&root, request).await
9528 }
9529
9530 fn native_session_route_root(
9531 &self,
9532 route: &NativeSessionCatalogRoute,
9533 ) -> Result<Option<String>, NodeFailure> {
9534 route
9535 .validate()
9536 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9537 match route.scope {
9538 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9539 .workspace_id
9540 .as_ref()
9541 .map(|workspace_id| self.workspace_root(workspace_id))
9542 .transpose(),
9543 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9544 }
9545 }
9546
9547 fn project_native_session_entries(
9548 &self,
9549 route: &NativeSessionCatalogRoute,
9550 entries: Vec<ScopedNativeSessionCatalogEntry>,
9551 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9552 let current_root = route
9553 .workspace_id
9554 .as_ref()
9555 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9556 let records = self
9557 .session_records
9558 .lock()
9559 .unwrap_or_else(|poisoned| poisoned.into_inner());
9560 let mut seen_records = std::collections::HashSet::new();
9561 entries
9562 .into_iter()
9563 .map(|entry| {
9564 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9565 let matches = records
9566 .records
9567 .values()
9568 .filter(|record| {
9569 record.provider == route.provider
9570 && record.workspace_id == *workspace_id
9571 && current_root.as_deref().is_some_and(|root| {
9572 platform::roots_equal(
9573 root,
9574 windows_path_text(&record.canonical_root),
9575 )
9576 })
9577 && record.provider_session.as_ref().is_some_and(|identity| {
9578 session_registry::same_provider_session(
9579 &record.provider,
9580 identity,
9581 &route.provider,
9582 &entry.provider_session,
9583 )
9584 })
9585 })
9586 .map(|record| record.record_id.clone())
9587 .collect::<Vec<_>>();
9588 (matches.len() == 1
9589 && seen_records.insert(matches[0].clone()))
9590 .then(|| matches[0].clone())
9591 });
9592 let metadata = entry.metadata;
9593 crate::protocol::NativeSessionCatalogEntry {
9594 selection_id: metadata.selection_id,
9595 title: metadata.title,
9596 modified_at_unix_ms: metadata.modified_at_unix_ms,
9597 model: metadata.model,
9598 message_count: metadata.message_count,
9599 completed_turn_count: metadata.completed_turn_count,
9600 external_group: entry.external_group,
9601 record_id,
9602 }
9603 })
9604 .collect()
9605 }
9606
9607 async fn catalog_native_sessions(
9608 &self,
9609 route: NativeSessionCatalogRoute,
9610 limit: u16,
9611 ) -> Result<(
9612 Vec<crate::protocol::NativeSessionCatalogEntry>,
9613 crate::protocol::NativeSessionCatalogSummary,
9614 ), NodeFailure> {
9615 let canonical_root = self.native_session_route_root(&route)?;
9616 let registered_workspace_roots = self.workspace_roots();
9617 let provider = route.provider.clone();
9618 let scope = route.scope;
9619 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9620 failure(
9621 NodeFailureCode::InvalidRequest,
9622 "native session catalog is not configured",
9623 )
9624 })?;
9625 let _permit = Arc::clone(&self.inspection_slots)
9626 .try_acquire_owned()
9627 .map_err(|_| {
9628 failure(
9629 NodeFailureCode::BackendBusy,
9630 "native session catalog capacity is busy",
9631 )
9632 })?;
9633 let page = run_native_session_catalog_operation(
9634 catalog,
9635 "native session catalog",
9636 move |catalog| {
9637 catalog.catalog_initial_for_scope(
9638 &provider,
9639 scope,
9640 canonical_root.as_deref().map(Path::new),
9641 ®istered_workspace_roots,
9642 limit,
9643 )
9644 },
9645 )
9646 .await?;
9647 let summary = crate::protocol::NativeSessionCatalogSummary {
9648 catalog_revision: page.revision,
9649 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9650 recent_total_count: page.recent_total_count,
9651 older_total_count: page.older_total_count,
9652 recent_next_after_selection_id: page.next_after_selection_id,
9653 recent_has_more: page.remaining_count > 0,
9654 };
9655 Ok((self.project_native_session_entries(&route, page.entries), summary))
9656 }
9657
9658 async fn page_native_sessions(
9659 &self,
9660 route: NativeSessionCatalogRoute,
9661 window: crate::protocol::NativeSessionCatalogWindow,
9662 catalog_revision: u64,
9663 recent_cutoff_unix_ms: u64,
9664 after_selection_id: Option<String>,
9665 limit: u16,
9666 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9667 let canonical_root = self.native_session_route_root(&route)?;
9668 let registered_workspace_roots = self.workspace_roots();
9669 let provider = route.provider.clone();
9670 let scope = route.scope;
9671 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9672 failure(
9673 NodeFailureCode::InvalidRequest,
9674 "native session catalog is not configured",
9675 )
9676 })?;
9677 let _permit = Arc::clone(&self.inspection_slots)
9678 .try_acquire_owned()
9679 .map_err(|_| {
9680 failure(
9681 NodeFailureCode::BackendBusy,
9682 "native session catalog capacity is busy",
9683 )
9684 })?;
9685 let page = run_native_session_catalog_operation(
9686 catalog,
9687 "native session catalog page",
9688 move |catalog| {
9689 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9690 &provider,
9691 scope,
9692 canonical_root.as_deref().map(Path::new),
9693 ®istered_workspace_roots,
9694 window,
9695 catalog_revision,
9696 recent_cutoff_unix_ms,
9697 after_selection_id.as_deref(),
9698 limit,
9699 ))
9700 },
9701 )
9702 .await?;
9703 let page = page.map_err(|error| match error {
9704 NativeSessionCatalogError::StaleCatalog => failure(
9705 NodeFailureCode::StaleNativeSessionCatalog,
9706 "native session catalog revision or cursor is stale",
9707 ),
9708 _ => failure(
9709 NodeFailureCode::BackendOperationFailed,
9710 "native session catalog page failed",
9711 ),
9712 })?;
9713 let entries = self.project_native_session_entries(&route, page.entries);
9714 Ok(crate::protocol::NativeSessionCatalogPage {
9715 window: page.window,
9716 revision: page.revision,
9717 entries,
9718 next_after_selection_id: page.next_after_selection_id,
9719 remaining_count: page.remaining_count,
9720 has_more: page.remaining_count > 0,
9721 })
9722 }
9723
9724 async fn preview_native_session(
9725 &self,
9726 selection: NativeSessionSelection,
9727 message_limit: u16,
9728 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9729 selection
9730 .validate()
9731 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9732 let canonical_root = self.native_session_route_root(&selection.route)?;
9733 let registered_workspace_roots = self.workspace_roots();
9734 let provider = selection.route.provider.clone();
9735 let scope = selection.route.scope;
9736 let catalog_revision = selection.catalog_revision;
9737 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9738 let selection_id = selection.selection_id.clone();
9739 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9740 failure(
9741 NodeFailureCode::InvalidRequest,
9742 "native session preview is not configured",
9743 )
9744 })?;
9745 let _permit = Arc::clone(&self.inspection_slots)
9746 .try_acquire_owned()
9747 .map_err(|_| {
9748 failure(
9749 NodeFailureCode::BackendBusy,
9750 "native session preview capacity is busy",
9751 )
9752 })?;
9753 let preview = run_native_session_catalog_operation(
9754 catalog,
9755 "native session preview",
9756 move |catalog| {
9757 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9758 &provider,
9759 scope,
9760 canonical_root.as_deref().map(Path::new),
9761 ®istered_workspace_roots,
9762 catalog_revision,
9763 recent_cutoff_unix_ms,
9764 &selection_id,
9765 message_limit,
9766 ))
9767 },
9768 )
9769 .await?;
9770 preview
9771 .map(Into::into)
9772 .map_err(native_session_preview_failure)
9773 }
9774
9775 async fn index_native_session(
9776 &self,
9777 selection: NativeSessionSelection,
9778 display_name: String,
9779 ) -> Result<ManagedSessionRecord, NodeFailure> {
9780 selection
9781 .validate()
9782 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9783 if selection.route.scope
9784 != gate4agent_types::NativeSessionCatalogScope::Workspace
9785 {
9786 return Err(failure(
9787 NodeFailureCode::WorkspaceRegistrationRequired,
9788 "register the external project as a workspace before indexing or resuming it",
9789 ));
9790 }
9791 let workspace_id = selection
9792 .route
9793 .workspace_id
9794 .clone()
9795 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9796 let canonical_root = self
9797 .native_session_route_root(&selection.route)?
9798 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9799 let registered_workspace_roots = self.workspace_roots();
9800 let provider = selection.route.provider.clone();
9801 let catalog_revision = selection.catalog_revision;
9802 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9803 let selection_id = selection.selection_id.clone();
9804 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9805 failure(
9806 NodeFailureCode::InvalidRequest,
9807 "native session index is not configured",
9808 )
9809 })?;
9810 let _permit = Arc::clone(&self.inspection_slots)
9811 .try_acquire_owned()
9812 .map_err(|_| {
9813 failure(
9814 NodeFailureCode::BackendBusy,
9815 "native session index capacity is busy",
9816 )
9817 })?;
9818 let resolved = run_native_session_catalog_operation(
9819 catalog,
9820 "native session index resolution",
9821 move |catalog| {
9822 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9823 &provider,
9824 gate4agent_types::NativeSessionCatalogScope::Workspace,
9825 Some(Path::new(&canonical_root)),
9826 ®istered_workspace_roots,
9827 catalog_revision,
9828 recent_cutoff_unix_ms,
9829 &selection_id,
9830 ))
9831 },
9832 )
9833 .await?
9834 .map_err(native_session_preview_failure)?;
9835 self.index_provider_session_with_policy(
9836 workspace_id,
9837 selection.route.provider,
9838 resolved.identity,
9839 display_name,
9840 true,
9841 )
9842 }
9843
9844 async fn preview_native_session_by_session_id(
9845 &self,
9846 workspace_id: WorkspaceId,
9847 provider: AgentId,
9848 session_id: String,
9849 message_limit: u16,
9850 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9851 let canonical_root = self.workspace_root(&workspace_id)?;
9852 let registered_workspace_roots = self.workspace_roots();
9853 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9854 failure(
9855 NodeFailureCode::InvalidRequest,
9856 "native session preview is not configured",
9857 )
9858 })?;
9859 let _permit = Arc::clone(&self.inspection_slots)
9860 .try_acquire_owned()
9861 .map_err(|_| {
9862 failure(
9863 NodeFailureCode::BackendBusy,
9864 "native session preview capacity is busy",
9865 )
9866 })?;
9867 run_native_session_catalog_operation(
9868 catalog,
9869 "native session preview",
9870 move |catalog| {
9871 catalog.preview_session_id_for_workspace(
9872 &provider,
9873 Path::new(&canonical_root),
9874 ®istered_workspace_roots,
9875 &session_id,
9876 message_limit,
9877 )
9878 },
9879 )
9880 .await
9881 }
9882
9883 async fn register_workspace(
9884 &self,
9885 workspace_id: WorkspaceId,
9886 root: String,
9887 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9888 validate_workspace_request_root(&root)?;
9889 let canonicalize_workspace_id = workspace_id.clone();
9890 let workspace = tokio::task::spawn_blocking(move || {
9891 WorkspaceConfig::new(canonicalize_workspace_id, root)
9892 })
9893 .await
9894 .map_err(|error| {
9895 failure(
9896 NodeFailureCode::BackendOperationFailed,
9897 &format!("workspace canonicalization task failed: {error}"),
9898 )
9899 })?
9900 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9901 let transaction = self
9902 .state_transaction
9903 .lock()
9904 .unwrap_or_else(|poisoned| poisoned.into_inner());
9905 let mut workspaces = self
9906 .workspaces
9907 .write()
9908 .unwrap_or_else(|poisoned| poisoned.into_inner());
9909 if workspaces.contains_key(&workspace_id) {
9910 return Err(failure(
9911 NodeFailureCode::DuplicateWorkspaceId,
9912 "workspace ID is already registered",
9913 ));
9914 }
9915 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9916 platform::roots_equal(existing_root, workspace.canonical_root())
9917 }) {
9918 return Err(failure(
9919 NodeFailureCode::DuplicateWorkspaceRoot,
9920 &format!("workspace root is already registered as '{existing_id}'"),
9921 ));
9922 }
9923 {
9924 let records = self
9925 .session_records
9926 .lock()
9927 .unwrap_or_else(|poisoned| poisoned.into_inner());
9928 if let Some(record) = records.records.values().find(|record| {
9929 record.workspace_id == workspace_id
9930 && !record
9931 .canonical_root
9932 .as_utf8()
9933 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9934 }) {
9935 return Err(failure(
9936 NodeFailureCode::SessionRecordConflict,
9937 &format!(
9938 "workspace ID is retained by session record '{}' at another root",
9939 record.record_id,
9940 ),
9941 ));
9942 }
9943 }
9944 let snapshot = WorkspaceSnapshot {
9945 workspace_id: workspace_id.clone(),
9946 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9947 sessions: Vec::new(),
9948 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9949 managed_worktree_profiles: Some(WorktreeProfileInventory {
9950 profiles: Vec::new(),
9951 }),
9952 };
9953 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9954 drop(workspaces);
9955 let previous_records = {
9956 let mut records = self
9957 .session_records
9958 .lock()
9959 .unwrap_or_else(|poisoned| poisoned.into_inner());
9960 let previous = records.records.clone();
9961 for record in records.records.values_mut().filter(|record| {
9962 record.workspace_id == workspace_id
9963 && record
9964 .canonical_root
9965 .as_utf8()
9966 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9967 }) {
9968 if record.active_session.is_none() {
9969 record.state = detached_record_state(record);
9970 }
9971 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9972 record.last_error = None;
9973 }
9974 record.updated_at_unix_ms = unix_time_ms();
9975 }
9976 previous
9977 };
9978 if let Err(error) = self.persist_state_locked() {
9979 self.workspaces
9980 .write()
9981 .unwrap_or_else(|poisoned| poisoned.into_inner())
9982 .remove(&workspace_id);
9983 self.session_records
9984 .lock()
9985 .unwrap_or_else(|poisoned| poisoned.into_inner())
9986 .records = previous_records;
9987 return Err(persistence_failure(error));
9988 }
9989 let restored = self
9990 .session_records
9991 .lock()
9992 .unwrap_or_else(|poisoned| poisoned.into_inner())
9993 .records
9994 .values()
9995 .filter(|record| record.workspace_id == workspace_id)
9996 .cloned()
9997 .collect::<Vec<_>>();
9998 drop(transaction);
9999 self.publish(NodeEvent::WorkspaceAdded {
10000 workspace: snapshot.clone(),
10001 });
10002 for record in restored {
10003 self.publish_record(record);
10004 }
10005 Ok(snapshot)
10006 }
10007
10008 async fn create_standalone_workspace(
10009 &self,
10010 workspace_id: WorkspaceId,
10011 root: String,
10012 initial_branch: Option<String>,
10013 ) -> Result<WorkspaceSnapshot, NodeFailure> {
10014 validate_workspace_request_root(&root)?;
10015 validate_git_revision("initial branch", initial_branch.as_deref())?;
10016 let preflight_workspace_id = workspace_id.clone();
10017 let preflight_root = root.clone();
10018 let canonical_candidate = tokio::task::spawn_blocking(move || {
10019 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10020 })
10021 .await
10022 .map_err(|_| failure(
10023 NodeFailureCode::BackendOperationFailed,
10024 "standalone workspace root preflight task failed",
10025 ))?
10026 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10027 {
10028 let workspaces = self
10029 .workspaces
10030 .read()
10031 .unwrap_or_else(|poisoned| poisoned.into_inner());
10032 if workspaces.contains_key(&workspace_id) {
10033 return Err(failure(
10034 NodeFailureCode::DuplicateWorkspaceId,
10035 "workspace ID is already registered",
10036 ));
10037 }
10038 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10039 platform::roots_equal(existing_root, &canonical_candidate)
10040 }) {
10041 return Err(failure(
10042 NodeFailureCode::DuplicateWorkspaceRoot,
10043 &format!("workspace root is already registered as '{existing_id}'"),
10044 ));
10045 }
10046 }
10047 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10048 .await
10049 .map_err(standalone_workspace_failure)?;
10050 prepared
10051 .verify_for_registration()
10052 .await
10053 .map_err(standalone_workspace_failure)?;
10054 match self
10055 .register_workspace(workspace_id, prepared.root().to_owned())
10056 .await
10057 {
10058 Ok(workspace) => Ok(workspace),
10059 Err(registration_error) => {
10060 if prepared.compensate_registration_failure().await {
10061 Err(registration_error)
10062 } else {
10063 Err(failure(
10064 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10065 "standalone workspace initialization succeeded but registration failed; recovery is required",
10066 ))
10067 }
10068 }
10069 }
10070 }
10071
10072 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10073 let bindings = self
10074 .session_bindings
10075 .lock()
10076 .unwrap_or_else(|poisoned| poisoned.into_inner());
10077 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10078 return Err(failure(
10079 NodeFailureCode::WorkspaceBusy,
10080 "workspace retains a bound session; remove every session before unregistering it",
10081 ));
10082 }
10083 drop(bindings);
10084 let transaction = self
10085 .state_transaction
10086 .lock()
10087 .unwrap_or_else(|poisoned| poisoned.into_inner());
10088 let mut workspaces = self
10089 .workspaces
10090 .write()
10091 .unwrap_or_else(|poisoned| poisoned.into_inner());
10092 if !workspaces.contains_key(workspace_id) {
10093 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10094 }
10095 if workspaces.len() == 1 {
10096 return Err(failure(
10097 NodeFailureCode::LastWorkspace,
10098 "node must retain at least one workspace",
10099 ));
10100 }
10101 let removed_root = workspaces
10102 .remove(workspace_id)
10103 .expect("workspace presence was validated");
10104 drop(workspaces);
10105 let previous_records = {
10106 let mut records = self
10107 .session_records
10108 .lock()
10109 .unwrap_or_else(|poisoned| poisoned.into_inner());
10110 let previous = records.records.clone();
10111 for record in records
10112 .records
10113 .values_mut()
10114 .filter(|record| &record.workspace_id == workspace_id)
10115 {
10116 record.active_session = None;
10117 record.state = ManagedSessionState::Unavailable;
10118 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10119 record.updated_at_unix_ms = unix_time_ms();
10120 }
10121 previous
10122 };
10123 if let Err(error) = self.persist_state_locked() {
10124 self.workspaces
10125 .write()
10126 .unwrap_or_else(|poisoned| poisoned.into_inner())
10127 .insert(workspace_id.clone(), removed_root);
10128 self.session_records
10129 .lock()
10130 .unwrap_or_else(|poisoned| poisoned.into_inner())
10131 .records = previous_records;
10132 return Err(persistence_failure(error));
10133 }
10134 let unavailable = self
10135 .session_records
10136 .lock()
10137 .unwrap_or_else(|poisoned| poisoned.into_inner())
10138 .records
10139 .values()
10140 .filter(|record| &record.workspace_id == workspace_id)
10141 .cloned()
10142 .collect::<Vec<_>>();
10143 drop(transaction);
10144 self.publish(NodeEvent::WorkspaceRemoved {
10145 workspace_id: workspace_id.clone(),
10146 });
10147 for record in unavailable {
10148 self.publish_record(record);
10149 }
10150 Ok(())
10151 }
10152
10153 async fn create_worktree(
10154 &self,
10155 source_workspace_id: WorkspaceId,
10156 workspace_id: WorkspaceId,
10157 target_root: String,
10158 branch: String,
10159 base: Option<String>,
10160 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10161 self.require_worktree_service(&source_workspace_id)?;
10162 validate_workspace_request_root(&target_root)?;
10163 validate_git_revision("worktree base", base.as_deref())?;
10164 let source_root = self.workspace_root(&source_workspace_id)?;
10165 {
10166 let workspaces = self
10167 .workspaces
10168 .read()
10169 .unwrap_or_else(|poisoned| poisoned.into_inner());
10170 if workspaces.contains_key(&workspace_id) {
10171 return Err(failure(
10172 NodeFailureCode::DuplicateWorkspaceId,
10173 "workspace ID is already registered",
10174 ));
10175 }
10176 if let Some((existing_id, _)) = workspaces
10177 .iter()
10178 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10179 {
10180 return Err(failure(
10181 NodeFailureCode::DuplicateWorkspaceRoot,
10182 &format!("workspace root is already registered as '{existing_id}'"),
10183 ));
10184 }
10185 }
10186 let mut worktree = create_git_worktree(
10187 &source_root,
10188 &target_root,
10189 &branch,
10190 base.as_deref(),
10191 )
10192 .await
10193 .map_err(git_worktree_failure)?;
10194 let workspace = match self
10195 .register_workspace(workspace_id, worktree.path.clone())
10196 .await
10197 {
10198 Ok(workspace) => workspace,
10199 Err(registration_error) => {
10200 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10201 return Err(failure(
10202 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10203 "worktree registration failed and compensation requires recovery",
10204 ));
10205 }
10206 return Err(failure(
10207 registration_error.code,
10208 ®istration_error.message,
10209 ));
10210 }
10211 };
10212 worktree.workspace_id = Some(workspace.workspace_id.clone());
10213 Ok((protocol_worktree(worktree), workspace))
10214 }
10215
10216 async fn remove_worktree(
10217 &self,
10218 source_workspace_id: WorkspaceId,
10219 target_root: String,
10220 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10221 self.require_worktree_service(&source_workspace_id)?;
10222 validate_workspace_request_root(&target_root)?;
10223 let source_root = self.workspace_root(&source_workspace_id)?;
10224 let target_root = normalize_worktree_removal_target(&target_root)
10225 .map_err(git_worktree_failure)?;
10226 let target = list_git_worktrees(&source_root)
10227 .await
10228 .map_err(git_worktree_failure)?
10229 .into_iter()
10230 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10231 .ok_or_else(|| {
10232 failure(
10233 NodeFailureCode::WorktreeProtected,
10234 "refusing to remove a path that is not in Git's worktree listing",
10235 )
10236 })?;
10237 let registered_workspace_id = {
10238 let workspaces = self
10239 .workspaces
10240 .read()
10241 .unwrap_or_else(|poisoned| poisoned.into_inner());
10242 let matched = workspaces
10243 .iter()
10244 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10245 .map(|(workspace_id, _)| workspace_id.clone());
10246 if matched.is_some() && workspaces.len() == 1 {
10247 return Err(failure(
10248 NodeFailureCode::LastWorkspace,
10249 "node must retain at least one workspace",
10250 ));
10251 }
10252 matched
10253 };
10254 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10255 let bindings = self
10256 .session_bindings
10257 .lock()
10258 .unwrap_or_else(|poisoned| poisoned.into_inner());
10259 if bindings
10260 .values()
10261 .any(|binding| &binding.workspace_id == workspace_id)
10262 {
10263 return Err(failure(
10264 NodeFailureCode::WorkspaceBusy,
10265 "worktree workspace retains a bound session; remove every session first",
10266 ));
10267 }
10268 }
10269 remove_git_worktree(&source_root, &target.path)
10270 .await
10271 .map_err(git_worktree_failure)?;
10272 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10273 self.unregister_workspace(workspace_id)
10274 .map_err(|error| failure(error.code, &error.message))?;
10275 }
10276 Ok(registered_workspace_id)
10277 }
10278
10279 async fn cleanup_managed_worktree(
10280 &self,
10281 lease_id: &ManagedWorktreeLeaseId,
10282 explicit: bool,
10283 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10284 let lease = self.managed_worktrees.lock()
10285 .unwrap_or_else(|poisoned| poisoned.into_inner())
10286 .get(lease_id)
10287 .cloned()
10288 .ok_or_else(|| failure(
10289 NodeFailureCode::UnknownManagedWorktreeLease,
10290 "managed worktree lease does not exist",
10291 ))?;
10292 if lease.state == ManagedWorktreeLeaseState::Removed {
10293 return Ok(lease.snapshot());
10294 }
10295 if lease.has_holders() {
10296 return Err(failure(
10297 NodeFailureCode::ManagedWorktreeBusy,
10298 "managed worktree lease still has a session or durable-record holder",
10299 ));
10300 }
10301 if self
10302 .materializations
10303 .lock()
10304 .unwrap_or_else(|poisoned| poisoned.into_inner())
10305 .values()
10306 .any(|record| record.managed_lease_id() == Some(lease_id))
10307 {
10308 return Err(failure(
10309 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10310 "managed worktree cleanup is blocked by session environment ownership",
10311 ));
10312 }
10313 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10314 let snapshot = {
10315 let mut registry = self.managed_worktrees.lock()
10316 .unwrap_or_else(|poisoned| poisoned.into_inner());
10317 let current = registry.get_mut(lease_id)
10318 .expect("managed worktree lease remains present");
10319 current.state = ManagedWorktreeLeaseState::Retained;
10320 current.cleanup_failure = None;
10321 current.updated_at_unix_ms = unix_time_ms();
10322 current.snapshot()
10323 };
10324 if self.persist_state().is_err() {
10325 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10326 }
10327 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10328 return Ok(snapshot);
10329 }
10330 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10331 if profile.revision() != &lease.profile_revision {
10332 self.mark_managed_recovery_required(
10333 lease_id,
10334 ManagedWorktreeCleanupFailure::OwnershipConflict,
10335 );
10336 return Err(failure(
10337 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10338 "managed worktree profile revision changed",
10339 ));
10340 }
10341 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10342 self.mark_managed_recovery_required(
10343 lease_id,
10344 ManagedWorktreeCleanupFailure::OwnershipConflict,
10345 );
10346 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10347 })?;
10348 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10349 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10350 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10351 managed_git_worktree_failure(error)
10352 })?;
10353 let target = listed.iter()
10354 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10355 if let Some(target) = target {
10356 if target.is_main || target.is_bare || !exact_owned_worktree(
10357 &lease,
10358 &target.path,
10359 target.branch.as_deref(),
10360 &target.head,
10361 ) {
10362 self.mark_managed_recovery_required(
10363 lease_id,
10364 ManagedWorktreeCleanupFailure::OwnershipConflict,
10365 );
10366 return Err(failure(
10367 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10368 "Git worktree identity no longer matches the managed lease",
10369 ));
10370 }
10371 if target.prunable {
10372 self.mark_managed_cleanup_blocked(
10373 lease_id,
10374 ManagedWorktreeCleanupFailure::Prunable,
10375 );
10376 return Err(failure(
10377 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10378 "managed worktree is prunable and requires recovery",
10379 ));
10380 }
10381 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10382 let failure_kind = match error.kind {
10383 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10384 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10385 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10386 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10387 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10388 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10389 };
10390 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10391 self.mark_managed_recovery_required(lease_id, failure_kind);
10392 } else {
10393 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10394 }
10395 return Err(managed_git_worktree_failure(error));
10396 }
10397 } else {
10398 match std::fs::symlink_metadata(&lease.target_root) {
10399 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10400 Ok(_) => {
10401 self.mark_managed_recovery_required(
10402 lease_id,
10403 ManagedWorktreeCleanupFailure::OwnershipConflict,
10404 );
10405 return Err(failure(
10406 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10407 "managed target exists but is not owned by Git",
10408 ));
10409 }
10410 Err(_) => {
10411 self.mark_managed_cleanup_blocked(
10412 lease_id,
10413 ManagedWorktreeCleanupFailure::Backend,
10414 );
10415 return Err(failure(
10416 NodeFailureCode::BackendOperationFailed,
10417 "managed target identity could not be inspected",
10418 ));
10419 }
10420 }
10421 }
10422 let registered = self.workspaces.read()
10423 .unwrap_or_else(|poisoned| poisoned.into_inner())
10424 .contains_key(&lease.workspace_id);
10425 if registered {
10426 self.unregister_workspace(&lease.workspace_id)?;
10427 }
10428 let removed = {
10429 self.managed_worktrees.lock()
10430 .unwrap_or_else(|poisoned| poisoned.into_inner())
10431 .tombstone(lease_id, unix_time_ms())
10432 .expect("managed worktree lease remains present during cleanup")
10433 };
10434 self.persist_state().map_err(persistence_failure)?;
10435 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10436 Ok(removed.snapshot())
10437 }
10438
10439 fn mark_managed_cleanup_blocked(
10440 &self,
10441 lease_id: &ManagedWorktreeLeaseId,
10442 failure_kind: ManagedWorktreeCleanupFailure,
10443 ) {
10444 let snapshot = {
10445 let mut registry = self.managed_worktrees.lock()
10446 .unwrap_or_else(|poisoned| poisoned.into_inner());
10447 let Some(lease) = registry.get_mut(lease_id) else { return };
10448 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10449 lease.cleanup_failure = Some(failure_kind);
10450 lease.updated_at_unix_ms = unix_time_ms();
10451 lease.snapshot()
10452 };
10453 let _ = self.persist_state();
10454 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10455 }
10456
10457 fn require_worktree_service(
10458 &self,
10459 source_workspace_id: &WorkspaceId,
10460 ) -> Result<(), NodeFailure> {
10461 if self.managed_worktrees.lock()
10462 .unwrap_or_else(|poisoned| poisoned.into_inner())
10463 .lease_for_workspace(source_workspace_id)
10464 .is_some()
10465 {
10466 return Err(failure(
10467 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10468 "managed worktree targets are internal-only workspaces",
10469 ));
10470 }
10471 match self
10472 .worktree_service_modes
10473 .get(source_workspace_id)
10474 .copied()
10475 .unwrap_or(WorktreeServiceMode::Manual)
10476 {
10477 WorktreeServiceMode::Manual => Ok(()),
10478 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10479 NodeFailureCode::UnsupportedSpawnCapability,
10480 "Git worktree service mode is unavailable in this node build",
10481 )),
10482 }
10483 }
10484
10485 fn managed_reservation_conflict(
10486 &self,
10487 workspace_id: Option<&WorkspaceId>,
10488 root: Option<&str>,
10489 ) -> bool {
10490 self.managed_worktrees.lock()
10491 .unwrap_or_else(|poisoned| poisoned.into_inner())
10492 .active_records()
10493 .any(|lease| {
10494 workspace_id.is_some_and(|workspace_id| {
10495 workspace_id == &lease.source_workspace_id
10496 || workspace_id == &lease.workspace_id
10497 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10498 })
10499 }
10500
10501 fn reject_managed_reservation(
10502 &self,
10503 workspace_id: Option<&WorkspaceId>,
10504 root: Option<&str>,
10505 ) -> Result<(), NodeFailure> {
10506 if self.managed_reservation_conflict(workspace_id, root) {
10507 Err(failure(
10508 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10509 "managed worktree ownership may only be mutated through its lease cleanup",
10510 ))
10511 } else {
10512 Ok(())
10513 }
10514 }
10515
10516 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10517 self.validate_address(address)?;
10518 self.workspace_root(&address.workspace_id)
10519 }
10520
10521 fn require_session_runtime_policy(
10555 &self,
10556 address: &SessionAddress,
10557 requirement: ProviderRuntimeRequirement,
10558 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10559 let runtime_policy = {
10560 let bindings = self
10561 .session_bindings
10562 .lock()
10563 .unwrap_or_else(|poisoned| poisoned.into_inner());
10564 let binding = bindings
10565 .get(&address.session.instance_id)
10566 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10567 if binding.workspace_id != address.workspace_id {
10568 return Err(failure(
10569 NodeFailureCode::SessionWorkspaceMismatch,
10570 "session belongs to another workspace",
10571 ));
10572 }
10573 if binding.generation != address.session.generation {
10574 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10575 }
10576 binding.runtime_policy
10577 };
10578 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10579 let snapshot = self.internal_session_snapshot(address)?;
10580 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10581 && snapshot.provider.lead_activity != ProviderActivity::Idle
10582 {
10583 return Err(failure(
10584 NodeFailureCode::TurnInFlight,
10585 "session already has a turn in flight; wait for it to complete before sending another prompt",
10586 ));
10587 }
10588 }
10589 require_policy(runtime_policy, false, false, requirement)
10590 .map(|()| runtime_policy)
10591 .map_err(|_| failure(
10592 NodeFailureCode::UnsupportedCapability,
10593 "session runtime policy does not admit this semantic operation",
10594 ))
10595 }
10596
10597 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10598 let mut bindings = self
10599 .session_bindings
10600 .lock()
10601 .unwrap_or_else(|poisoned| poisoned.into_inner());
10602 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10603 binding.workspace_id == address.workspace_id
10604 && binding.generation == address.session.generation
10605 }) {
10606 let removed = bindings.remove(&address.session.instance_id);
10607 self.clear_terminal_frame_watermark(address);
10608 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10609 return removed;
10610 }
10611 None
10612 }
10613
10614 fn cleanup_session_owned_materialization(
10615 &self,
10616 address: &SessionAddress,
10617 binding: &SessionBinding,
10618 ) -> Result<(), NodeFailure> {
10619 let Some(id) = binding.materialization_id.as_ref() else {
10620 return Ok(());
10621 };
10622 let owner = self
10623 .materializations
10624 .lock()
10625 .unwrap_or_else(|poisoned| poisoned.into_inner())
10626 .get(id)
10627 .map(|record| record.owner().clone());
10628 match owner {
10629 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10630 Some(MaterializationOwner::Session {
10631 incarnation_id,
10632 instance_id,
10633 generation,
10634 }) if incarnation_id == self.incarnation_id
10635 && instance_id == address.session.instance_id
10636 && generation == address.session.generation =>
10637 {
10638 self.cleanup_materialization(id)
10639 }
10640 Some(MaterializationOwner::Session { .. }) => Err(failure(
10641 NodeFailureCode::BackendOperationFailed,
10642 "session environment ownership requires recovery",
10643 )),
10644 }
10645 }
10646
10647 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10648 let mut watermarks = self
10649 .terminal_frame_watermarks
10650 .lock()
10651 .unwrap_or_else(|poisoned| poisoned.into_inner());
10652 if watermarks
10653 .get(&address.session.instance_id)
10654 .is_some_and(|(current, _)| current == address)
10655 {
10656 watermarks.remove(&address.session.instance_id);
10657 }
10658 }
10659
10660 #[cfg(test)]
10661 fn address_for(
10662 &self,
10663 instance_id: AgentInstanceId,
10664 generation: SessionGeneration,
10665 ) -> Option<SessionAddress> {
10666 let binding = self.session_bindings
10667 .lock()
10668 .unwrap_or_else(|poisoned| poisoned.into_inner())
10669 .get(&instance_id)
10670 .cloned()?;
10671 (binding.generation == generation).then_some(SessionAddress {
10672 workspace_id: binding.workspace_id,
10673 session: SessionKey { instance_id, generation },
10674 })
10675 }
10676
10677 fn arm_resume(
10678 &self,
10679 address: &SessionAddress,
10680 command_id: CommandId,
10681 runtime_policy: ProviderRuntimePolicy,
10682 ) -> Result<(), NodeFailure> {
10683 let mut bindings = self
10684 .session_bindings
10685 .lock()
10686 .unwrap_or_else(|poisoned| poisoned.into_inner());
10687 let binding = bindings
10688 .get_mut(&address.session.instance_id)
10689 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10690 if binding.workspace_id != address.workspace_id {
10691 return Err(failure(
10692 NodeFailureCode::SessionWorkspaceMismatch,
10693 "session belongs to another workspace",
10694 ));
10695 }
10696 if binding.generation != address.session.generation {
10697 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10698 }
10699 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10700 Ok(())
10701 }
10702
10703 fn clear_armed_resume(&self, address: &SessionAddress) {
10704 let mut bindings = self
10705 .session_bindings
10706 .lock()
10707 .unwrap_or_else(|poisoned| poisoned.into_inner());
10708 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10709 if binding.workspace_id == address.workspace_id
10710 && binding.generation == address.session.generation
10711 {
10712 binding.pending_resume = None;
10713 }
10714 }
10715 }
10716
10717 fn publish_control(&self, event: ControlEvent) {
10718 let revokes_harness_mcp = matches!(
10719 &event.event,
10720 ControlEventKind::Exited { .. }
10721 | ControlEventKind::Failed { .. }
10722 | ControlEventKind::Removed
10723 );
10724 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10725 let mut bindings = self
10726 .session_bindings
10727 .lock()
10728 .unwrap_or_else(|poisoned| poisoned.into_inner());
10729 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10730 return;
10731 };
10732 let previous_address = SessionAddress {
10733 workspace_id: binding.workspace_id.clone(),
10734 session: SessionKey {
10735 instance_id: event.instance_id,
10736 generation: binding.generation,
10737 },
10738 };
10739 let mut resume_command_rejected = false;
10740 if binding.generation == event.generation {
10741 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10742 let rejected_resume = matches!(
10743 &event.event,
10744 ControlEventKind::CommandRejected { .. }
10745 ) && event.command_id == Some(pending_command_id);
10746 let resume_failed = matches!(
10747 &event.event,
10748 ControlEventKind::ResumeDenied { .. }
10749 | ControlEventKind::ResumeFailed { .. }
10750 );
10751 if pending_generation == binding.generation
10752 && (rejected_resume || resume_failed)
10753 {
10754 resume_command_rejected = rejected_resume;
10755 binding.pending_resume = None;
10756 }
10757 }
10758 } else {
10759 let expected = binding.generation.0.checked_add(1);
10760 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10761 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10762 generation == binding.generation
10763 })
10764 || expected != Some(event.generation.0)
10765 || !authorized
10766 {
10767 return;
10768 }
10769 let (_, _, runtime_policy) = binding
10770 .pending_resume
10771 .expect("authorized resume retains its pending runtime policy");
10772 binding.generation = event.generation;
10773 binding.runtime_policy = runtime_policy;
10774 binding.pending_resume = None;
10775 }
10776 (
10777 SessionAddress {
10778 workspace_id: binding.workspace_id.clone(),
10779 session: SessionKey {
10780 instance_id: event.instance_id,
10781 generation: event.generation,
10782 },
10783 },
10784 (previous_address.session.generation != event.generation)
10785 .then_some(previous_address),
10786 binding.record_id.clone(),
10787 binding.runtime_policy.provider_session_identity,
10788 resume_command_rejected,
10789 )
10790 };
10791 if let Some(previous_address) = previous_address.as_ref() {
10792 self.clear_terminal_frame_watermark(previous_address);
10793 }
10794 if let Some(record_id) = record_id {
10795 self.reconcile_managed_record(
10796 &record_id,
10797 &address,
10798 &event,
10799 identity_admitted,
10800 resume_command_rejected,
10801 );
10802 }
10803 match &event.event {
10811 ControlEventKind::Running { process_id } => {
10812 #[cfg(unix)]
10813 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10814 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10815 }
10816 tracing::info!(
10817 node_id = %self.node_id,
10818 workspace_id = %address.workspace_id,
10819 session = ?address.session,
10820 pid = ?process_id,
10821 "session process is running",
10822 );
10823 }
10824 ControlEventKind::Exited { exit_code, forced } => {
10825 #[cfg(unix)]
10826 if let Some(proxy) = self.signer_proxy.get().cloned() {
10827 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10828 if proxy.has_session(&key) {
10829 std::thread::spawn(move || proxy.release(&key));
10831 }
10832 }
10833 tracing::info!(
10834 node_id = %self.node_id,
10835 workspace_id = %address.workspace_id,
10836 session = ?address.session,
10837 exit_code = ?exit_code,
10838 forced = forced,
10839 "session process exited",
10840 );
10841 }
10842 ControlEventKind::Failed { message } => {
10843 tracing::warn!(
10844 node_id = %self.node_id,
10845 workspace_id = %address.workspace_id,
10846 session = ?address.session,
10847 cause = %message,
10848 "session failed",
10849 );
10850 }
10851 ControlEventKind::CommandRejected { message } => {
10852 tracing::warn!(
10853 node_id = %self.node_id,
10854 workspace_id = %address.workspace_id,
10855 session = ?address.session,
10856 cause = %message,
10857 "session command rejected",
10858 );
10859 }
10860 ControlEventKind::Removed => {
10861 tracing::info!(
10862 node_id = %self.node_id,
10863 workspace_id = %address.workspace_id,
10864 session = ?address.session,
10865 "session removed",
10866 );
10867 }
10868 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10869 tracing::info!(
10870 node_id = %self.node_id,
10871 workspace_id = %address.workspace_id,
10872 session = ?address.session,
10873 provider_session = ?provider_session,
10874 "session resume authorized",
10875 );
10876 }
10877 ControlEventKind::Resumed { session: provider_session, process_id } => {
10878 tracing::info!(
10879 node_id = %self.node_id,
10880 workspace_id = %address.workspace_id,
10881 session = ?address.session,
10882 provider_session = ?provider_session,
10883 pid = ?process_id,
10884 "session resumed",
10885 );
10886 }
10887 ControlEventKind::ResumeDenied { reason } => {
10888 tracing::warn!(
10889 node_id = %self.node_id,
10890 workspace_id = %address.workspace_id,
10891 session = ?address.session,
10892 cause = %reason,
10893 "session resume denied",
10894 );
10895 }
10896 ControlEventKind::ResumeFailed { message } => {
10897 tracing::warn!(
10898 node_id = %self.node_id,
10899 workspace_id = %address.workspace_id,
10900 session = ?address.session,
10901 cause = %message,
10902 "session resume failed",
10903 );
10904 }
10905 _ => {}
10906 }
10907 self.apply_pending_acp_options(&address, &event);
10908 self.apply_acp_auto_permit(&address, &event);
10909 let stream_chunk = agent_stream_chunk(&event);
10910 self.publish(NodeEvent::Control {
10911 address: address.clone(),
10912 event,
10913 });
10914 if let Some(chunk) = stream_chunk {
10915 self.publish_agent_stream_chunk(address.clone(), chunk);
10916 }
10917 if revokes_harness_mcp {
10918 SessionLifecycleHook::on_session_ended(self, &address);
10919 }
10920 }
10921
10922 fn reconcile_managed_record(
10923 &self,
10924 record_id: &SessionRecordId,
10925 address: &SessionAddress,
10926 event: &ControlEvent,
10927 identity_admitted: bool,
10928 resume_command_rejected: bool,
10929 ) {
10930 let _transaction = self
10931 .state_transaction
10932 .lock()
10933 .unwrap_or_else(|poisoned| poisoned.into_inner());
10934 let observed_identity = match &event.event {
10935 ControlEventKind::ProviderEvent {
10936 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10937 ..
10938 } if identity_admitted => Some(identity.clone()),
10939 _ => None,
10940 };
10941 let identity_was_observed = observed_identity.is_some();
10942 let replacement_id = observed_identity
10943 .as_ref()
10944 .and_then(|_| self.allocate_record_id().ok());
10945 let mut upserts = Vec::new();
10946 let mut removals = Vec::new();
10947 let mut rebind = None;
10948 let original_records;
10949 let original_materializations = self
10950 .materializations
10951 .lock()
10952 .unwrap_or_else(|poisoned| poisoned.into_inner())
10953 .clone();
10954 {
10955 let mut records = self
10956 .session_records
10957 .lock()
10958 .unwrap_or_else(|poisoned| poisoned.into_inner());
10959 original_records = records.records.clone();
10960 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10961 return;
10962 };
10963 if let Some(identity) = observed_identity {
10964 let existing_id = records
10965 .records
10966 .values()
10967 .find(|record| {
10968 record.record_id != *record_id
10969 && record.provider_session.as_ref().is_some_and(|record_identity| {
10970 session_registry::same_provider_session(
10971 &record.provider,
10972 record_identity,
10973 ¤t_snapshot.provider,
10974 &identity,
10975 )
10976 })
10977 })
10978 .map(|record| record.record_id.clone());
10979 if let Some(existing_id) = existing_id {
10980 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10981 record.mode == current_snapshot.mode
10982 && record.workspace_id == current_snapshot.workspace_id
10983 && record.environment_profile == current_snapshot.environment_profile
10984 && record.bundle == current_snapshot.bundle
10985 && record.context_id == current_snapshot.context_id
10986 && record.context == current_snapshot.context
10987 && record
10988 .canonical_root
10989 .as_utf8()
10990 .zip(current_snapshot.canonical_root.as_utf8())
10991 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10992 });
10993 if !scope_matches {
10994 if let Some(current) = records.records.get_mut(record_id) {
10995 current.state = ManagedSessionState::Unavailable;
10996 current.last_error =
10997 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10998 current.updated_at_unix_ms = unix_time_ms();
10999 upserts.push(current.clone());
11000 }
11001 } else {
11002 let existing_is_active = records
11003 .records
11004 .get(&existing_id)
11005 .is_some_and(|record| record.active_session.is_some());
11006 if existing_is_active {
11007 if let Some(current) = records.records.get_mut(record_id) {
11008 current.state = ManagedSessionState::Unavailable;
11009 current.last_error =
11010 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11011 current.updated_at_unix_ms = unix_time_ms();
11012 upserts.push(current.clone());
11013 }
11014 } else {
11015 if current_snapshot.provider_session.is_some() {
11016 if let Some(current) = records.records.get_mut(record_id) {
11017 current.active_session = None;
11018 current.state = ManagedSessionState::Dormant;
11019 current.updated_at_unix_ms = unix_time_ms();
11020 upserts.push(current.clone());
11021 }
11022 } else {
11023 records.records.remove(record_id);
11024 removals.push(record_id.clone());
11025 }
11026 if let Some(existing) = records.records.get_mut(&existing_id) {
11027 existing.provider_session = Some(identity.clone());
11028 existing.active_session = Some(address.clone());
11029 existing.state = ManagedSessionState::Live;
11030 existing.updated_at_unix_ms = unix_time_ms();
11031 existing.last_error = None;
11032 upserts.push(existing.clone());
11033 }
11034 rebind = Some(existing_id);
11035 }
11036 }
11037 } else if current_snapshot
11038 .provider_session
11039 .as_ref()
11040 .is_some_and(|current| {
11041 !session_registry::same_provider_session(
11042 ¤t_snapshot.provider,
11043 current,
11044 ¤t_snapshot.provider,
11045 &identity,
11046 )
11047 })
11048 {
11049 if let Some(current) = records.records.get_mut(record_id) {
11050 current.active_session = None;
11051 if current.bundle.is_some() || current.context.is_some() {
11052 let had_bundle = current.bundle.is_some();
11053 current.provider_session = None;
11054 current.bundle = None;
11055 current.context_id = None;
11056 current.context = None;
11057 current.state = ManagedSessionState::Unavailable;
11058 current.last_error = Some(if had_bundle {
11059 "bundle-unavailable".to_owned()
11060 } else {
11061 "context-unavailable".to_owned()
11062 });
11063 } else {
11064 current.state = ManagedSessionState::Dormant;
11065 }
11066 current.updated_at_unix_ms = unix_time_ms();
11067 upserts.push(current.clone());
11068 }
11069 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11070 if let Some(current) = records.records.get_mut(record_id) {
11071 current.state = ManagedSessionState::Unavailable;
11072 current.last_error =
11073 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11074 current.updated_at_unix_ms = unix_time_ms();
11075 upserts.push(current.clone());
11076 }
11077 } else if let Some(new_record_id) = replacement_id {
11078 let now = unix_time_ms();
11079 let replacement = ManagedSessionRecord {
11080 record_id: new_record_id.clone(),
11081 display_name: format!(
11082 "{} #{}",
11083 current_snapshot.provider.as_str(),
11084 records
11085 .records
11086 .values()
11087 .filter(|record| {
11088 record.provider == current_snapshot.provider
11089 })
11090 .count()
11091 .saturating_add(1),
11092 ),
11093 provider: current_snapshot.provider.clone(),
11094 mode: current_snapshot.mode,
11095 state: ManagedSessionState::Live,
11096 workspace_id: current_snapshot.workspace_id,
11097 canonical_root: current_snapshot.canonical_root,
11098 provider_session: Some(identity),
11099 active_session: Some(address.clone()),
11100 environment_profile: current_snapshot.environment_profile,
11101 bundle: current_snapshot.bundle,
11102 context_id: current_snapshot.context_id,
11103 context: current_snapshot.context,
11104 exported_context: None,
11110 task_binding: current_snapshot.task_binding,
11111 created_at_unix_ms: now,
11112 updated_at_unix_ms: now,
11113 last_error: None,
11114 };
11115 records
11116 .records
11117 .insert(new_record_id.clone(), replacement.clone());
11118 upserts.push(replacement);
11119 rebind = Some(new_record_id);
11120 } else if let Some(current) = records.records.get_mut(record_id) {
11121 current.state = ManagedSessionState::Unavailable;
11122 current.last_error =
11123 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11124 upserts.push(current.clone());
11125 }
11126 } else if let Some(current) = records.records.get_mut(record_id) {
11127 current.provider_session = Some(identity);
11128 current.active_session = Some(address.clone());
11129 current.state = ManagedSessionState::Live;
11130 current.updated_at_unix_ms = unix_time_ms();
11131 current.last_error = None;
11132 upserts.push(current.clone());
11133 }
11134 } else if let Some(current) = records.records.get_mut(record_id) {
11135 if resume_command_rejected {
11136 current.active_session = None;
11137 current.state = detached_record_state(current);
11138 current.updated_at_unix_ms = unix_time_ms();
11139 current.last_error =
11140 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11141 upserts.push(current.clone());
11142 } else {
11143 match &event.event {
11144 ControlEventKind::Running { .. }
11145 | ControlEventKind::ResumeAuthorized { .. }
11146 | ControlEventKind::Resumed { .. } => {
11147 current.active_session = Some(address.clone());
11148 current.state = active_record_state(
11149 identity_admitted,
11150 current.provider_session.is_some(),
11151 );
11152 current.updated_at_unix_ms = unix_time_ms();
11153 current.last_error = None;
11154 upserts.push(current.clone());
11155 }
11156 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11157 current.active_session = None;
11158 current.state = detached_record_state(current);
11159 current.updated_at_unix_ms = unix_time_ms();
11160 upserts.push(current.clone());
11161 }
11162 ControlEventKind::Failed { message }
11163 | ControlEventKind::ResumeFailed { message } => {
11164 current.active_session = None;
11165 current.state = detached_record_state(current);
11166 current.updated_at_unix_ms = unix_time_ms();
11167 current.last_error = Some(
11168 session_registry::sanitized_record_error_summary(message),
11169 );
11170 upserts.push(current.clone());
11171 }
11172 ControlEventKind::ResumeDenied { reason } => {
11173 current.active_session = None;
11174 current.state = detached_record_state(current);
11175 current.updated_at_unix_ms = unix_time_ms();
11176 current.last_error = Some(
11177 session_registry::sanitized_record_error_summary(reason),
11178 );
11179 upserts.push(current.clone());
11180 }
11181 _ => {}
11182 }
11183 }
11184 }
11185 }
11186 let materialization_owner_record = identity_was_observed.then(|| {
11187 rebind.clone().unwrap_or_else(|| record_id.clone())
11188 }).and_then(|candidate| {
11189 self.session_records
11190 .lock()
11191 .unwrap_or_else(|poisoned| poisoned.into_inner())
11192 .records
11193 .get(&candidate)
11194 .filter(|record| {
11195 record.provider_session.is_some()
11196 && record.active_session.as_ref() == Some(address)
11197 })
11198 .map(|_| candidate)
11199 });
11200 if let Some(owner_record_id) = materialization_owner_record {
11201 let materialization_id = self
11202 .session_bindings
11203 .lock()
11204 .unwrap_or_else(|poisoned| poisoned.into_inner())
11205 .get(&address.session.instance_id)
11206 .and_then(|binding| binding.materialization_id.clone());
11207 if let Some(materialization_id) = materialization_id {
11208 let mut transfer_failed = false;
11209 {
11210 let mut materializations = self
11211 .materializations
11212 .lock()
11213 .unwrap_or_else(|poisoned| poisoned.into_inner());
11214 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11215 match ownership.owner() {
11216 MaterializationOwner::Session {
11217 incarnation_id,
11218 instance_id,
11219 generation,
11220 } if *incarnation_id == self.incarnation_id
11221 && *instance_id == address.session.instance_id
11222 && *generation == address.session.generation =>
11223 {
11224 transfer_failed = ownership
11225 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11226 .is_err();
11227 }
11228 MaterializationOwner::Record { record_id }
11229 if record_id == &owner_record_id => {}
11230 MaterializationOwner::Record { record_id } => {
11231 let expected = record_id.clone();
11232 transfer_failed = ownership
11233 .transfer_record_owner(
11234 &expected,
11235 owner_record_id.clone(),
11236 unix_time_ms(),
11237 )
11238 .is_err();
11239 }
11240 _ => transfer_failed = true,
11241 }
11242 if transfer_failed {
11243 let _ = ownership.mark_recovery_required(unix_time_ms());
11244 }
11245 } else {
11246 transfer_failed = true;
11247 }
11248 }
11249 if transfer_failed {
11250 if let Some(record) = self
11251 .session_records
11252 .lock()
11253 .unwrap_or_else(|poisoned| poisoned.into_inner())
11254 .records
11255 .get_mut(&owner_record_id)
11256 {
11257 record.state = ManagedSessionState::Unavailable;
11258 record.last_error = Some("environment-profile-unavailable".to_owned());
11259 record.updated_at_unix_ms = unix_time_ms();
11260 upserts.push(record.clone());
11261 }
11262 }
11263 }
11264 }
11265 if let Some(new_record_id) = rebind.clone() {
11266 if let Some(binding) = self
11267 .session_bindings
11268 .lock()
11269 .unwrap_or_else(|poisoned| poisoned.into_inner())
11270 .get_mut(&address.session.instance_id)
11271 {
11272 binding.record_id = Some(new_record_id);
11273 }
11274 }
11275 if upserts.is_empty() && removals.is_empty() {
11276 return;
11277 }
11278 if self.persist_state_locked().is_err() {
11279 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11280 let mut records = self
11281 .session_records
11282 .lock()
11283 .unwrap_or_else(|poisoned| poisoned.into_inner());
11284 records.records = original_records;
11285 *self
11286 .materializations
11287 .lock()
11288 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11289 if let Some(current) = records.records.get_mut(record_id) {
11290 current.state = ManagedSessionState::Unavailable;
11291 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11292 current.updated_at_unix_ms = unix_time_ms();
11293 upserts = vec![current.clone()];
11294 } else {
11295 upserts.clear();
11296 }
11297 removals.clear();
11298 drop(records);
11299 if let Some(binding) = self
11300 .session_bindings
11301 .lock()
11302 .unwrap_or_else(|poisoned| poisoned.into_inner())
11303 .get_mut(&address.session.instance_id)
11304 {
11305 binding.record_id = Some(record_id.clone());
11306 }
11307 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11308 }
11309 for record in upserts {
11310 self.publish_record(record);
11311 }
11312 for record_id in removals {
11313 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11314 }
11315 }
11316
11317 fn snapshot(&self) -> NodeSnapshot {
11318 let control = self.handle.snapshot();
11319 let bindings = self
11320 .session_bindings
11321 .lock()
11322 .unwrap_or_else(|poisoned| poisoned.into_inner())
11323 .clone();
11324 let workspace_roots = self
11325 .workspaces
11326 .read()
11327 .unwrap_or_else(|poisoned| poisoned.into_inner())
11328 .clone();
11329 let environment_profiles = self
11330 .environment_profiles
11331 .read()
11332 .unwrap_or_else(|poisoned| poisoned.into_inner());
11333 let mut workspaces = workspace_roots
11334 .iter()
11335 .map(|(workspace_id, canonical_root)| {
11336 (
11337 workspace_id.clone(),
11338 WorkspaceSnapshot {
11339 workspace_id: workspace_id.clone(),
11340 canonical_root: opaque_windows_path(canonical_root.clone()),
11341 sessions: Vec::new(),
11342 worktree_service_mode: Some(
11343 self.worktree_service_modes
11344 .get(workspace_id)
11345 .copied()
11346 .unwrap_or(WorktreeServiceMode::Manual),
11347 ),
11348 managed_worktree_profiles: Some(WorktreeProfileInventory {
11349 profiles: self
11350 .managed_worktree_profiles
11351 .get(workspace_id)
11352 .into_iter()
11353 .flat_map(BTreeMap::values)
11354 .map(|profile| ManagedWorktreeProfileSummary {
11355 id: profile.profile_id().clone(),
11356 revision: profile.revision().clone(),
11357 retention: profile.retention(),
11358 })
11359 .collect(),
11360 }),
11361 },
11362 )
11363 })
11364 .collect::<BTreeMap<_, _>>();
11365 let mut agent_progress = Vec::new();
11366 for session in &control.sessions {
11367 let Some(binding) = bindings.get(&session.instance_id) else {
11368 continue;
11369 };
11370 if binding.generation != session.generation {
11371 continue;
11372 }
11373 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11374 let address = SessionAddress {
11375 workspace_id: binding.workspace_id.clone(),
11376 session: SessionKey {
11377 instance_id: session.instance_id,
11378 generation: session.generation,
11379 },
11380 };
11381 if let Some(progress) =
11382 agent_progress_from_provider_snapshot(address, &session.provider)
11383 {
11384 agent_progress.push(progress);
11385 }
11386 workspace.sessions.push(session.clone());
11387 }
11388 }
11389 NodeSnapshot {
11390 node_id: self.node_id.clone(),
11391 enabled_providers: self.enabled_providers.clone(),
11392 provider_runtime_statuses: {
11393 let updates = self
11394 .provider_runtime_status_updates
11395 .read()
11396 .unwrap_or_else(|poisoned| poisoned.into_inner());
11397 ProviderRuntimeStatuses::new(
11398 self.provider_runtime_statuses
11399 .iter()
11400 .filter(|status| !updates.contains_key(status.provider()))
11401 .cloned()
11402 .chain(updates.values().cloned()),
11403 )
11404 .expect("refreshed provider runtime inventory remains bounded and unique")
11405 },
11406 workspaces: workspaces.into_values().collect(),
11407 session_records: self
11408 .session_records
11409 .lock()
11410 .unwrap_or_else(|poisoned| poisoned.into_inner())
11411 .records
11412 .values()
11413 .cloned()
11414 .collect(),
11415 managed_worktrees: self.managed_worktrees
11416 .lock()
11417 .unwrap_or_else(|poisoned| poisoned.into_inner())
11418 .snapshots(),
11419 launch_inventory: Some(LaunchInventory {
11420 spawn_profiles: Some(
11421 self.spawn_profiles
11422 .iter()
11423 .filter_map(|profile| {
11424 resolved_spawn_profile_summary(profile, &environment_profiles)
11425 })
11426 .collect(),
11427 ),
11428 bundles: Some(
11429 self.bundle_catalog
11430 .read()
11431 .unwrap_or_else(|poisoned| poisoned.into_inner())
11432 .iter()
11433 .map(NodeBundle::receipt)
11434 .collect(),
11435 ),
11436 network_allowlists: {
11439 let catalog = self
11440 .network_allowlist_catalog
11441 .read()
11442 .unwrap_or_else(|poisoned| poisoned.into_inner());
11443 if catalog.is_empty() {
11444 None
11445 } else {
11446 Some(catalog.ids().cloned().collect())
11447 }
11448 },
11449 }),
11450 agent_progress,
11451 }
11452 }
11453
11454 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11455 assert!(
11456 !matches!(
11457 &event,
11458 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11459 ),
11460 "terminal frame and agent stream events must use their own dedicated live channel",
11461 );
11462 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11463 let sequence = history
11464 .last_sequence
11465 .checked_add(1)
11466 .expect("node event sequence exhausted");
11467 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11468 if let Some(evicted) = history.events.pop_front() {
11469 history.replay_floor_sequence = evicted
11470 .sequence
11471 .checked_add(1)
11472 .expect("node replay floor sequence exhausted");
11473 history.removed_record_providers.remove(&evicted.sequence);
11474 }
11475 }
11476 match &event {
11477 NodeEvent::HarnessMcpReadCall { .. } => {
11478 panic!("transient harness MCP calls must not enter durable event history")
11479 }
11480 NodeEvent::SessionRecordUpserted { record } => {
11481 history
11482 .record_providers
11483 .insert(record.record_id.clone(), record.provider.clone());
11484 }
11485 NodeEvent::SessionRecordRemoved { record_id } => {
11486 if let Some(provider) = history.record_providers.remove(record_id) {
11487 history.removed_record_providers.insert(sequence, provider);
11488 }
11489 }
11490 NodeEvent::ManagedWorktreeUpserted { .. }
11491 | NodeEvent::ManagedWorktreeRemoved { .. }
11492 | NodeEvent::Control { .. }
11493 | NodeEvent::ControllerChanged { .. }
11494 | NodeEvent::SessionRecordHistorySummarized { .. }
11495 | NodeEvent::WorkspaceAdded { .. }
11496 | NodeEvent::WorkspaceRemoved { .. }
11497 | NodeEvent::ResyncRequired { .. }
11498 | NodeEvent::AgentStream { .. }
11499 | NodeEvent::TerminalFrame { .. } => {}
11500 }
11501 let envelope = NodeEventEnvelope { sequence, event };
11502 history.events.push_back(envelope.clone());
11503 history.last_sequence = sequence;
11504 let _ = self.event_tx.send(envelope.clone());
11505 drop(history);
11506 envelope
11507 }
11508
11509 fn publish_transient(&self, event: NodeEvent) -> bool {
11520 assert!(event.requires_harness_mcp_proxy_capability());
11521 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11522 let envelope = NodeEventEnvelope { sequence: 0, event };
11523 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11524 Ok(_) => true,
11525 Err(broadcast::error::SendError(undelivered)) => {
11526 if let Some(NodeEventEnvelope {
11527 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11528 ..
11529 }) = undelivered.first() {
11530 tracing::warn!(
11536 call_id = call_id.as_str(),
11537 content_type = ?request.content_type,
11538 cause = "no-subscribers",
11539 "harness MCP proxy event could not be published",
11540 );
11541 }
11542 false
11543 }
11544 }
11545 }
11546
11547 fn publish_terminal_frames(&self) {
11548 let snapshot = self.handle.snapshot();
11549 let bindings = self
11550 .session_bindings
11551 .lock()
11552 .unwrap_or_else(|poisoned| poisoned.into_inner());
11553 let candidates = snapshot
11554 .sessions
11555 .iter()
11556 .filter_map(|session| {
11557 let binding = bindings.get(&session.instance_id)?;
11558 if binding.generation != session.generation {
11559 return None;
11560 }
11561 let frame = session.terminal_frame.clone()?;
11562 crate::session_signals::feed(
11563 binding.workspace_id.as_str(),
11564 session.instance_id.0,
11565 session.agent_id.as_str(),
11566 &frame.contents,
11567 );
11568 crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11569 Some((
11570 SessionAddress {
11571 workspace_id: binding.workspace_id.clone(),
11572 session: SessionKey {
11573 instance_id: session.instance_id,
11574 generation: session.generation,
11575 },
11576 },
11577 frame,
11578 ))
11579 })
11580 .collect();
11581 drop(bindings);
11582 self.publish_terminal_frame_candidates(candidates);
11583 }
11584
11585 fn publish_terminal_frame_candidates(
11586 &self,
11587 candidates: Vec<(SessionAddress, TerminalFrame)>,
11588 ) {
11589 let mut watermarks = self
11590 .terminal_frame_watermarks
11591 .lock()
11592 .unwrap_or_else(|poisoned| poisoned.into_inner());
11593 let mut events = Vec::with_capacity(candidates.len());
11594 for (address, frame) in candidates {
11595 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11596 let advanced = watermarks
11597 .get(&address.session.instance_id)
11598 .map_or(true, |(current_address, sequence)| {
11599 current_address != &address || frame.sequence > *sequence
11600 });
11601 if !advanced {
11602 continue;
11603 }
11604 if is_first_frame {
11605 if self.controller_state().is_none() {
11619 tracing::warn!(
11620 node_id = %self.node_id,
11621 workspace_id = %address.workspace_id,
11622 session = ?address.session,
11623 terminal_size = ?frame.size,
11624 "session producing output with no controller attached",
11625 );
11626 } else {
11627 tracing::info!(
11628 node_id = %self.node_id,
11629 workspace_id = %address.workspace_id,
11630 session = ?address.session,
11631 terminal_size = ?frame.size,
11632 "session produced its first output",
11633 );
11634 }
11635 }
11636 watermarks.insert(
11637 address.session.instance_id,
11638 (address.clone(), frame.sequence),
11639 );
11640 if let Some((at, reported)) = self
11641 .pty_input_marks
11642 .lock()
11643 .unwrap_or_else(|poisoned| poisoned.into_inner())
11644 .get_mut(&address.session.instance_id)
11645 {
11646 if !*reported && at.elapsed() < Duration::from_secs(5) {
11648 *reported = true;
11649 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");
11650 }
11651 }
11652 events.push(NodeEvent::TerminalFrame { address, frame });
11653 }
11654 drop(watermarks);
11655 if events.is_empty() {
11656 return;
11657 }
11658 let mut history = self
11659 .history
11660 .lock()
11661 .unwrap_or_else(|poisoned| poisoned.into_inner());
11662 let mut envelopes = Vec::with_capacity(events.len());
11663 for event in events {
11664 let sequence = history
11665 .last_sequence
11666 .checked_add(1)
11667 .expect("node event sequence exhausted");
11668 history.last_sequence = sequence;
11669 envelopes.push(NodeEventEnvelope { sequence, event });
11670 }
11671 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11672 drop(history);
11673 }
11674
11675 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11684 let mut history = self
11685 .history
11686 .lock()
11687 .unwrap_or_else(|poisoned| poisoned.into_inner());
11688 let sequence = history
11689 .last_sequence
11690 .checked_add(1)
11691 .expect("node event sequence exhausted");
11692 history.last_sequence = sequence;
11693 drop(history);
11694 tracing::debug!(
11702 source_sequence = chunk.source_sequence,
11703 kind = agent_stream_chunk_kind_label(&chunk.kind),
11704 envelope_sequence = sequence,
11705 "publishing agent stream chunk",
11706 );
11707 let envelope = NodeEventEnvelope {
11708 sequence,
11709 event: NodeEvent::AgentStream { address, chunk },
11710 };
11711 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11712 }
11713
11714 fn current_sequence(&self) -> u64 {
11715 self.history
11716 .lock()
11717 .unwrap_or_else(|poisoned| poisoned.into_inner())
11718 .last_sequence
11719 }
11720
11721 fn controller_state(&self) -> Option<ControllerState> {
11722 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11723 let lease = controller.as_ref().copied()?;
11724 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11725 if remaining.is_zero() {
11726 *controller = None;
11727 return None;
11728 }
11729 Some(ControllerState {
11730 connection_id: lease.connection_id,
11731 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11732 })
11733 }
11734
11735 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11736 if role != ClientRole::Operator {
11737 tracing::warn!(
11738 node_id = %self.node_id,
11739 connection_id,
11740 "controller lease rejected: observer connections cannot acquire control",
11741 );
11742 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11743 }
11744 let lease_ms = if lease_ms == 0 {
11745 DEFAULT_CONTROLLER_LEASE_MS
11746 } else {
11747 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11748 };
11749 let now = Instant::now();
11750 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11751 if let Some(active) = controller.as_ref() {
11752 if active.connection_id != connection_id && active.expires_at > now {
11753 tracing::warn!(
11754 node_id = %self.node_id,
11755 connection_id,
11756 holder_connection_id = active.connection_id,
11757 "controller lease rejected: another operator holds it",
11758 );
11759 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11760 }
11761 }
11762 let is_new_attach = match controller.as_ref() {
11769 Some(active) => active.connection_id != connection_id,
11770 None => true,
11771 };
11772 *controller = Some(ControllerLease {
11773 connection_id,
11774 expires_at: now + Duration::from_millis(lease_ms),
11775 });
11776 drop(controller);
11777 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11778 if is_new_attach {
11779 tracing::info!(
11780 node_id = %self.node_id,
11781 connection_id,
11782 lease_ms,
11783 "controller lease acquired",
11784 );
11785 } else {
11786 tracing::debug!(
11787 node_id = %self.node_id,
11788 connection_id,
11789 lease_ms,
11790 "controller lease renewed",
11791 );
11792 }
11793 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11794 Ok(state)
11795 }
11796
11797 fn release_controller(&self, connection_id: u64) -> bool {
11798 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11799 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11800 if released {
11801 *controller = None;
11802 }
11803 drop(controller);
11804 if released {
11805 tracing::info!(
11806 node_id = %self.node_id,
11807 connection_id,
11808 "controller lease released",
11809 );
11810 self.publish(NodeEvent::ControllerChanged { controller: None });
11811 }
11812 released
11813 }
11814
11815 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11824 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11825 return;
11826 };
11827 let id = address.session.instance_id;
11828 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11829 return;
11830 };
11831 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11832 .iter()
11833 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11834 .collect();
11835 let (writes, set_model, effort_gap) =
11836 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11837 if set_model {
11838 if let Some(model_id) = model.clone() {
11839 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11840 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11841 }
11842 }
11843 }
11844 for (option_id, value_json) in writes {
11845 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11846 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11847 }
11848 }
11849 if effort_gap {
11850 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11851 }
11852 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11853 }
11854
11855 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11858 let id = address.session.instance_id;
11859 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11860 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11861 crate::auto_permit::forget_acp(id.0);
11862 return;
11863 }
11864 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11865 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11866 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11867 }
11868 match provider_event {
11869 ProviderEvent::ModeChanged { mode_id, available } => {
11870 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11871 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11872 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11873 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11874 }
11875 }
11876 }
11877 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11878 match self.dispatch(ControlCommand::ResolveInteraction {
11879 instance_id: id,
11880 generation: address.session.generation,
11881 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11882 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11883 }) {
11884 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11885 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11886 }
11887 }
11888 _ => {}
11891 }
11892 }
11893
11894 fn mark_spawn_provisional(&self, address: SessionAddress) {
11895 let id = address.session.instance_id;
11896 self.provisional_spawns
11897 .lock()
11898 .unwrap_or_else(|poisoned| poisoned.into_inner())
11899 .insert(id, (address, Instant::now()));
11900 tracing::info!(
11901 node_id = %self.node_id,
11902 instance_id = id.0,
11903 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11904 "draft spawn; waiting for HQ view",
11905 );
11906 }
11907
11908 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11909 let removed = self
11910 .provisional_spawns
11911 .lock()
11912 .unwrap_or_else(|poisoned| poisoned.into_inner())
11913 .remove(&instance_id);
11914 if removed.is_some() {
11915 tracing::info!(
11916 node_id = %self.node_id,
11917 instance_id = instance_id.0,
11918 "draft spawn claimed by HQ view",
11919 );
11920 }
11921 }
11922
11923 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11924 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11925 let mut map = self
11926 .provisional_spawns
11927 .lock()
11928 .unwrap_or_else(|poisoned| poisoned.into_inner());
11929 let due: Vec<AgentInstanceId> = map
11930 .iter()
11931 .filter(|(_, (_, at))| at.elapsed() >= grace)
11932 .map(|(id, _)| *id)
11933 .collect();
11934 due.into_iter()
11935 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11936 .collect()
11937 }
11938
11939 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11940 let instance_id = address.session.instance_id;
11941 tracing::warn!(
11942 node_id = %self.node_id,
11943 instance_id = instance_id.0,
11944 workspace = %address.workspace_id,
11945 reason = "attach_never_arrived",
11946 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11947 "draft spawn never viewed; stopping PTY",
11948 );
11949 let _ = self.dispatch(ControlCommand::Stop {
11950 instance_id,
11951 force: true,
11952 });
11953 let _ = self.remove_session(&address).await;
11954 }
11955
11956 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11957 if role != ClientRole::Operator {
11958 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11959 }
11960 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11961 let now = Instant::now();
11962 match controller.as_ref().copied() {
11963 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11964 Some(active) if active.expires_at <= now => {
11965 *controller = None;
11966 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11967 }
11968 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11969 }
11970 }
11971
11972 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11973 if !self
11974 .workspaces
11975 .read()
11976 .unwrap_or_else(|poisoned| poisoned.into_inner())
11977 .contains_key(&address.workspace_id)
11978 {
11979 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11980 }
11981 let binding = self
11982 .session_bindings
11983 .lock()
11984 .unwrap_or_else(|poisoned| poisoned.into_inner())
11985 .get(&address.session.instance_id)
11986 .cloned()
11987 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11988 if binding.workspace_id != address.workspace_id {
11989 return Err(failure(
11990 NodeFailureCode::SessionWorkspaceMismatch,
11991 "session belongs to another workspace",
11992 ));
11993 }
11994 if binding.generation != address.session.generation {
11995 return Err(failure(
11996 NodeFailureCode::StaleGeneration,
11997 &format!(
11998 "stale session generation: expected {}, received {}",
11999 binding.generation.0,
12000 address.session.generation.0,
12001 ),
12002 ));
12003 }
12004 let snapshot = self.handle.snapshot();
12005 let Some(current) = snapshot
12006 .sessions
12007 .iter()
12008 .find(|item| item.instance_id == address.session.instance_id)
12009 else {
12010 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12011 };
12012 if current.generation != address.session.generation {
12013 return Err(failure(
12014 NodeFailureCode::StaleGeneration,
12015 &format!(
12016 "stale session generation: expected {}, received {}",
12017 current.generation.0,
12018 address.session.generation.0,
12019 ),
12020 ));
12021 }
12022 Ok(current.agent_id.clone())
12023 }
12024
12025 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12026 self.dispatch_envelope(self.prepare_command(command))
12027 }
12028
12029 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12030 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12031 CommandEnvelope {
12032 id: CommandId(id),
12033 command,
12034 }
12035 }
12036
12037 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12038 let command_id = envelope.id;
12039 self.handle
12040 .dispatch(envelope)
12041 .map_err(|error| match error {
12042 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12043 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12044 })?;
12045 Ok(command_id)
12046 }
12047
12048 fn record_rejected_command(
12057 &self,
12058 command_id: CommandId,
12059 message: String,
12060 unsupported_transport: bool,
12061 ) {
12062 let now = Instant::now();
12063 let mut reasons = self.rejected_command_reasons.lock()
12064 .unwrap_or_else(|poisoned| poisoned.into_inner());
12065 reasons.retain(|_, reason| reason.expires_at > now);
12066 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12067 if let Some(&oldest) = reasons.keys().next() {
12068 reasons.remove(&oldest);
12069 }
12070 }
12071 reasons.insert(
12072 command_id,
12073 RejectedCommandReason {
12074 message,
12075 unsupported_transport,
12076 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12077 },
12078 );
12079 }
12080
12081 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12088 let now = Instant::now();
12089 let mut reasons = self.rejected_command_reasons.lock()
12090 .unwrap_or_else(|poisoned| poisoned.into_inner());
12091 match reasons.remove(&command_id) {
12092 Some(reason) if reason.expires_at > now => Some(reason),
12093 _ => None,
12094 }
12095 }
12096
12097 async fn dispatch_bounded(
12098 &self,
12099 command: ControlCommand,
12100 timeout_duration: Duration,
12101 ) -> Result<CommandId, NodeFailure> {
12102 let deadline = Instant::now() + timeout_duration;
12103 loop {
12104 match self.dispatch(command.clone()) {
12105 Ok(command_id) => return Ok(command_id),
12106 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12107 sleep(Duration::from_millis(2)).await;
12108 }
12109 Err(error) => return Err(error),
12110 }
12111 }
12112 }
12113
12114 fn internal_session_snapshot(
12115 &self,
12116 address: &SessionAddress,
12117 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12118 self.validate_address(address)?;
12119 self.handle
12120 .snapshot()
12121 .sessions
12122 .clone()
12123 .into_iter()
12124 .find(|session| {
12125 session.instance_id == address.session.instance_id
12126 && session.generation == address.session.generation
12127 })
12128 .ok_or_else(|| {
12129 failure(
12130 NodeFailureCode::UnknownSession,
12131 "session snapshot is unavailable",
12132 )
12133 })
12134 }
12135
12136 fn require_acp_session(
12144 &self,
12145 address: &SessionAddress,
12146 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12147 let snapshot = self.internal_session_snapshot(address)?;
12148 if snapshot.transport != TransportKind::Acp {
12149 return Err(failure(
12150 NodeFailureCode::UnsupportedTransport,
12151 "this verb requires a session spawned over the ACP transport",
12152 ));
12153 }
12154 Ok(snapshot)
12155 }
12156
12157 async fn dispatch_history_bounded(
12158 &self,
12159 address: &SessionAddress,
12160 command: ControlCommand,
12161 operation: HistoryOperation,
12162 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12163 self.validate_address(address)?;
12164 let started_after = self.current_sequence();
12165 let command_id = self
12166 .dispatch_bounded(
12167 command,
12168 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12169 )
12170 .await?;
12171 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12172 let mut scan_after = started_after;
12173 let mut accepted_after = None;
12174 loop {
12175 let events = self
12176 .history
12177 .lock()
12178 .unwrap_or_else(|poisoned| poisoned.into_inner())
12179 .events
12180 .iter()
12181 .filter(|envelope| envelope.sequence > scan_after)
12182 .cloned()
12183 .collect::<Vec<_>>();
12184 for envelope in events {
12185 scan_after = scan_after.max(envelope.sequence);
12186 let NodeEvent::Control {
12187 address: event_address,
12188 event,
12189 } = envelope.event
12190 else {
12191 continue;
12192 };
12193 if event_address != *address {
12194 continue;
12195 }
12196 if event.command_id == Some(command_id) {
12197 match &event.event {
12198 ControlEventKind::CommandRejected { .. } => {
12199 return Err(failure(
12200 NodeFailureCode::InvalidRequest,
12201 "history request was rejected",
12202 ));
12203 }
12204 ControlEventKind::HistoryRequested {
12205 operation: requested,
12206 ..
12207 } if requested == &operation => {
12208 accepted_after = Some(envelope.sequence);
12209 continue;
12210 }
12211 _ => {}
12212 }
12213 }
12214 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12215 match (&operation, &event.event) {
12216 (
12217 HistoryOperation::Discover { .. },
12218 ControlEventKind::HistoryDiscovered { .. },
12219 )
12220 | (
12221 HistoryOperation::Load { .. },
12222 ControlEventKind::HistoryLoaded { .. },
12223 ) => return self.internal_session_snapshot(address),
12224 (_, ControlEventKind::HistoryFailed { .. }) => {
12225 return Err(failure(
12226 NodeFailureCode::BackendOperationFailed,
12227 "history operation failed",
12228 ));
12229 }
12230 _ => {}
12231 }
12232 }
12233 }
12234 if Instant::now() >= deadline {
12235 return Err(failure(
12236 NodeFailureCode::BackendBusy,
12237 if accepted_after.is_some() {
12238 "history operation did not settle before the bounded deadline"
12239 } else {
12240 "history operation was not accepted before the bounded deadline"
12241 },
12242 ));
12243 }
12244 sleep(Duration::from_millis(2)).await;
12245 }
12246 }
12247
12248 async fn discover_history(
12249 &self,
12250 address: &SessionAddress,
12251 limit: u16,
12252 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12253 let query = HistoryQuery {
12254 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12255 limit,
12256 };
12257 query
12258 .validate()
12259 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12260 let snapshot = self
12261 .dispatch_history_bounded(
12262 address,
12263 ControlCommand::DiscoverHistory {
12264 instance_id: address.session.instance_id,
12265 query: query.clone(),
12266 },
12267 HistoryOperation::Discover { query },
12268 )
12269 .await?;
12270 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12271 return Err(failure(
12272 NodeFailureCode::BackendOperationFailed,
12273 "history discovery settled into an invalid state",
12274 ));
12275 }
12276 Ok(snapshot.history.candidates)
12277 }
12278
12279 async fn load_history(
12280 &self,
12281 address: &SessionAddress,
12282 candidate_id: String,
12283 ) -> Result<HistorySessionRecord, NodeFailure> {
12284 validate_candidate_id(&candidate_id)
12285 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12286 let snapshot = self
12287 .dispatch_history_bounded(
12288 address,
12289 ControlCommand::LoadHistory {
12290 instance_id: address.session.instance_id,
12291 candidate_id: candidate_id.clone(),
12292 },
12293 HistoryOperation::Load {
12294 candidate_id: candidate_id.clone(),
12295 },
12296 )
12297 .await?;
12298 if snapshot.history.pending.is_some()
12299 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12300 {
12301 return Err(failure(
12302 NodeFailureCode::BackendOperationFailed,
12303 "history load settled into an invalid state",
12304 ));
12305 }
12306 snapshot.history.loaded.ok_or_else(|| {
12307 failure(
12308 NodeFailureCode::BackendOperationFailed,
12309 "history load produced no bounded session",
12310 )
12311 })
12312 }
12313
12314 async fn export_context_pack_for_session_record(
12321 &self,
12322 record_id: &SessionRecordId,
12323 session: &SessionAddress,
12324 allow_clean_detachment: bool,
12325 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12326 let outcome = self
12332 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12333 .await;
12334 if let Err(failure) = &outcome {
12335 tracing::warn!(
12336 record = %record_id,
12337 code = ?failure.code,
12338 reason = %failure.message,
12339 "session-record context-pack export refused",
12340 );
12341 }
12342 outcome
12343 }
12344
12345 async fn export_context_pack_for_session_record_inner(
12346 &self,
12347 record_id: &SessionRecordId,
12348 session: &SessionAddress,
12349 allow_clean_detachment: bool,
12350 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12351 let step = |name: &'static str| move |failure: NodeFailure| {
12356 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12357 failure
12358 };
12359 let record = self.record(record_id).map_err(step("record-lookup"))?;
12360 let identity = match record.provider_session.clone() {
12376 Some(identity) => {
12377 if identity.key != ProviderSessionKey::SessionId {
12378 return Err(step("provider-identity-key")(failure(
12379 NodeFailureCode::BackendOperationFailed,
12380 "managed session provider identity cannot bind history exactly",
12381 )));
12382 }
12383 Some(identity)
12384 }
12385 None if record.state == ManagedSessionState::IdentityPending => {
12386 return Err(step("provider-session-identity")(failure(
12387 NodeFailureCode::BackendOperationFailed,
12388 "managed session has not yet observed its provider session identity",
12389 )));
12390 }
12391 None => None,
12392 };
12393 self.revalidate_session_record_context_export(
12394 &record,
12395 identity.as_ref(),
12396 session,
12397 allow_clean_detachment,
12398 ).map_err(step("revalidate-before-discovery"))?;
12399 let candidates = self
12400 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12401 .await
12402 .map_err(step("discover-history"))?;
12403 let candidate_id = select_history_candidate(
12408 &candidates,
12409 identity.as_ref().map(|identity| identity.id.as_str()),
12410 ).map_err(step("unique-candidate"))?;
12411 let loaded = self.load_history(session, candidate_id.clone()).await
12412 .map_err(step("load-history"))?;
12413 if let Some(identity) = identity.as_ref() {
12414 if loaded.session_id != identity.id {
12415 return Err(failure(
12416 NodeFailureCode::SessionRecordConflict,
12417 "loaded history identity does not match the managed session record",
12418 ));
12419 }
12420 }
12421 self.revalidate_session_record_context_export(
12422 &record,
12423 identity.as_ref(),
12424 session,
12425 allow_clean_detachment,
12426 )?;
12427 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12428 .map_err(step("revalidate-loaded-history"))?;
12429 let pack = self.materialize_context_pack(session).await
12430 .map_err(step("materialize-context-pack"))?;
12431 self.commit_context_pack_for_session_record(
12432 &record,
12433 identity.as_ref(),
12434 session,
12435 &candidate_id,
12436 &loaded,
12437 pack,
12438 allow_clean_detachment,
12439 )
12440 }
12441
12442 async fn export_context_pack(
12443 &self,
12444 address: &SessionAddress,
12445 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12446 let pack = self.materialize_context_pack(address).await?;
12447 self.commit_context_pack(pack)
12448 }
12449
12450 async fn materialize_context_pack(
12451 &self,
12452 address: &SessionAddress,
12453 ) -> Result<NodeContextPack, NodeFailure> {
12454 let snapshot = self.internal_session_snapshot(address)?;
12455 if !context_pack_source_status_is_usable(&snapshot.status) {
12456 return Err(failure(
12457 NodeFailureCode::InvalidRequest,
12458 "context export requires a running or successfully exited source session",
12459 ));
12460 }
12461 if snapshot.history.pending.is_some()
12462 || snapshot.history.loaded_candidate_id.is_none()
12463 {
12464 return Err(failure(
12465 NodeFailureCode::InvalidRequest,
12466 "context export requires settled loaded history",
12467 ));
12468 }
12469 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12470 failure(
12471 NodeFailureCode::InvalidRequest,
12472 "context export requires settled loaded history",
12473 )
12474 })?;
12475 let source_provider = snapshot.agent_id.clone();
12476 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12477 let repository = self
12478 .context_pack_repository(&address.workspace_id)
12479 .await?;
12480 let current = self.internal_session_snapshot(address)?;
12481 if !context_pack_source_status_is_usable(¤t.status)
12482 || current.agent_id != source_provider
12483 || current.history.pending.is_some()
12484 || current.history.loaded_candidate_id != loaded_candidate_id
12485 || current.history.loaded.as_ref() != Some(&loaded)
12486 {
12487 return Err(failure(
12488 NodeFailureCode::ContextPackMaterializationFailed,
12489 "context source changed during bounded repository capture",
12490 ));
12491 }
12492 NodeContextPack::export_with_repository(
12493 ContextPackLineageReceipt {
12494 source_node_id: self.node_id.clone(),
12495 source_session: address.clone(),
12496 source_provider,
12497 },
12498 &loaded,
12499 Some(repository),
12500 )
12501 .map_err(|_| {
12502 failure(
12503 NodeFailureCode::ContextPackMaterializationFailed,
12504 "bounded context pack export failed",
12505 )
12506 })
12507 }
12508
12509 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12515 let mut store = self
12516 .context_pack_store
12517 .lock()
12518 .unwrap_or_else(|poisoned| poisoned.into_inner());
12519 let Some(store) = store.as_mut() else {
12520 return Ok(());
12521 };
12522 store.commit(pack).map_err(|_| {
12523 failure(
12524 NodeFailureCode::ContextPackMaterializationFailed,
12525 "durable context pack commit failed",
12526 )
12527 })
12528 }
12529
12530 fn commit_context_pack(
12531 &self,
12532 pack: NodeContextPack,
12533 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12534 self.commit_context_pack_durably(&pack)?;
12535 self.context_catalog
12536 .write()
12537 .unwrap_or_else(|poisoned| poisoned.into_inner())
12538 .insert(pack)
12539 .map_err(|_| {
12540 failure(
12541 NodeFailureCode::ContextPackMaterializationFailed,
12542 "context pack catalog is full",
12543 )
12544 })
12545 }
12546
12547 fn commit_context_pack_for_session_record(
12548 &self,
12549 expected: &ManagedSessionRecord,
12550 identity: Option<&ProviderSessionIdentity>,
12551 session: &SessionAddress,
12552 candidate_id: &str,
12553 loaded: &HistorySessionRecord,
12554 pack: NodeContextPack,
12555 allow_clean_detachment: bool,
12556 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12557 let current_root = self.workspace_root(&expected.workspace_id)?;
12558 let _transaction = self
12559 .state_transaction
12560 .lock()
12561 .unwrap_or_else(|poisoned| poisoned.into_inner());
12562 let records = self
12563 .session_records
12564 .lock()
12565 .unwrap_or_else(|poisoned| poisoned.into_inner());
12566 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12567 failure(
12568 NodeFailureCode::SessionRecordConflict,
12569 "managed session record disappeared during context export",
12570 )
12571 })?;
12572 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12573 return Err(failure(
12574 NodeFailureCode::SessionRecordConflict,
12575 "managed session record changed during context export",
12576 ));
12577 }
12578 let bindings = self
12579 .session_bindings
12580 .lock()
12581 .unwrap_or_else(|poisoned| poisoned.into_inner());
12582 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12583 failure(
12584 NodeFailureCode::SessionRecordConflict,
12585 "managed session runtime binding disappeared during context export",
12586 )
12587 })?;
12588 let runtime = self
12589 .handle
12590 .snapshot()
12591 .sessions
12592 .iter()
12593 .find(|snapshot| {
12594 snapshot.instance_id == session.session.instance_id
12595 && snapshot.generation == session.session.generation
12596 })
12597 .cloned()
12598 .ok_or_else(|| {
12599 failure(
12600 NodeFailureCode::SessionRecordConflict,
12601 "managed session runtime disappeared during context export",
12602 )
12603 })?;
12604 let receipt = pack.receipt();
12605 if !session_record_context_export_binding_is_exact(
12606 expected,
12607 current,
12608 identity,
12609 session,
12610 binding,
12611 &runtime.agent_id,
12612 ¤t_root,
12613 allow_clean_detachment,
12614 ) || !session_record_context_export_source_is_usable(
12615 current,
12616 &runtime.status,
12617 allow_clean_detachment,
12618 ) || runtime.history.pending.is_some()
12619 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12620 || runtime.history.loaded.as_ref() != Some(loaded)
12621 || receipt.lineage.source_node_id != self.node_id
12622 || receipt.lineage.source_session != *session
12623 || receipt.lineage.source_provider != current.provider
12624 || receipt.source_message_count != loaded.message_count
12625 {
12626 return Err(failure(
12627 NodeFailureCode::SessionRecordConflict,
12628 "managed session source changed before context catalog commit",
12629 ));
12630 }
12631 self.commit_context_pack_durably(&pack)?;
12632 self.context_catalog
12633 .write()
12634 .unwrap_or_else(|poisoned| poisoned.into_inner())
12635 .insert(pack)
12636 .map_err(|_| {
12637 failure(
12638 NodeFailureCode::ContextPackMaterializationFailed,
12639 "context pack catalog is full",
12640 )
12641 })
12642 }
12643
12644 async fn context_pack_repository(
12645 &self,
12646 workspace_id: &WorkspaceId,
12647 ) -> Result<ContextPackRepository, NodeFailure> {
12648 let first = self
12649 .context_pack_repository_observation(workspace_id)
12650 .await?;
12651 let second = self
12652 .context_pack_repository_observation(workspace_id)
12653 .await?;
12654 stable_context_pack_repository(first, second)
12655 }
12656
12657 async fn context_pack_repository_observation(
12658 &self,
12659 workspace_id: &WorkspaceId,
12660 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12661 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12662 let head = if git.is_repository {
12663 let root = self.workspace_root(workspace_id)?;
12664 match resolve_base_commit_with_timeout(
12665 &root,
12666 "HEAD",
12667 GIT_COMMAND_TIMEOUT_MS,
12668 )
12669 .await
12670 {
12671 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12672 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12673 ContextPackRepositoryHead::Unborn
12674 }
12675 Err(_) => {
12676 return Err(failure(
12677 NodeFailureCode::ContextPackMaterializationFailed,
12678 "context pack HEAD observation failed",
12679 ));
12680 }
12681 }
12682 } else {
12683 ContextPackRepositoryHead::NotRepository
12684 };
12685 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12686 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12687 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12688 failure(
12689 NodeFailureCode::InvalidRepositoryPath,
12690 "context pack selected file path is invalid",
12691 )
12692 })?;
12693 match self.read_workspace_file(workspace_id.clone(), path).await {
12694 Ok(WorkspaceFileRead {
12695 content: WorkspaceFileContent::Utf8 { text, byte_len },
12696 ..
12697 }) => files.push(ContextPackRepositoryFileSource::utf8(
12698 selected_path,
12699 text,
12700 byte_len,
12701 )),
12702 Ok(WorkspaceFileRead {
12703 content: WorkspaceFileContent::NonUtf8 { byte_len },
12704 ..
12705 }) => files.push(ContextPackRepositoryFileSource::skipped(
12706 selected_path,
12707 ContextPackSelectedFileSkipReason::NonUtf8,
12708 Some(byte_len),
12709 )),
12710 Ok(WorkspaceFileRead {
12711 content: WorkspaceFileContent::TooLarge { .. },
12712 ..
12713 }) => files.push(ContextPackRepositoryFileSource::skipped(
12714 selected_path,
12715 ContextPackSelectedFileSkipReason::TooLarge,
12716 None,
12717 )),
12718 Err(error) => {
12719 let reason = if matches!(
12720 error.code,
12721 NodeFailureCode::InvalidRepositoryPath
12722 | NodeFailureCode::RepositoryFileNotRegular
12723 | NodeFailureCode::RepositoryPathUnsafe
12724 ) {
12725 ContextPackSelectedFileSkipReason::Unsafe
12726 } else {
12727 ContextPackSelectedFileSkipReason::Unavailable
12728 };
12729 files.push(ContextPackRepositoryFileSource::skipped(
12730 selected_path,
12731 reason,
12732 None,
12733 ));
12734 }
12735 }
12736 }
12737 Ok(ContextPackRepositoryObservation { head, git, files })
12738 }
12739
12740 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12741 let referenced_by_binding = self
12742 .session_bindings
12743 .lock()
12744 .unwrap_or_else(|poisoned| poisoned.into_inner())
12745 .values()
12746 .any(|binding| {
12747 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12748 });
12749 let referenced_by_record = self
12750 .session_records
12751 .lock()
12752 .unwrap_or_else(|poisoned| poisoned.into_inner())
12753 .records
12754 .values()
12755 .any(|record| record.context_id.as_ref() == Some(context_id));
12756 let referenced_by_materialization = self
12757 .materializations
12758 .lock()
12759 .unwrap_or_else(|poisoned| poisoned.into_inner())
12760 .values()
12761 .any(|ownership| {
12762 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12763 });
12764 if referenced_by_binding
12765 || referenced_by_record
12766 || referenced_by_materialization
12767 {
12768 return Err(failure(
12769 NodeFailureCode::ContextPackBusy,
12770 "context pack is still referenced",
12771 ));
12772 }
12773 let mut catalog = self.context_catalog
12774 .write()
12775 .unwrap_or_else(|poisoned| poisoned.into_inner());
12776 if catalog.get(context_id).is_none() {
12777 return Err(failure(
12778 NodeFailureCode::UnknownContextPack,
12779 "context pack is unavailable",
12780 ));
12781 }
12782 self.spawn_idempotency
12783 .lock()
12784 .unwrap_or_else(|poisoned| poisoned.into_inner())
12785 .entries
12786 .retain(|_, entry| !entry.value.references_context(context_id));
12787 let removed = catalog.remove(context_id);
12788 debug_assert!(removed.is_some(), "validated context pack remains present");
12789 Ok(())
12790 }
12791
12792 async fn dispatch_input_bounded(
12793 &self,
12794 address: &SessionAddress,
12795 action: InputAction,
12796 ) -> Result<(), NodeFailure> {
12797 let started_after = self.current_sequence();
12798 self.pty_input_marks
12799 .lock()
12800 .unwrap_or_else(|poisoned| poisoned.into_inner())
12801 .insert(address.session.instance_id, (Instant::now(), false));
12802 self.pty_input_wake.notify_one();
12803 let command_id = self
12804 .dispatch_bounded(
12805 ControlCommand::SendInput {
12806 instance_id: address.session.instance_id,
12807 action,
12808 },
12809 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12810 )
12811 .await?;
12812 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12816 let mut scan_after = started_after;
12817 let mut accepted_after = None;
12818 loop {
12819 let events = self
12820 .history
12821 .lock()
12822 .unwrap_or_else(|poisoned| poisoned.into_inner())
12823 .events
12824 .iter()
12825 .filter(|envelope| envelope.sequence > scan_after)
12826 .cloned()
12827 .collect::<Vec<_>>();
12828 for envelope in events {
12829 scan_after = scan_after.max(envelope.sequence);
12830 let NodeEvent::Control {
12831 address: event_address,
12832 event,
12833 } = envelope.event
12834 else {
12835 continue;
12836 };
12837 if event_address != *address {
12838 continue;
12839 }
12840 if event.command_id == Some(command_id) {
12841 match &event.event {
12842 ControlEventKind::CommandRejected { message } => {
12843 return Err(failure(NodeFailureCode::InvalidRequest, message));
12844 }
12845 ControlEventKind::InputRequested { .. } => {
12846 accepted_after = Some(envelope.sequence);
12847 continue;
12848 }
12849 _ => {}
12850 }
12851 }
12852 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12853 match &event.event {
12854 ControlEventKind::InputCompleted { .. } => return Ok(()),
12855 ControlEventKind::InputFailed { message, .. } => {
12856 return Err(failure(
12857 NodeFailureCode::BackendOperationFailed,
12858 message,
12859 ));
12860 }
12861 _ => {}
12862 }
12863 }
12864 }
12865 if Instant::now() >= deadline {
12866 return Err(failure(
12867 NodeFailureCode::BackendBusy,
12868 if accepted_after.is_some() {
12869 "input was accepted but did not settle before the bounded deadline"
12870 } else {
12871 "input was not accepted before the bounded deadline"
12872 },
12873 ));
12874 }
12875 sleep(Duration::from_millis(2)).await;
12876 }
12877 }
12878
12879 async fn dispatch_resize_bounded(
12880 &self,
12881 address: &SessionAddress,
12882 size: gate4agent_types::TerminalSize,
12883 ) -> Result<(), NodeFailure> {
12884 let started_after = self.current_sequence();
12885 let command_id = self
12886 .dispatch_bounded(
12887 ControlCommand::Resize {
12888 instance_id: address.session.instance_id,
12889 size,
12890 },
12891 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12892 )
12893 .await?;
12894 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12895 let mut scan_after = started_after;
12896 let mut accepted_after = None;
12897 loop {
12898 let events = self
12899 .history
12900 .lock()
12901 .unwrap_or_else(|poisoned| poisoned.into_inner())
12902 .events
12903 .iter()
12904 .filter(|envelope| envelope.sequence > scan_after)
12905 .cloned()
12906 .collect::<Vec<_>>();
12907 for envelope in events {
12908 scan_after = scan_after.max(envelope.sequence);
12909 let NodeEvent::Control {
12910 address: event_address,
12911 event,
12912 } = envelope.event
12913 else {
12914 continue;
12915 };
12916 if event_address != *address {
12917 continue;
12918 }
12919 if event.command_id == Some(command_id) {
12920 match &event.event {
12921 ControlEventKind::CommandRejected { message } => {
12922 return Err(failure(NodeFailureCode::InvalidRequest, message));
12923 }
12924 ControlEventKind::ResizeRequested {
12925 size: requested_size,
12926 ..
12927 } if *requested_size == size => {
12928 accepted_after = Some(envelope.sequence);
12929 continue;
12930 }
12931 _ => {}
12932 }
12933 }
12934 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12935 match &event.event {
12936 ControlEventKind::Resized { size: settled_size }
12937 if *settled_size == size =>
12938 {
12939 return Ok(());
12940 }
12941 ControlEventKind::ResizeFailed { message } => {
12942 return Err(failure(
12943 NodeFailureCode::BackendOperationFailed,
12944 message,
12945 ));
12946 }
12947 _ => {}
12948 }
12949 }
12950 }
12951 if Instant::now() >= deadline {
12952 return Err(failure(
12953 NodeFailureCode::BackendBusy,
12954 if accepted_after.is_some() {
12955 "resize was accepted but did not settle before the bounded deadline"
12956 } else {
12957 "resize was not accepted before the bounded deadline"
12958 },
12959 ));
12960 }
12961 sleep(Duration::from_millis(2)).await;
12962 }
12963 }
12964
12965 async fn spawn_session(
12966 &self,
12967 workspace_id: WorkspaceId,
12968 provider: AgentId,
12969 mode: SessionMode,
12970 terminal_size: gate4agent_types::TerminalSize,
12971 initial_prompt: Option<String>,
12972 ) -> Result<SessionAddress, NodeFailure> {
12973 if self.managed_worktrees.lock()
12974 .unwrap_or_else(|poisoned| poisoned.into_inner())
12975 .lease_for_workspace(&workspace_id)
12976 .is_some()
12977 {
12978 return Err(failure(
12979 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12980 "managed worktree targets may only be spawned through their lease request",
12981 ));
12982 }
12983 self
12984 .spawn_session_with_deadline(
12985 workspace_id,
12986 provider.clone(),
12987 mode,
12988 terminal_size,
12989 initial_prompt,
12990 None,
12991 None,
12992 None,
12993 None,
12994 None,
12995 SpawnRecordPolicy::ProviderIdentityOnly,
12996 None,
12997 &[],
12998 None,
12999 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13006 None,
13007 None,
13008 )
13009 .await
13010 .map(|(session, _runtime_policy)| session)
13011 }
13012
13013 async fn spawn_session_with_deadline(
13014 &self,
13015 workspace_id: WorkspaceId,
13016 provider: AgentId,
13017 mode: SessionMode,
13018 terminal_size: gate4agent_types::TerminalSize,
13019 initial_prompt: Option<String>,
13020 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13021 bundle: Option<ResolvedBundleReceipt>,
13022 context: Option<ResolvedContextPackReceipt>,
13023 managed_authority: Option<ManagedWorktreeLeaseId>,
13024 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13025 record_policy: SpawnRecordPolicy,
13026 deadline: Option<Instant>,
13027 required_capabilities: &[ProviderRuntimeCapability],
13028 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13029 approval_level: ApprovalLevel,
13030 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13031 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13037 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13038 let reserved_lease = self.managed_worktrees.lock()
13039 .unwrap_or_else(|poisoned| poisoned.into_inner())
13040 .lease_for_workspace(&workspace_id);
13041 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13042 (Some(expected), Some(provided)) if expected == provided => {}
13043 (None, None) => {}
13044 _ => {
13045 return Err(failure(
13046 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13047 "spawn lacks exact managed worktree lease authority",
13048 ));
13049 }
13050 }
13051 self.ensure_workspace_environment_profile_compatible(
13055 &workspace_id,
13056 environment_profile.as_ref(),
13057 )?;
13058 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13059 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13060 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13061 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13062 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13063 };
13064 let transport = match mode {
13070 SessionMode::Pty => TransportKind::Pty,
13071 SessionMode::Inline => TransportKind::Pipe,
13072 SessionMode::Acp => TransportKind::Acp,
13073 };
13074 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13075 let acp_transport = self.provider_supports_acp_transport(&provider);
13076 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13077 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13078 NodeFailureCode::UnsupportedSpawnCapability,
13079 "pre-admitted provider runtime no longer satisfies spawn requirements",
13080 ))?;
13081 runtime_policy
13082 } else if let Some(deadline) = deadline {
13083 let remaining = spawn_deadline_remaining(deadline)?;
13084 timeout(
13085 remaining,
13086 self.admit_provider_runtime(&provider, runtime_requirement),
13087 )
13088 .await
13089 .map_err(|_| {
13090 failure(
13091 NodeFailureCode::SpawnDeadlineExceeded,
13092 "provider admission exceeded the spawn deadline",
13093 )
13094 })??
13095 } else {
13096 self.admit_provider_runtime(&provider, runtime_requirement).await?
13097 };
13098 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13099 let runtime_policy =
13111 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13112 if required_capabilities
13113 .iter()
13114 .any(|capability| !runtime_policy.admits(*capability))
13115 {
13116 return Err(failure(
13117 NodeFailureCode::UnsupportedSpawnCapability,
13118 "provider runtime does not admit a required spawn capability",
13119 ));
13120 }
13121 if let Some(deadline) = deadline {
13122 spawn_deadline_remaining(deadline)?;
13123 }
13124 self.ensure_binding_capacity()?;
13125 let mut working_directory = self.workspace_root(&workspace_id)?;
13126 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13127 let session = SessionKey {
13128 instance_id,
13129 generation: SessionGeneration(1),
13130 };
13131 let address = SessionAddress {
13132 workspace_id: workspace_id.clone(),
13133 session,
13134 };
13135 tracing::info!(
13143 node_id = %self.node_id,
13144 workspace_id = %workspace_id,
13145 provider = %provider,
13146 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13147 session = ?address.session,
13148 "session spawn request accepted",
13149 );
13150 let mut prepared_spawn = SpawnPreparer::prepare(
13151 self,
13152 SpawnPrepareRequest {
13153 instance_id,
13154 address: &address,
13155 provider: &provider,
13156 mode,
13157 environment_profile: environment_profile.as_ref(),
13158 bundle: bundle.as_ref(),
13159 context: context.as_ref(),
13160 managed_authority: managed_authority.as_ref(),
13161 harness_mcp,
13162 approval_level,
13163 network_allowlist: network_allowlist.as_ref(),
13164 browser_profile_id,
13165 },
13166 )?;
13167 let materialization_id = prepared_spawn.materialization_id.take();
13168 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13169 let record_id = self.bind_spawn_session_with_materialization(
13170 &address,
13171 provider.clone(),
13172 mode,
13173 runtime_policy,
13174 record_policy,
13175 environment_profile,
13176 bundle,
13177 context,
13178 materialization_id,
13179 )?;
13180 prepared_spawn.retain_session_guards();
13181 let session_provider = provider.clone();
13185 let agent_id = provider;
13186 let register_command_id = match self.dispatch_bounded(
13187 ControlCommand::Register {
13188 instance_id,
13189 agent_id,
13190 transport,
13191 },
13192 dispatch_timeout,
13193 )
13194 .await {
13195 Ok(command_id) => command_id,
13196 Err(error) => {
13197 if let Some(binding) = self.remove_binding(&address) {
13198 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13199 }
13200 if let Some(record_id) = record_id.as_ref() {
13201 self.discard_record(record_id)?;
13202 }
13203 return Err(spawn_dispatch_error(error, deadline));
13204 }
13205 };
13206 let start_timeout = match spawn_dispatch_timeout(deadline) {
13207 Ok(timeout) => timeout,
13208 Err(error) => {
13209 let recovery = self
13210 .rollback_spawn(
13211 &address,
13212 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13213 )
13214 .await;
13215 return match recovery {
13216 Ok(()) => {
13217 if let Some(record_id) = record_id.as_ref() {
13218 self.discard_record(record_id)?;
13219 }
13220 Err(error)
13221 }
13222 Err(recovery_error) => {
13223 if let Some(record_id) = record_id.as_ref() {
13224 self.mark_record_error(
13225 record_id,
13226 "spawn deadline elapsed and registration recovery failed",
13227 )?;
13228 }
13229 Err(recovery_error)
13230 }
13231 };
13232 }
13233 };
13234 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13235 if let Err(recovery_error) = self
13236 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13237 .await
13238 {
13239 return Err(recovery_error);
13240 }
13241 if let Some(record_id) = record_id.as_ref() {
13242 self.discard_record(record_id)?;
13243 }
13244 return Err(start_error);
13245 }
13246 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13248 working_directory = dir.to_string_lossy().into_owned();
13249 }
13250 let start_result = self
13251 .dispatch_bounded(
13252 ControlCommand::Start {
13253 instance_id,
13254 runtime_policy,
13255 request: StartRequest {
13256 working_directory,
13257 terminal_size,
13258 initial_prompt,
13259 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13260 approval_level,
13261 },
13262 },
13263 start_timeout,
13264 )
13265 .await;
13266 let start_command_id = match start_result {
13267 Ok(command_id) => command_id,
13268 Err(start_error) => {
13269 let start_error = spawn_dispatch_error(start_error, deadline);
13270 let recovery = self
13271 .rollback_spawn(
13272 &address,
13273 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13274 )
13275 .await;
13276 return match recovery {
13277 Ok(()) => {
13278 if let Some(record_id) = record_id.as_ref() {
13279 self.discard_record(record_id)?;
13280 }
13281 Err(start_error)
13282 }
13283 Err(recovery_error) => {
13284 let diagnostic = format!(
13285 "start failed and registration recovery failed: {}",
13286 recovery_error.message,
13287 );
13288 if let Some(record_id) = record_id.as_ref() {
13289 self.mark_record_error(record_id, &diagnostic)?;
13290 }
13291 Err(failure(recovery_error.code, &diagnostic))
13292 }
13293 };
13294 }
13295 };
13296
13297 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13298 loop {
13299 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13308 .or_else(|| self.take_rejected_command_reason(start_command_id))
13309 {
13310 self.rollback_spawn(
13311 &address,
13312 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13313 ).await.map_err(|error| {
13314 failure(
13315 error.code,
13316 &format!(
13317 "spawn was rejected ({}) and registration recovery failed: {}",
13318 reason.message, error.message
13319 ),
13320 )
13321 })?;
13322 if let Some(record_id) = record_id.as_ref() {
13323 self.discard_record(record_id)?;
13324 }
13325 let code = if reason.unsupported_transport {
13326 NodeFailureCode::UnsupportedTransport
13327 } else {
13328 NodeFailureCode::BackendOperationFailed
13329 };
13330 return Err(failure(code, &reason.message));
13331 }
13332 if let Some(current) = self
13333 .handle
13334 .snapshot()
13335 .sessions
13336 .iter()
13337 .find(|current| current.instance_id == instance_id)
13338 {
13339 if current.generation != session.generation {
13340 let divergence = failure(
13341 NodeFailureCode::StaleGeneration,
13342 "spawn generation diverged",
13343 );
13344 return match self
13345 .rollback_spawn(
13346 &address,
13347 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13348 )
13349 .await
13350 {
13351 Ok(()) => {
13352 if let Some(record_id) = record_id.as_ref() {
13353 self.discard_record(record_id)?;
13354 }
13355 Err(divergence)
13356 }
13357 Err(recovery_error) => {
13358 if let Some(record_id) = record_id.as_ref() {
13359 self.mark_record_error(
13360 record_id,
13361 "spawn generation diverged and recovery failed",
13362 )?;
13363 }
13364 Err(recovery_error)
13365 }
13366 };
13367 }
13368 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13369 prepared_spawn.retain_launch_guards();
13370 return Ok((address, runtime_policy));
13371 }
13372 }
13373 if Instant::now() >= commit_deadline {
13374 self.rollback_spawn(
13375 &address,
13376 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13377 ).await.map_err(|error| {
13378 failure(
13379 error.code,
13380 &format!(
13381 "start did not commit and registration recovery failed: {}",
13382 error.message
13383 ),
13384 )
13385 })?;
13386 if let Some(record_id) = record_id.as_ref() {
13387 self.discard_record(record_id)?;
13388 }
13389 return Err(failure(
13390 if deadline.is_some() {
13391 NodeFailureCode::SpawnDeadlineExceeded
13392 } else {
13393 NodeFailureCode::BackendBusy
13394 },
13395 "start did not commit before the bounded deadline; registration was removed",
13396 ));
13397 }
13398 sleep(Duration::from_millis(2)).await;
13399 }
13400 }
13401
13402 fn effective_spawn_runtime_policy(
13403 &self,
13404 runtime_policy: ProviderRuntimePolicy,
13405 ) -> ProviderRuntimePolicy {
13406 #[cfg(feature = "fixture")]
13407 let runtime_policy = if self.fixture_semantic_hook_policy {
13408 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13409 .expect("monitoring Hook fixture policy is internally valid")
13410 } else {
13411 runtime_policy
13412 };
13413 runtime_policy
13414 }
13415
13416 fn verify_harness_mcp_before_start(
13417 &self,
13418 prepared: Option<&PreparedHarnessMcpSpawn>,
13419 ) -> Result<(), NodeFailure> {
13420 prepared
13421 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13422 .transpose()
13423 .map(|_| ())
13424 .map_err(harness_mcp_failure)
13425 }
13426
13427 async fn rollback_spawn(
13428 &self,
13429 address: &SessionAddress,
13430 commit_timeout: Duration,
13431 ) -> Result<(), NodeFailure> {
13432 self.dispatch_bounded(
13433 ControlCommand::Remove {
13434 instance_id: address.session.instance_id,
13435 },
13436 commit_timeout,
13437 )
13438 .await?;
13439 self.wait_until_removed(address, commit_timeout).await?;
13440 if let Some(binding) = self.remove_binding(address) {
13441 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13442 }
13443 Ok(())
13444 }
13445
13446 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13447 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13448 self.dispatch_bounded(
13449 ControlCommand::Remove {
13450 instance_id: address.session.instance_id,
13451 },
13452 commit_timeout,
13453 )
13454 .await?;
13455 self.wait_until_removed(address, commit_timeout).await?;
13456 let removed = self.remove_binding(address);
13457 if let Some(binding) = removed.as_ref() {
13458 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13459 }
13460 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13461 let snapshot = self.managed_worktrees.lock()
13462 .unwrap_or_else(|poisoned| poisoned.into_inner())
13463 .release_session(
13464 &lease_id,
13465 self.incarnation_id,
13466 address.session.instance_id,
13467 address.session.generation,
13468 unix_time_ms(),
13469 );
13470 if self.persist_state().is_err() {
13471 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13472 }
13473 if let Some(snapshot) = snapshot {
13474 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13475 }
13476 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13477 }
13478 Ok(())
13479 }
13480
13481 async fn wait_until_removed(
13527 &self,
13528 address: &SessionAddress,
13529 commit_timeout: Duration,
13530 ) -> Result<(), NodeFailure> {
13531 let deadline = Instant::now() + commit_timeout;
13532 let mut next_dispatch = Instant::now();
13533 loop {
13534 let snapshot = self.handle.snapshot();
13535 let status = snapshot
13536 .sessions
13537 .iter()
13538 .find(|session| session.instance_id == address.session.instance_id)
13539 .map(|session| session.status.clone());
13540 let Some(status) = status else {
13541 return Ok(());
13542 };
13543 let now = Instant::now();
13544 if now >= deadline {
13545 return Err(failure(
13546 NodeFailureCode::BackendBusy,
13547 "remove did not commit before the bounded deadline; session binding was retained",
13548 ));
13549 }
13550 if status.allows_remove() && now >= next_dispatch {
13551 let _ = self.dispatch(ControlCommand::Remove {
13552 instance_id: address.session.instance_id,
13553 });
13554 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13555 }
13556 sleep(Duration::from_millis(2)).await;
13557 }
13558 }
13559
13560 fn resync(&self, after_sequence: u64) -> NodeResponse {
13561 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13562 let event_sequence = history.last_sequence;
13563 let oldest_available_sequence = history.replay_floor_sequence;
13564 let events = history
13565 .events
13566 .iter()
13567 .filter(|event| event.sequence > after_sequence)
13568 .cloned()
13569 .collect();
13570 drop(history);
13571 NodeResponse::Resync {
13572 event_sequence,
13573 oldest_available_sequence,
13574 snapshot: self.snapshot(),
13575 events,
13576 }
13577 }
13578}
13579
13580fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13585 match kind {
13586 AgentStreamChunkKindV1::Text { .. } => "text",
13587 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13588 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13589 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13590 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13591 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13592 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13593 }
13594}
13595
13596async fn accept_connections(
13610 endpoint: &str,
13611 call_home: Option<std::net::SocketAddr>,
13612 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13613 shared: Arc<NodeShared>,
13614) -> Result<(), NodeServerError> {
13615 let mut connections = JoinSet::new();
13616 let result = match call_home {
13617 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13618 Some(relay) => {
13619 let listener =
13620 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13621 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13622 tokio::pin!(listener);
13623 tokio::pin!(dialler);
13624 tokio::select! {
13625 result = &mut listener => result,
13626 result = &mut dialler => result,
13627 }
13628 }
13629 };
13630 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13631 while !connections.is_empty() {
13632 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13633 if remaining.is_zero()
13634 || timeout(remaining, connections.join_next()).await.is_err()
13635 {
13636 break;
13637 }
13638 }
13639 connections.abort_all();
13640 while connections.join_next().await.is_some() {}
13641 result
13642}
13643
13644const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13648
13649async fn call_home_loop(
13663 relay: std::net::SocketAddr,
13664 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13665 shared: Arc<NodeShared>,
13666) -> Result<(), NodeServerError> {
13667 let mut failures = 0_usize;
13668 loop {
13669 if shared.shutdown.load(Ordering::Acquire) {
13670 return Ok(());
13671 }
13672 let permit = tokio::select! {
13673 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13674 permit.map_err(|_| NodeServerError::PreauthClosed)?
13675 }
13676 _ = shared.shutdown_notify.notified() => return Ok(()),
13677 };
13678 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13679 Ok(()) => {
13680 failures = 0;
13684 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13685 }
13686 Err(error) => {
13687 failures = failures.saturating_add(1);
13688 tracing::warn!(
13689 node_id = %shared.node_id,
13690 %relay,
13691 cause = %error,
13692 attempt = failures,
13693 "call-home connection failed",
13694 );
13695 let backoff = call_home_backoff(failures);
13696 tokio::select! {
13697 _ = sleep(backoff) => {}
13698 _ = shared.shutdown_notify.notified() => return Ok(()),
13699 }
13700 }
13701 }
13702 }
13703}
13704
13705const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13707const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13709
13710fn call_home_keepalive() -> socket2::TcpKeepalive {
13711 let keepalive = socket2::TcpKeepalive::new()
13712 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13713 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13714 #[cfg(not(windows))]
13715 let keepalive = keepalive.with_retries(3);
13716 keepalive
13717}
13718
13719fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13720 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13721}
13722
13723pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13725 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13726}
13727
13728fn call_home_backoff(attempt: usize) -> Duration {
13730 Duration::from_millis(
13731 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13732 )
13733}
13734
13735
13736async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13739 #[cfg(feature = "wireguard")]
13740 {
13741 let std_stream = tokio::task::spawn_blocking(move || {
13742 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13743 })
13744 .await
13745 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13746 .map_err(NodeServerError::CallHomeTransport)?;
13747 std_stream
13748 .set_nonblocking(true)
13749 .map_err(NodeServerError::CallHomeTransport)?;
13750 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13751 }
13752 #[cfg(not(feature = "wireguard"))]
13753 {
13754 tokio::net::TcpStream::connect(relay)
13755 .await
13756 .map_err(NodeServerError::CallHomeTransport)
13757 }
13758}
13759
13760async fn dial_relay_and_serve(
13762 relay: std::net::SocketAddr,
13763 identity: Option<&hatchery_enroll::NodeIdentity>,
13764 shared: Arc<NodeShared>,
13765 preauth_permit: OwnedSemaphorePermit,
13766) -> Result<(), NodeServerError> {
13767 let mut stream = dial_call_home(relay).await?;
13768 let _ = stream.set_nodelay(true);
13772 if let Err(error) = enable_call_home_keepalive(&stream) {
13778 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13779 }
13780 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13781 .await
13782 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13783 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13784 let mut session_token = None;
13785 if let Some(identity) = identity {
13786 let mut magic = [0_u8; 4];
13790 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13791 .await
13792 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13793 .map_err(NodeServerError::CallHomeTransport)?;
13794 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13795 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13796 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13797 .await
13798 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13799 .map_err(NodeServerError::CallHomeTransport)?;
13800 let (answer, token) = identity
13801 .answer_challenge(shared.node_id.as_str(), &frame)
13802 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13803 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13804 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13805 session_token = Some(token);
13806 }
13807 }
13808 serve_connection(stream, shared, session_token, preauth_permit).await
13809}
13810
13811async fn accept_connections_inner(
13812 endpoint: &str,
13813 shared: Arc<NodeShared>,
13814 connections: &mut JoinSet<Result<(), NodeServerError>>,
13815) -> Result<(), NodeServerError> {
13816 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13817 loop {
13818 while connections.try_join_next().is_some() {}
13819 if shared.shutdown.load(Ordering::Acquire) {
13820 return Ok(());
13821 }
13822 let preauth_permit = tokio::select! {
13823 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13824 permit.map_err(|_| NodeServerError::PreauthClosed)?
13825 }
13826 _ = shared.shutdown_notify.notified() => return Ok(()),
13827 };
13828 let server = tokio::select! {
13829 result = listener.accept() => result?,
13830 _ = shared.shutdown_notify.notified() => return Ok(()),
13831 };
13832 let connection_shared = Arc::clone(&shared);
13833 connections.spawn(async move {
13834 serve_connection(server, connection_shared, None, preauth_permit).await
13835 });
13836 if shared.shutdown.load(Ordering::Acquire) {
13837 return Ok(());
13838 }
13839 }
13840}
13841
13842async fn serve_connection<S>(
13850 mut pipe: S,
13851 shared: Arc<NodeShared>,
13852 session_token: Option<String>,
13853 preauth_permit: OwnedSemaphorePermit,
13854) -> Result<(), NodeServerError>
13855where
13856 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13857{
13858 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13860 let hello_frame = timeout(
13861 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13862 read_json_frame_limited_body_timeout(
13863 &mut pipe,
13864 MAX_NODE_HELLO_FRAME_BYTES,
13865 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13866 ),
13867 )
13868 .await
13869 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13870 let ClientFrame::Hello(hello) = hello_frame else {
13871 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13872 };
13873 if hello.build_stamp != BUILD_STAMP {
13874 return Err(NodeServerError::Handshake(format!(
13875 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13876 hello.build_stamp,
13877 )));
13878 }
13879 let compatibility = match hello.compatibility.as_ref() {
13880 Some(offer) => {
13881 let mut selected = node_compatibility_support(&shared)?
13882 .negotiate(offer)
13883 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13884 project_negotiated_provider_ids(&mut selected);
13885 Some(selected)
13886 }
13887 None => None,
13888 };
13889 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13890 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13891 (Some(offer), Some(selected)) => negotiated_auth_proof(
13892 auth_token,
13893 AuthDirection::Server,
13894 hello.role,
13895 &hello.client_nonce,
13896 &server_nonce,
13897 offer,
13898 selected,
13899 ),
13900 (None, None) => auth_proof(
13901 auth_token,
13902 AuthDirection::Server,
13903 hello.role,
13904 &hello.client_nonce,
13905 &server_nonce,
13906 ),
13907 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13908 }
13909 .map_err(NodeServerError::Authentication)?;
13910 write_json_frame_limited(
13911 &mut pipe,
13912 &ServerFrame::Challenge(ServerChallenge {
13913 build_stamp: BUILD_STAMP.to_owned(),
13914 server_nonce,
13915 server_proof,
13916 compatibility: compatibility.clone(),
13917 }),
13918 MAX_NODE_HELLO_FRAME_BYTES,
13919 )
13920 .await?;
13921 let authentication = timeout(
13922 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13923 read_json_frame_limited_body_timeout(
13924 &mut pipe,
13925 MAX_NODE_HELLO_FRAME_BYTES,
13926 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13927 ),
13928 )
13929 .await
13930 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13931 let ClientFrame::Authenticate(authentication) = authentication else {
13932 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13933 };
13934 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13935 (Some(offer), Some(selected)) => negotiated_auth_proof(
13936 auth_token,
13937 AuthDirection::Client,
13938 hello.role,
13939 &hello.client_nonce,
13940 &server_nonce,
13941 offer,
13942 selected,
13943 ),
13944 (None, None) => auth_proof(
13945 auth_token,
13946 AuthDirection::Client,
13947 hello.role,
13948 &hello.client_nonce,
13949 &server_nonce,
13950 ),
13951 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13952 }
13953 .map_err(NodeServerError::Authentication)?;
13954 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13955 return Err(NodeServerError::Handshake("access denied".to_owned()));
13956 }
13957 let selected_capabilities = compatibility
13958 .as_ref()
13959 .map(|selected| selected.capabilities.clone())
13960 .unwrap_or_default();
13961 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13962 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13963 });
13964 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13965 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13966 });
13967 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13968 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13969 });
13970 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13971 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13972 });
13973 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13974 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13975 });
13976 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13977 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13978 }) && selected_capabilities.iter().any(|capability| {
13979 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13980 });
13981 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13982 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13983 });
13984 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13985 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13986 });
13987 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13988 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13989 });
13990 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13991 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13992 });
13993 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13994 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13995 });
13996 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13997 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13998 });
13999 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14000 .try_acquire_owned()
14001 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14002 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14003 let _release_guard = ControllerReleaseGuard {
14004 shared: Arc::clone(&shared),
14005 connection_id,
14006 };
14007 drop(preauth_permit);
14008 let _authenticated_permit = authenticated_permit;
14009 let mut event_rx = shared.event_tx.subscribe();
14010 let mut terminal_event_rx = terminal_event_subscription(
14011 &shared,
14012 include_terminal_frame_events,
14013 );
14014 let mut agent_stream_event_rx = agent_stream_event_subscription(
14015 &shared,
14016 include_agent_stream_events,
14017 );
14018 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14019 &shared,
14020 include_harness_mcp_proxy,
14021 );
14022 write_json_frame(
14023 &mut pipe,
14024 &ServerFrame::Hello(NodeHello {
14025 build_stamp: BUILD_STAMP.to_owned(),
14026 incarnation_id: shared.incarnation_id,
14027 connection_id,
14028 role: hello.role,
14029 event_sequence: shared.current_sequence(),
14030 controller: shared.controller_state(),
14031 snapshot: snapshot_for_wire(
14032 &shared,
14033 include_provider_runtime_status,
14034 include_open_provider_ids,
14035 include_spawn_profiles,
14036 include_managed_worktrees,
14037 include_child_environment_profiles,
14038 include_session_bundles,
14039 include_history_context_packs,
14040 include_agent_progress,
14041 include_session_task_correlation,
14042 ),
14043 compatibility,
14044 }),
14045 )
14046 .await?;
14047 let (mut reader, mut writer) = tokio::io::split(pipe);
14048 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14049 let reader_task = tokio::spawn(async move {
14050 loop {
14051 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14052 &mut reader,
14053 MAX_NODE_CLIENT_FRAME_BYTES,
14054 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14055 )
14056 .await;
14057 let terminal = frame.is_err();
14058 if frame_tx.send(frame).await.is_err() || terminal {
14059 break;
14060 }
14061 }
14062 });
14063 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14064 let mut pending_events = Vec::new();
14065 let mut resync_required = false;
14066 let mut discard_events_through = 0_u64;
14067 loop {
14068 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14073 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14074 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14075 for _ in 0..durable_drain_budget {
14076 match event_rx.try_recv() {
14077 Ok(event) => queue_connection_event(
14078 &mut pending_events,
14079 event,
14080 discard_events_through,
14081 ),
14082 Err(broadcast::error::TryRecvError::Empty) => break,
14083 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14084 resync_required = true;
14085 break;
14086 }
14087 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14088 }
14089 }
14090 if let Some(receiver) = terminal_event_rx.as_mut() {
14091 match receiver.try_recv() {
14092 Ok(events) => queue_connection_event_batch(
14093 &mut pending_events,
14094 &events,
14095 discard_events_through,
14096 ),
14097 Err(broadcast::error::TryRecvError::Empty) => {}
14098 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14099 resync_required = true;
14100 }
14101 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14102 }
14103 }
14104 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14105 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14115 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14116 for _ in 0..agent_stream_drain_budget {
14117 match receiver.try_recv() {
14118 Ok(events) => queue_connection_event_batch(
14119 &mut pending_events,
14120 &events,
14121 discard_events_through,
14122 ),
14123 Err(broadcast::error::TryRecvError::Empty) => break,
14124 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14125 tracing::warn!(
14126 connection_id,
14127 dropped,
14128 "agent stream event subscription lagged; forcing a resync",
14129 );
14130 resync_required = true;
14131 break;
14132 }
14133 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14134 }
14135 }
14136 }
14137 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14138 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14144 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14145 for _ in 0..harness_mcp_proxy_drain_budget {
14146 match receiver.try_recv() {
14147 Ok(events) => queue_connection_event_batch(
14148 &mut pending_events,
14149 &events,
14150 discard_events_through,
14151 ),
14152 Err(broadcast::error::TryRecvError::Empty) => break,
14153 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14154 tracing::warn!(
14155 connection_id,
14156 dropped,
14157 "harness MCP proxy event subscription lagged; forcing a resync",
14158 );
14159 resync_required = true;
14160 break;
14161 }
14162 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14163 }
14164 }
14165 }
14166 if resync_required {
14167 let event = resync_required_event(&shared);
14168 apply_connection_resync_watermark(
14169 &mut pending_events,
14170 &mut discard_events_through,
14171 event.sequence,
14172 );
14173 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14174 resync_required = false;
14175 continue;
14176 }
14177 if !pending_events.is_empty() {
14178 pending_events.sort_unstable_by_key(|event| event.sequence);
14179 let burst_len = connection_event_burst_len(pending_events.len());
14180 shared
14181 .connection_events_sent
14182 .fetch_add(burst_len as u64, Ordering::Relaxed);
14183 for event in pending_events.drain(..burst_len) {
14184 let event = if include_managed_worktrees {
14185 Some(event)
14186 } else {
14187 project_event_without_managed_worktrees(event)
14188 };
14189 let event = event.and_then(|event| if include_open_provider_ids {
14190 Some(event)
14191 } else {
14192 project_event_legacy_provider_ids(&shared, event)
14193 });
14194 let event = event.map(|event| if include_child_environment_profiles {
14195 event
14196 } else {
14197 project_event_without_child_environment_profile(event)
14198 });
14199 let event = event.map(|event| if include_session_bundles {
14200 event
14201 } else {
14202 project_event_without_session_bundle(event)
14203 });
14204 let event = event.map(|event| {
14205 project_event_history_for_wire(event, include_history_context_packs)
14206 });
14207 let event = event.map(|event| if include_history_context_packs {
14208 event
14209 } else {
14210 project_event_without_context_pack(event)
14211 });
14212 let event = event.map(|event| if include_session_task_correlation {
14213 event
14214 } else {
14215 project_event_without_session_task_binding(event)
14216 });
14217 let exact_harness_controller = hello.role == ClientRole::Operator
14218 && shared.controller_state().is_some_and(|controller| {
14219 controller.connection_id == connection_id
14220 });
14221 let event = event.and_then(|event| {
14222 if include_harness_mcp_proxy && exact_harness_controller {
14223 Some(event)
14224 } else {
14225 project_event_without_harness_mcp_proxy(event)
14226 }
14227 });
14228 if let Some(event) = event {
14229 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14230 }
14231 }
14232 }
14233 tokio::select! {
14234 biased;
14235 frame = frame_rx.recv() => {
14236 let frame = match frame {
14237 Some(frame) => frame,
14238 None => break,
14239 };
14240 let frame = match frame {
14241 Ok(frame) => frame,
14242 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14243 Err(error) => return Err(error.into()),
14244 };
14245 let ClientFrame::Request(request) = frame else {
14246 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14247 };
14248 #[cfg(feature = "fixture")]
14249 let probe_spawn_managed_worktree_v2 = matches!(
14250 &request.request,
14251 NodeRequest::SpawnManagedWorktreeV2 { .. }
14252 );
14253 let requires_open_provider_ids =
14254 request_requires_open_provider_ids(&shared, &request.request);
14255 let requires_child_environment_profile =
14256 request_requires_child_environment_profile(&shared, &request.request);
14257 let requires_session_bundle =
14258 request_requires_session_bundle(&shared, &request.request);
14259 let requires_history_context_pack =
14260 request_requires_history_context_pack(&shared, &request.request);
14261 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14262 ResponseEnvelope {
14263 request_id: request.request_id,
14264 result: Err(failure(
14265 NodeFailureCode::UnsupportedCapability,
14266 "open provider IDs were not negotiated",
14267 )),
14268 }
14269 } else if requires_child_environment_profile
14270 && !include_child_environment_profiles
14271 {
14272 ResponseEnvelope {
14273 request_id: request.request_id,
14274 result: Err(failure(
14275 NodeFailureCode::UnsupportedCapability,
14276 "child environment profiles were not negotiated",
14277 )),
14278 }
14279 } else if requires_session_bundle && !include_session_bundles {
14280 ResponseEnvelope {
14281 request_id: request.request_id,
14282 result: Err(failure(
14283 NodeFailureCode::UnsupportedCapability,
14284 "session bundles were not negotiated",
14285 )),
14286 }
14287 } else if requires_history_context_pack && !include_history_context_packs {
14288 ResponseEnvelope {
14289 request_id: request.request_id,
14290 result: Err(failure(
14291 NodeFailureCode::UnsupportedCapability,
14292 "history context packs were not negotiated",
14293 )),
14294 }
14295 } else if request_uses_unnegotiated_capability(
14296 &request.request,
14297 &selected_capabilities,
14298 )
14299 {
14300 ResponseEnvelope {
14301 request_id: request.request_id,
14302 result: Err(failure(
14303 NodeFailureCode::UnsupportedCapability,
14304 "request capability was not negotiated",
14305 )),
14306 }
14307 } else {
14308 process_request(&shared, connection_id, hello.role, request).await
14309 };
14310 #[cfg(feature = "fixture")]
14311 if probe_spawn_managed_worktree_v2 {
14312 shared
14313 .fixture_spawn_managed_worktree_v2_failure_probe
14314 .record(reply.result.as_ref().err().map(|failure| failure.code));
14315 }
14316 if !include_provider_runtime_status {
14317 clear_response_provider_runtime_status(&mut reply);
14318 }
14319 if !include_managed_worktrees {
14320 project_response_without_managed_worktrees(&mut reply);
14321 }
14322 if !include_open_provider_ids {
14323 project_response_legacy_provider_ids(&shared, &mut reply);
14324 }
14325 if !include_child_environment_profiles {
14326 project_response_without_child_environment_profile(&mut reply);
14327 }
14328 if !include_session_bundles {
14329 project_response_without_session_bundle(&mut reply);
14330 }
14331 project_response_history_for_wire(
14332 &mut reply,
14333 include_history_context_packs,
14334 );
14335 if !include_history_context_packs {
14336 project_response_without_context_pack(&mut reply);
14337 }
14338 if !include_agent_progress {
14339 project_response_without_agent_progress(&mut reply);
14340 }
14341 if !include_session_task_correlation {
14342 project_response_without_session_task_binding(&mut reply);
14343 }
14344 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14345 }
14346 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14347 event = event_rx.recv() => {
14348 match event {
14349 Ok(event) => queue_connection_event(
14350 &mut pending_events,
14351 event,
14352 discard_events_through,
14353 ),
14354 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14355 Err(broadcast::error::RecvError::Closed) => break,
14356 }
14357 }
14358 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14359 match events {
14360 Ok(events) => queue_connection_event_batch(
14361 &mut pending_events,
14362 &events,
14363 discard_events_through,
14364 ),
14365 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14366 Err(broadcast::error::RecvError::Closed) => break,
14367 }
14368 }
14369 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14370 match events {
14371 Ok(events) => queue_connection_event_batch(
14372 &mut pending_events,
14373 &events,
14374 discard_events_through,
14375 ),
14376 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14377 Err(broadcast::error::RecvError::Closed) => break,
14378 }
14379 }
14380 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14381 match events {
14382 Ok(events) => queue_connection_event_batch(
14383 &mut pending_events,
14384 &events,
14385 discard_events_through,
14386 ),
14387 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14388 Err(broadcast::error::RecvError::Closed) => break,
14389 }
14390 }
14391 _ = shared.shutdown_notify.notified() => break,
14392 }
14393 }
14394 Ok(())
14395}
14396
14397async fn receive_terminal_event_batch(
14398 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14399) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14400 match receiver {
14401 Some(receiver) => receiver.recv().await,
14402 None => std::future::pending().await,
14403 }
14404}
14405
14406fn terminal_event_subscription(
14407 shared: &NodeShared,
14408 enabled: bool,
14409) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14410 enabled.then(|| shared.terminal_event_tx.subscribe())
14411}
14412
14413fn agent_stream_event_subscription(
14417 shared: &NodeShared,
14418 enabled: bool,
14419) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14420 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14421}
14422
14423fn harness_mcp_proxy_event_subscription(
14428 shared: &NodeShared,
14429 enabled: bool,
14430) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14431 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14432}
14433
14434fn queue_connection_event(
14435 pending: &mut Vec<NodeEventEnvelope>,
14436 event: NodeEventEnvelope,
14437 discard_through: u64,
14438) {
14439 if event.sequence <= discard_through
14440 && !event.event.requires_harness_mcp_proxy_capability()
14441 {
14442 return;
14443 }
14444 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14445 if let Some(index) = pending.iter().position(|current| {
14446 matches!(
14447 ¤t.event,
14448 NodeEvent::TerminalFrame {
14449 address: current_address,
14450 ..
14451 } if current_address == address
14452 )
14453 }) {
14454 pending.remove(index);
14455 }
14456 }
14457 pending.push(event);
14458}
14459
14460fn project_event_without_harness_mcp_proxy(
14461 envelope: NodeEventEnvelope,
14462) -> Option<NodeEventEnvelope> {
14463 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14464}
14465
14466fn queue_connection_event_batch(
14467 pending: &mut Vec<NodeEventEnvelope>,
14468 events: &[NodeEventEnvelope],
14469 discard_through: u64,
14470) {
14471 for event in events {
14472 queue_connection_event(pending, event.clone(), discard_through);
14473 }
14474}
14475
14476fn apply_connection_resync_watermark(
14477 pending: &mut Vec<NodeEventEnvelope>,
14478 discard_through: &mut u64,
14479 marker_sequence: u64,
14480) {
14481 *discard_through = (*discard_through).max(marker_sequence);
14482 pending.retain(|event| event.sequence > *discard_through);
14483}
14484
14485fn connection_event_burst_len(pending_len: usize) -> usize {
14486 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14487}
14488
14489fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14490 let history = shared
14491 .history
14492 .lock()
14493 .unwrap_or_else(|poisoned| poisoned.into_inner());
14494 let sequence = history.last_sequence;
14495 let oldest_available_sequence = history.replay_floor_sequence;
14496 NodeEventEnvelope {
14497 sequence,
14498 event: NodeEvent::ResyncRequired {
14499 oldest_available_sequence,
14500 },
14501 }
14502}
14503
14504fn node_compatibility_support(
14505 shared: &NodeShared,
14506) -> Result<NodeCompatibilitySupport, NodeServerError> {
14507 let mut support = node_compatibility_support_for_manifest(
14508 &shared.provider_contracts,
14509 &shared.provider_adapter_contracts,
14510 )?;
14511 if shared
14512 .delivery_store
14513 .lock()
14514 .unwrap_or_else(|poisoned| poisoned.into_inner())
14515 .is_some()
14516 {
14517 support.capabilities.push(
14518 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14519 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14520 );
14521 }
14522 if shared.harness_mcp_registry.is_some() {
14523 support.capabilities.push(
14524 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14525 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14526 );
14527 }
14528 Ok(support)
14529}
14530
14531fn node_compatibility_support_for_manifest(
14532 provider_contracts: &[ProviderContractSupport],
14533 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14534) -> Result<NodeCompatibilitySupport, NodeServerError> {
14535 Ok(NodeCompatibilitySupport {
14536 build_stamp: BUILD_STAMP.to_owned(),
14537 capabilities: baseline_capabilities()?,
14538 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14539 path_semantics: platform::path_semantics(),
14540 local_transport: platform::local_transport(),
14541 state_schema: StateSchemaSupport {
14542 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14543 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14544 },
14545 provider_contracts: provider_contracts.to_vec(),
14546 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14547 })
14548}
14549
14550fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14551 let capabilities = [
14552 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14553 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14554 NODE_REPOSITORY_PATH_CAPABILITY,
14555 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14556 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14557 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14558 NODE_GIT_READ_CAPABILITY,
14559 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14560 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14561 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14562 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14563 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14564 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14565 NODE_ACP_CONTROL_CAPABILITY,
14566 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14567 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14568 NODE_WORKTREE_SELECTION_CAPABILITY,
14569 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14570 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14571 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14572 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14573 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14574 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14575 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14576 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14577 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14578 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14579 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14580 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14581 ]
14582 .into_iter()
14583 .map(|capability| {
14584 CapabilityId::new(capability)
14585 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14586 })
14587 .collect::<Result<Vec<_>, _>>()?;
14588 #[cfg(windows)]
14589 let capabilities = {
14590 let mut capabilities = capabilities;
14591 capabilities.push(
14592 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14593 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14594 );
14595 capabilities
14596 };
14597 Ok(capabilities)
14598}
14599
14600fn request_uses_unnegotiated_capability(
14601 request: &NodeRequest,
14602 selected_capabilities: &[CapabilityId],
14603) -> bool {
14604 let selected = |required: &str| {
14605 selected_capabilities
14606 .iter()
14607 .any(|capability| capability.as_str() == required)
14608 };
14609 request
14610 .required_capability()
14611 .is_some_and(|required| !selected(required))
14612 || (request.requires_worktree_selection_capability()
14613 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14614 || (request.requires_spawn_spec_defaults_overrides_capability()
14615 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14616 || (request.requires_spawn_profile_revision_capability()
14617 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14618 || (request.requires_child_environment_profile_capability()
14619 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14620 || (request.requires_session_bundle_materialization_capability()
14621 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14622 || (request.requires_history_context_pack_capability()
14623 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14624}
14625
14626fn snapshot_for_wire(
14627 shared: &NodeShared,
14628 include_provider_runtime_status: bool,
14629 include_open_provider_ids: bool,
14630 include_spawn_profiles: bool,
14631 include_managed_worktrees: bool,
14632 include_child_environment_profiles: bool,
14633 include_session_bundles: bool,
14634 include_history_context_packs: bool,
14635 include_agent_progress: bool,
14636 include_session_task_correlation: bool,
14637) -> NodeSnapshot {
14638 let mut snapshot = shared.snapshot();
14639 if !include_provider_runtime_status {
14640 snapshot.provider_runtime_statuses.clear();
14641 }
14642 if !include_open_provider_ids {
14643 project_snapshot_legacy_provider_ids(&mut snapshot);
14644 }
14645 if !include_managed_worktrees {
14646 snapshot.managed_worktrees.clear();
14647 for workspace in &mut snapshot.workspaces {
14648 workspace.managed_worktree_profiles = None;
14649 }
14650 }
14651 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14652 if !include_spawn_profiles {
14653 inventory.spawn_profiles = None;
14654 }
14655 if !include_session_bundles {
14656 inventory.bundles = None;
14657 }
14658 if inventory.spawn_profiles.is_none()
14659 && inventory.bundles.is_none()
14660 && inventory.network_allowlists.is_none()
14661 {
14662 snapshot.launch_inventory = None;
14663 }
14664 }
14665 if !include_child_environment_profiles {
14666 clear_snapshot_child_environment_profiles(&mut snapshot);
14667 }
14668 if !include_session_bundles {
14669 clear_snapshot_session_bundles(&mut snapshot);
14670 }
14671 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14672 if !include_history_context_packs {
14673 clear_snapshot_context_packs(&mut snapshot);
14674 }
14675 if !include_agent_progress {
14676 snapshot.agent_progress.clear();
14677 }
14678 if !include_session_task_correlation {
14679 clear_snapshot_session_task_bindings(&mut snapshot);
14680 }
14681 snapshot
14682}
14683
14684fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14685 for record in &mut snapshot.session_records {
14686 record.task_binding = None;
14687 }
14688}
14689
14690fn project_event_without_session_task_binding(
14691 mut envelope: NodeEventEnvelope,
14692) -> NodeEventEnvelope {
14693 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14694 record.task_binding = None;
14695 }
14696 envelope
14697}
14698
14699fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14700 let Ok(response) = reply.result.as_mut() else { return };
14701 match response {
14702 NodeResponse::Snapshot { snapshot, .. } => {
14703 clear_snapshot_session_task_bindings(snapshot);
14704 }
14705 NodeResponse::Resync { snapshot, events, .. } => {
14706 clear_snapshot_session_task_bindings(snapshot);
14707 for event in events {
14708 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14709 record.task_binding = None;
14710 }
14711 }
14712 }
14713 NodeResponse::SessionRecordUpdated { record }
14714 | NodeResponse::ProviderSessionIndexed { record }
14715 | NodeResponse::NativeSessionIndexed { record, .. }
14716 | NodeResponse::SessionRecordResumed { record, .. } => {
14717 record.task_binding = None;
14718 }
14719 _ => {}
14720 }
14721}
14722
14723fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14724 let Ok(response) = reply.result.as_mut() else { return };
14725 match response {
14726 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14727 snapshot.agent_progress.clear();
14728 }
14729 _ => {}
14730 }
14731}
14732
14733fn project_event_without_managed_worktrees(
14734 mut envelope: NodeEventEnvelope,
14735) -> Option<NodeEventEnvelope> {
14736 if matches!(
14737 envelope.event,
14738 NodeEvent::ManagedWorktreeUpserted { .. }
14739 | NodeEvent::ManagedWorktreeRemoved { .. }
14740 ) {
14741 return None;
14742 }
14743 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14744 workspace.managed_worktree_profiles = None;
14745 }
14746 Some(envelope)
14747}
14748
14749fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14750 if matches!(
14751 reply.result,
14752 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14753 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14754 ) {
14755 reply.result = Err(failure(
14756 NodeFailureCode::UnsupportedCapability,
14757 "managed worktree capability was not negotiated",
14758 ));
14759 return;
14760 }
14761 let Ok(response) = reply.result.as_mut() else { return };
14762 match response {
14763 NodeResponse::Snapshot { snapshot, .. } => {
14764 snapshot.managed_worktrees.clear();
14765 for workspace in &mut snapshot.workspaces {
14766 workspace.managed_worktree_profiles = None;
14767 }
14768 }
14769 NodeResponse::Resync { snapshot, events, .. } => {
14770 snapshot.managed_worktrees.clear();
14771 for workspace in &mut snapshot.workspaces {
14772 workspace.managed_worktree_profiles = None;
14773 }
14774 *events = std::mem::take(events)
14775 .into_iter()
14776 .filter_map(project_event_without_managed_worktrees)
14777 .collect();
14778 }
14779 NodeResponse::WorkspaceRegistered { workspace }
14780 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14781 | NodeResponse::WorktreeCreated { workspace, .. } => {
14782 workspace.managed_worktree_profiles = None;
14783 }
14784 NodeResponse::WorkspaceInspected { inspection } => {
14785 inspection.git.managed_worktree = None;
14786 }
14787 _ => {}
14788 }
14789}
14790
14791fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14792 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14793 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14794 });
14795 if includes_open_provider_ids {
14796 return;
14797 }
14798 compatibility
14799 .provider_contracts
14800 .retain(|contract| provider_id_is_legacy(&contract.provider));
14801 compatibility
14802 .provider_adapter_contracts
14803 .retain(|contract| provider_id_is_legacy(&contract.provider));
14804}
14805
14806fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14807 if let Some(spec) = match request {
14808 NodeRequest::SpawnSpec { spec }
14809 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14810 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14811 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14812 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14813 _ => None,
14814 } {
14815 return shared
14816 .resolve_spawn_spec(spec)
14817 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14818 }
14819 request_requires_open_provider_ids_with(
14820 request,
14821 |session| shared.validate_address(session).ok(),
14822 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14823 )
14824}
14825
14826fn request_requires_child_environment_profile(
14827 shared: &NodeShared,
14828 request: &NodeRequest,
14829) -> bool {
14830 if let Some(spec) = match request {
14831 NodeRequest::SpawnSpec { spec }
14832 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14833 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14834 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14835 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14836 _ => None,
14837 } {
14838 return shared
14839 .resolve_spawn_spec(spec)
14840 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14841 }
14842 match request {
14843 NodeRequest::Resume { session, .. }
14844 | NodeRequest::Prompt { session, .. }
14845 | NodeRequest::Paste { session, .. }
14846 | NodeRequest::Input { session, .. }
14847 | NodeRequest::TerminalBytes { session, .. }
14848 | NodeRequest::TerminalControl { session, .. }
14849 | NodeRequest::Resize { session, .. }
14850 | NodeRequest::Interrupt { session }
14851 | NodeRequest::Stop { session, .. }
14852 | NodeRequest::Remove { session }
14853 | NodeRequest::ResolveInteraction { session, .. }
14854 | NodeRequest::SetSessionMode { session, .. }
14855 | NodeRequest::SetSessionConfigOption { session, .. }
14856 | NodeRequest::SetSessionModel { session, .. }
14857 | NodeRequest::DiscoverHistory { session, .. }
14858 | NodeRequest::LoadHistory { session, .. }
14859 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14860 | NodeRequest::ExportContextPack { session } => shared
14861 .session_bindings
14862 .lock()
14863 .unwrap_or_else(|poisoned| poisoned.into_inner())
14864 .get(&session.session.instance_id)
14865 .filter(|binding| {
14866 binding.workspace_id == session.workspace_id
14867 && binding.generation == session.session.generation
14868 })
14869 .map_or(true, |binding| binding.environment_profile.is_some()),
14870 NodeRequest::RenameSessionRecord { record_id, .. }
14871 | NodeRequest::SetSessionTask { record_id, .. }
14872 | NodeRequest::ResumeSessionRecord { record_id, .. }
14873 | NodeRequest::ForgetSessionRecord { record_id } => shared
14874 .record(record_id)
14875 .map_or(true, |record| record.environment_profile.is_some()),
14876 NodeRequest::ArmHarnessMcpReservation { .. }
14877 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14878 | NodeRequest::ActivateHarnessMcpReservation { .. }
14879 | NodeRequest::AbortHarnessMcpReservation { .. }
14880 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14881 | NodeRequest::RejectHarnessMcpCall { .. }
14882 | NodeRequest::Snapshot
14883 | NodeRequest::Resync { .. }
14884 | NodeRequest::BeginDeliveryStage { .. }
14885 | NodeRequest::PutDeliveryBlobChunk { .. }
14886 | NodeRequest::CommitDeliveryStage { .. }
14887 | NodeRequest::AbortDeliveryStage { .. }
14888 | NodeRequest::BrowseHostDirectories { .. }
14889 | NodeRequest::InspectWorkspace { .. }
14890 | NodeRequest::ReadWorkspaceFile { .. }
14891 | NodeRequest::WriteWorkspaceFile { .. }
14892 | NodeRequest::CreateWorkspaceFile { .. }
14893 | NodeRequest::CreateWorkspaceDirectory { .. }
14894 | NodeRequest::ReadGitHistory { .. }
14895 | NodeRequest::ReadGitDiff { .. }
14896 | NodeRequest::AcquireController { .. }
14897 | NodeRequest::ReleaseController
14898 | NodeRequest::Spawn { .. }
14899 | NodeRequest::SpawnWithOptions { .. }
14900 | NodeRequest::SpawnSpec { .. }
14901 | NodeRequest::RegisterWorkspace { .. }
14902 | NodeRequest::CreateStandaloneWorkspace { .. }
14903 | NodeRequest::UnregisterWorkspace { .. }
14904 | NodeRequest::CreateWorktree { .. }
14905 | NodeRequest::RemoveWorktree { .. }
14906 | NodeRequest::SpawnManagedWorktree { .. }
14907 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14908 | NodeRequest::CleanupManagedWorktree { .. }
14909 | NodeRequest::IndexProviderSession { .. }
14910 | NodeRequest::IndexNativeSession { .. }
14911 | NodeRequest::CatalogNativeSessions { .. }
14912 | NodeRequest::PageNativeSessions { .. }
14913 | NodeRequest::PreviewNativeSession { .. }
14914 | NodeRequest::PreviewSessionRecord { .. }
14915 | NodeRequest::ForgetContextPack { .. }
14916 | NodeRequest::ResolveDurableContextPack { .. }
14917 | NodeRequest::ReadContextPack { .. }
14918 | NodeRequest::Shutdown => false,
14919 }
14920}
14921
14922fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14923 if let Some(spec) = match request {
14924 NodeRequest::SpawnSpec { spec }
14925 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14926 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14927 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14928 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14929 _ => None,
14930 } {
14931 return shared
14932 .resolve_spawn_spec(spec)
14933 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14934 }
14935 match request {
14936 NodeRequest::Resume { session, .. }
14937 | NodeRequest::Prompt { session, .. }
14938 | NodeRequest::Paste { session, .. }
14939 | NodeRequest::Input { session, .. }
14940 | NodeRequest::TerminalBytes { session, .. }
14941 | NodeRequest::TerminalControl { session, .. }
14942 | NodeRequest::Resize { session, .. }
14943 | NodeRequest::Interrupt { session }
14944 | NodeRequest::Stop { session, .. }
14945 | NodeRequest::Remove { session }
14946 | NodeRequest::ResolveInteraction { session, .. }
14947 | NodeRequest::SetSessionMode { session, .. }
14948 | NodeRequest::SetSessionConfigOption { session, .. }
14949 | NodeRequest::SetSessionModel { session, .. }
14950 | NodeRequest::DiscoverHistory { session, .. }
14951 | NodeRequest::LoadHistory { session, .. }
14952 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14953 | NodeRequest::ExportContextPack { session } => shared
14954 .session_bindings
14955 .lock()
14956 .unwrap_or_else(|poisoned| poisoned.into_inner())
14957 .get(&session.session.instance_id)
14958 .filter(|binding| {
14959 binding.workspace_id == session.workspace_id
14960 && binding.generation == session.session.generation
14961 })
14962 .map_or(true, |binding| binding.bundle.is_some()),
14963 NodeRequest::RenameSessionRecord { record_id, .. }
14964 | NodeRequest::SetSessionTask { record_id, .. }
14965 | NodeRequest::ResumeSessionRecord { record_id, .. }
14966 | NodeRequest::ForgetSessionRecord { record_id } => shared
14967 .record(record_id)
14968 .map_or(true, |record| record.bundle.is_some()),
14969 NodeRequest::ArmHarnessMcpReservation { .. }
14970 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14971 | NodeRequest::ActivateHarnessMcpReservation { .. }
14972 | NodeRequest::AbortHarnessMcpReservation { .. }
14973 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14974 | NodeRequest::RejectHarnessMcpCall { .. }
14975 | NodeRequest::Snapshot
14976 | NodeRequest::Resync { .. }
14977 | NodeRequest::BeginDeliveryStage { .. }
14978 | NodeRequest::PutDeliveryBlobChunk { .. }
14979 | NodeRequest::CommitDeliveryStage { .. }
14980 | NodeRequest::AbortDeliveryStage { .. }
14981 | NodeRequest::BrowseHostDirectories { .. }
14982 | NodeRequest::InspectWorkspace { .. }
14983 | NodeRequest::ReadWorkspaceFile { .. }
14984 | NodeRequest::WriteWorkspaceFile { .. }
14985 | NodeRequest::CreateWorkspaceFile { .. }
14986 | NodeRequest::CreateWorkspaceDirectory { .. }
14987 | NodeRequest::ReadGitHistory { .. }
14988 | NodeRequest::ReadGitDiff { .. }
14989 | NodeRequest::AcquireController { .. }
14990 | NodeRequest::ReleaseController
14991 | NodeRequest::Spawn { .. }
14992 | NodeRequest::SpawnWithOptions { .. }
14993 | NodeRequest::SpawnSpec { .. }
14994 | NodeRequest::RegisterWorkspace { .. }
14995 | NodeRequest::CreateStandaloneWorkspace { .. }
14996 | NodeRequest::UnregisterWorkspace { .. }
14997 | NodeRequest::CreateWorktree { .. }
14998 | NodeRequest::RemoveWorktree { .. }
14999 | NodeRequest::SpawnManagedWorktree { .. }
15000 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15001 | NodeRequest::CleanupManagedWorktree { .. }
15002 | NodeRequest::IndexProviderSession { .. }
15003 | NodeRequest::IndexNativeSession { .. }
15004 | NodeRequest::CatalogNativeSessions { .. }
15005 | NodeRequest::PageNativeSessions { .. }
15006 | NodeRequest::PreviewNativeSession { .. }
15007 | NodeRequest::PreviewSessionRecord { .. }
15008 | NodeRequest::ForgetContextPack { .. }
15009 | NodeRequest::ResolveDurableContextPack { .. }
15010 | NodeRequest::ReadContextPack { .. }
15011 | NodeRequest::Shutdown => false,
15012 }
15013}
15014
15015fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15016 if request.requires_history_context_pack_capability() {
15017 return true;
15018 }
15019 if let Some(spec) = match request {
15020 NodeRequest::SpawnSpec { spec }
15021 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15022 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15023 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15024 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15025 _ => None,
15026 } {
15027 return shared
15028 .resolve_spawn_spec(spec)
15029 .map_or(true, |resolved| resolved.context_id.is_some());
15030 }
15031 match request {
15032 NodeRequest::Resume { session, .. }
15033 | NodeRequest::Prompt { session, .. }
15034 | NodeRequest::Paste { session, .. }
15035 | NodeRequest::Input { session, .. }
15036 | NodeRequest::TerminalBytes { session, .. }
15037 | NodeRequest::TerminalControl { session, .. }
15038 | NodeRequest::Resize { session, .. }
15039 | NodeRequest::Interrupt { session }
15040 | NodeRequest::Stop { session, .. }
15041 | NodeRequest::Remove { session }
15042 | NodeRequest::ResolveInteraction { session, .. }
15043 | NodeRequest::SetSessionMode { session, .. }
15044 | NodeRequest::SetSessionConfigOption { session, .. }
15045 | NodeRequest::SetSessionModel { session, .. } => shared
15046 .session_bindings
15047 .lock()
15048 .unwrap_or_else(|poisoned| poisoned.into_inner())
15049 .get(&session.session.instance_id)
15050 .filter(|binding| {
15051 binding.workspace_id == session.workspace_id
15052 && binding.generation == session.session.generation
15053 })
15054 .map_or(true, |binding| binding.context.is_some()),
15055 NodeRequest::RenameSessionRecord { record_id, .. }
15056 | NodeRequest::SetSessionTask { record_id, .. }
15057 | NodeRequest::ResumeSessionRecord { record_id, .. }
15058 | NodeRequest::ForgetSessionRecord { record_id } => shared
15059 .record(record_id)
15060 .map_or(true, |record| record.context.is_some()),
15061 NodeRequest::ArmHarnessMcpReservation { .. }
15062 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15063 | NodeRequest::ActivateHarnessMcpReservation { .. }
15064 | NodeRequest::AbortHarnessMcpReservation { .. }
15065 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15066 | NodeRequest::RejectHarnessMcpCall { .. }
15067 | NodeRequest::Snapshot
15068 | NodeRequest::Resync { .. }
15069 | NodeRequest::BeginDeliveryStage { .. }
15070 | NodeRequest::PutDeliveryBlobChunk { .. }
15071 | NodeRequest::CommitDeliveryStage { .. }
15072 | NodeRequest::AbortDeliveryStage { .. }
15073 | NodeRequest::BrowseHostDirectories { .. }
15074 | NodeRequest::InspectWorkspace { .. }
15075 | NodeRequest::ReadWorkspaceFile { .. }
15076 | NodeRequest::WriteWorkspaceFile { .. }
15077 | NodeRequest::CreateWorkspaceFile { .. }
15078 | NodeRequest::CreateWorkspaceDirectory { .. }
15079 | NodeRequest::ReadGitHistory { .. }
15080 | NodeRequest::ReadGitDiff { .. }
15081 | NodeRequest::AcquireController { .. }
15082 | NodeRequest::ReleaseController
15083 | NodeRequest::Spawn { .. }
15084 | NodeRequest::SpawnWithOptions { .. }
15085 | NodeRequest::SpawnSpec { .. }
15086 | NodeRequest::RegisterWorkspace { .. }
15087 | NodeRequest::CreateStandaloneWorkspace { .. }
15088 | NodeRequest::UnregisterWorkspace { .. }
15089 | NodeRequest::CreateWorktree { .. }
15090 | NodeRequest::RemoveWorktree { .. }
15091 | NodeRequest::SpawnManagedWorktree { .. }
15092 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15093 | NodeRequest::CleanupManagedWorktree { .. }
15094 | NodeRequest::IndexProviderSession { .. }
15095 | NodeRequest::IndexNativeSession { .. }
15096 | NodeRequest::CatalogNativeSessions { .. }
15097 | NodeRequest::PageNativeSessions { .. }
15098 | NodeRequest::PreviewNativeSession { .. }
15099 | NodeRequest::PreviewSessionRecord { .. }
15100 | NodeRequest::DiscoverHistory { .. }
15101 | NodeRequest::LoadHistory { .. }
15102 | NodeRequest::ExportContextPackForSessionRecord { .. }
15103 | NodeRequest::ExportContextPack { .. }
15104 | NodeRequest::ForgetContextPack { .. }
15105 | NodeRequest::ResolveDurableContextPack { .. }
15106 | NodeRequest::ReadContextPack { .. }
15107 | NodeRequest::Shutdown => false,
15108 }
15109}
15110
15111fn request_requires_open_provider_ids_with(
15112 request: &NodeRequest,
15113 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15114 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15115) -> bool {
15116 match request {
15117 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15118 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15119 NodeRequest::CatalogNativeSessions { route, .. }
15120 | NodeRequest::PageNativeSessions { route, .. } => {
15121 !provider_id_is_legacy(&route.provider)
15122 }
15123 NodeRequest::PreviewNativeSession { selection, .. }
15124 | NodeRequest::IndexNativeSession { selection, .. } => {
15125 !provider_id_is_legacy(&selection.route.provider)
15126 }
15127 NodeRequest::SpawnSpec { .. }
15128 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15129 NodeRequest::SpawnManagedWorktree { .. }
15130 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15131 NodeRequest::Resume { session, .. }
15132 | NodeRequest::Prompt { session, .. }
15133 | NodeRequest::Paste { session, .. }
15134 | NodeRequest::Input { session, .. }
15135 | NodeRequest::TerminalBytes { session, .. }
15136 | NodeRequest::TerminalControl { session, .. }
15137 | NodeRequest::Resize { session, .. }
15138 | NodeRequest::Interrupt { session }
15139 | NodeRequest::Stop { session, .. }
15140 | NodeRequest::Remove { session }
15141 | NodeRequest::ResolveInteraction { session, .. }
15142 | NodeRequest::SetSessionMode { session, .. }
15143 | NodeRequest::SetSessionConfigOption { session, .. }
15144 | NodeRequest::SetSessionModel { session, .. }
15145 | NodeRequest::DiscoverHistory { session, .. }
15146 | NodeRequest::LoadHistory { session, .. }
15147 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15148 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15149 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15150 NodeRequest::RenameSessionRecord { record_id, .. }
15151 | NodeRequest::SetSessionTask { record_id, .. }
15152 | NodeRequest::ResumeSessionRecord { record_id, .. }
15153 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15154 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15155 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15156 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15157 NodeRequest::ArmHarnessMcpReservation { .. }
15158 | NodeRequest::ActivateHarnessMcpReservation { .. }
15159 | NodeRequest::AbortHarnessMcpReservation { .. }
15160 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15161 | NodeRequest::RejectHarnessMcpCall { .. }
15162 | NodeRequest::Snapshot
15163 | NodeRequest::Resync { .. }
15164 | NodeRequest::BeginDeliveryStage { .. }
15165 | NodeRequest::PutDeliveryBlobChunk { .. }
15166 | NodeRequest::CommitDeliveryStage { .. }
15167 | NodeRequest::AbortDeliveryStage { .. }
15168 | NodeRequest::BrowseHostDirectories { .. }
15169 | NodeRequest::InspectWorkspace { .. }
15170 | NodeRequest::ReadWorkspaceFile { .. }
15171 | NodeRequest::WriteWorkspaceFile { .. }
15172 | NodeRequest::CreateWorkspaceFile { .. }
15173 | NodeRequest::CreateWorkspaceDirectory { .. }
15174 | NodeRequest::ReadGitHistory { .. }
15175 | NodeRequest::ReadGitDiff { .. }
15176 | NodeRequest::AcquireController { .. }
15177 | NodeRequest::ReleaseController
15178 | NodeRequest::RegisterWorkspace { .. }
15179 | NodeRequest::CreateStandaloneWorkspace { .. }
15180 | NodeRequest::UnregisterWorkspace { .. }
15181 | NodeRequest::CreateWorktree { .. }
15182 | NodeRequest::RemoveWorktree { .. }
15183 | NodeRequest::CleanupManagedWorktree { .. }
15184 | NodeRequest::Shutdown => false,
15185 NodeRequest::ForgetContextPack { .. } => true,
15186 NodeRequest::ResolveDurableContextPack { .. }
15192 | NodeRequest::ReadContextPack { .. } => true,
15193 }
15194}
15195
15196fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15197 snapshot.enabled_providers.retain(provider_id_is_legacy);
15198 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15199 snapshot
15200 .provider_runtime_statuses
15201 .as_slice()
15202 .iter()
15203 .filter(|status| provider_id_is_legacy(status.provider()))
15204 .cloned(),
15205 )
15206 .expect("a filtered provider runtime projection remains valid");
15207 snapshot
15208 .session_records
15209 .retain(|record| provider_id_is_legacy(&record.provider));
15210 for workspace in &mut snapshot.workspaces {
15211 workspace
15212 .sessions
15213 .retain(|session| provider_id_is_legacy(&session.agent_id));
15214 }
15215}
15216
15217fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15218 for record in &mut snapshot.session_records {
15219 record.environment_profile = None;
15220 }
15221 if let Some(profiles) = snapshot
15222 .launch_inventory
15223 .as_mut()
15224 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15225 {
15226 for profile in profiles {
15227 profile.environment_profile = None;
15228 }
15229 }
15230}
15231
15232fn project_event_without_child_environment_profile(
15233 mut envelope: NodeEventEnvelope,
15234) -> NodeEventEnvelope {
15235 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15236 record.environment_profile = None;
15237 }
15238 envelope
15239}
15240
15241fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15242 let contains_environment_profile = match reply.result.as_ref() {
15243 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15244 receipt.environment_profile.is_some()
15245 }
15246 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15247 receipt.spawn.environment_profile.is_some()
15248 }
15249 _ => false,
15250 };
15251 if contains_environment_profile {
15252 reply.result = Err(failure(
15253 NodeFailureCode::UnsupportedCapability,
15254 "child environment profiles were not negotiated",
15255 ));
15256 return;
15257 }
15258 let Ok(response) = reply.result.as_mut() else {
15259 return;
15260 };
15261 match response {
15262 NodeResponse::Snapshot { snapshot, .. } => {
15263 clear_snapshot_child_environment_profiles(snapshot);
15264 }
15265 NodeResponse::Resync { snapshot, events, .. } => {
15266 clear_snapshot_child_environment_profiles(snapshot);
15267 for event in events {
15268 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15269 record.environment_profile = None;
15270 }
15271 }
15272 }
15273 NodeResponse::SessionRecordUpdated { record }
15274 | NodeResponse::ProviderSessionIndexed { record }
15275 | NodeResponse::NativeSessionIndexed { record, .. }
15276 | NodeResponse::SessionRecordResumed { record, .. } => {
15277 record.environment_profile = None;
15278 }
15279 NodeResponse::Armed { .. }
15280 | NodeResponse::Spawned { .. }
15281 | NodeResponse::Activated { .. }
15282 | NodeResponse::Aborted { .. }
15283 | NodeResponse::ReplyChunkAccepted { .. }
15284 | NodeResponse::CallRejected { .. }
15285 | NodeResponse::DeliveryStageBegun { .. }
15286 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15287 | NodeResponse::DeliveryCommitted { .. }
15288 | NodeResponse::DeliveryStageAborted { .. }
15289 | NodeResponse::WorkspaceInspected { .. }
15290 | NodeResponse::HostDirectoriesBrowsed { .. }
15291 | NodeResponse::WorkspaceFileRead { .. }
15292 | NodeResponse::WorkspaceFileWritten { .. }
15293 | NodeResponse::WorkspaceFileCreated { .. }
15294 | NodeResponse::WorkspaceDirectoryCreated { .. }
15295 | NodeResponse::GitHistoryRead { .. }
15296 | NodeResponse::GitDiffRead { .. }
15297 | NodeResponse::Controller { .. }
15298 | NodeResponse::SpawnAccepted { .. }
15299 | NodeResponse::SpawnSpecAccepted { .. }
15300 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15301 | NodeResponse::ManagedWorktreeCleanup { .. }
15302 | NodeResponse::NativeSessionsCataloged { .. }
15303 | NodeResponse::NativeSessionsPaged { .. }
15304 | NodeResponse::NativeSessionPreviewed { .. }
15305 | NodeResponse::SessionRecordPreviewed { .. }
15306 | NodeResponse::HistoryDiscovered { .. }
15307 | NodeResponse::HistoryLoaded { .. }
15308 | NodeResponse::ContextPackForSessionRecordExported { .. }
15309 | NodeResponse::ContextPackExported { .. }
15310 | NodeResponse::ContextPackForgotten { .. }
15311 | NodeResponse::DurableContextPackResolved { .. }
15312 | NodeResponse::ContextPackBytesRead { .. }
15313 | NodeResponse::SessionRecordForgotten { .. }
15314 | NodeResponse::WorkspaceRegistered { .. }
15315 | NodeResponse::StandaloneWorkspaceCreated { .. }
15316 | NodeResponse::WorkspaceUnregistered { .. }
15317 | NodeResponse::WorktreeCreated { .. }
15318 | NodeResponse::WorktreeRemoved { .. }
15319 | NodeResponse::Accepted
15320 | NodeResponse::ShuttingDown => {}
15321 }
15322}
15323
15324fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15325 for record in &mut snapshot.session_records {
15326 record.bundle = None;
15327 }
15328}
15329
15330fn project_event_without_session_bundle(
15331 mut envelope: NodeEventEnvelope,
15332) -> NodeEventEnvelope {
15333 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15334 record.bundle = None;
15335 }
15336 envelope
15337}
15338
15339fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15340 let contains_bundle = match reply.result.as_ref() {
15341 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15342 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15343 receipt.spawn.bundle.is_some()
15344 }
15345 _ => false,
15346 };
15347 if contains_bundle {
15348 reply.result = Err(failure(
15349 NodeFailureCode::UnsupportedCapability,
15350 "session bundles were not negotiated",
15351 ));
15352 return;
15353 }
15354 let Ok(response) = reply.result.as_mut() else {
15355 return;
15356 };
15357 match response {
15358 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15359 NodeResponse::Resync { snapshot, events, .. } => {
15360 clear_snapshot_session_bundles(snapshot);
15361 for event in events {
15362 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15363 record.bundle = None;
15364 }
15365 }
15366 }
15367 NodeResponse::SessionRecordUpdated { record }
15368 | NodeResponse::ProviderSessionIndexed { record }
15369 | NodeResponse::NativeSessionIndexed { record, .. }
15370 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15371 NodeResponse::Armed { .. }
15372 | NodeResponse::Spawned { .. }
15373 | NodeResponse::Activated { .. }
15374 | NodeResponse::Aborted { .. }
15375 | NodeResponse::ReplyChunkAccepted { .. }
15376 | NodeResponse::CallRejected { .. }
15377 | NodeResponse::DeliveryStageBegun { .. }
15378 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15379 | NodeResponse::DeliveryCommitted { .. }
15380 | NodeResponse::DeliveryStageAborted { .. }
15381 | NodeResponse::WorkspaceInspected { .. }
15382 | NodeResponse::HostDirectoriesBrowsed { .. }
15383 | NodeResponse::WorkspaceFileRead { .. }
15384 | NodeResponse::WorkspaceFileWritten { .. }
15385 | NodeResponse::WorkspaceFileCreated { .. }
15386 | NodeResponse::WorkspaceDirectoryCreated { .. }
15387 | NodeResponse::GitHistoryRead { .. }
15388 | NodeResponse::GitDiffRead { .. }
15389 | NodeResponse::Controller { .. }
15390 | NodeResponse::SpawnAccepted { .. }
15391 | NodeResponse::SpawnSpecAccepted { .. }
15392 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15393 | NodeResponse::ManagedWorktreeCleanup { .. }
15394 | NodeResponse::NativeSessionsCataloged { .. }
15395 | NodeResponse::NativeSessionsPaged { .. }
15396 | NodeResponse::NativeSessionPreviewed { .. }
15397 | NodeResponse::SessionRecordPreviewed { .. }
15398 | NodeResponse::HistoryDiscovered { .. }
15399 | NodeResponse::HistoryLoaded { .. }
15400 | NodeResponse::ContextPackForSessionRecordExported { .. }
15401 | NodeResponse::ContextPackExported { .. }
15402 | NodeResponse::ContextPackForgotten { .. }
15403 | NodeResponse::DurableContextPackResolved { .. }
15404 | NodeResponse::ContextPackBytesRead { .. }
15405 | NodeResponse::SessionRecordForgotten { .. }
15406 | NodeResponse::WorkspaceRegistered { .. }
15407 | NodeResponse::StandaloneWorkspaceCreated { .. }
15408 | NodeResponse::WorkspaceUnregistered { .. }
15409 | NodeResponse::WorktreeCreated { .. }
15410 | NodeResponse::WorktreeRemoved { .. }
15411 | NodeResponse::Accepted
15412 | NodeResponse::ShuttingDown => {}
15413 }
15414}
15415
15416fn project_snapshot_history_for_wire(
15417 snapshot: &mut NodeSnapshot,
15418 include_history_metadata: bool,
15419) {
15420 for workspace in &mut snapshot.workspaces {
15421 for session in &mut workspace.sessions {
15422 if include_history_metadata {
15423 session.history.loaded = None;
15424 } else {
15425 session.history = Default::default();
15426 }
15427 }
15428 }
15429}
15430
15431fn project_event_history_for_wire(
15432 mut envelope: NodeEventEnvelope,
15433 include_history_metadata: bool,
15434) -> NodeEventEnvelope {
15435 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15436 for session in &mut workspace.sessions {
15437 if include_history_metadata {
15438 session.history.loaded = None;
15439 } else {
15440 session.history = Default::default();
15441 }
15442 }
15443 }
15444 envelope
15445}
15446
15447fn project_response_history_for_wire(
15448 reply: &mut ResponseEnvelope,
15449 include_history_metadata: bool,
15450) {
15451 let Ok(response) = reply.result.as_mut() else {
15452 return;
15453 };
15454 match response {
15455 NodeResponse::Snapshot { snapshot, .. } => {
15456 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15457 }
15458 NodeResponse::Resync {
15459 snapshot, events, ..
15460 } => {
15461 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15462 for event in events {
15463 *event = project_event_history_for_wire(
15464 event.clone(),
15465 include_history_metadata,
15466 );
15467 }
15468 }
15469 NodeResponse::WorkspaceRegistered { workspace }
15470 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15471 | NodeResponse::WorktreeCreated { workspace, .. } => {
15472 for session in &mut workspace.sessions {
15473 if include_history_metadata {
15474 session.history.loaded = None;
15475 } else {
15476 session.history = Default::default();
15477 }
15478 }
15479 }
15480 _ => {}
15481 }
15482}
15483
15484fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15485 for record in &mut snapshot.session_records {
15486 record.context_id = None;
15487 record.context = None;
15488 record.exported_context = None;
15489 }
15490}
15491
15492fn project_event_without_context_pack(
15493 mut envelope: NodeEventEnvelope,
15494) -> NodeEventEnvelope {
15495 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15496 record.context_id = None;
15497 record.context = None;
15498 record.exported_context = None;
15499 }
15500 envelope
15501}
15502
15503fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15504 let contains_context = match reply.result.as_ref() {
15505 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15506 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15507 receipt.spawn.context.is_some()
15508 }
15509 Ok(NodeResponse::HistoryDiscovered { .. })
15510 | Ok(NodeResponse::HistoryLoaded { .. })
15511 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15512 | Ok(NodeResponse::ContextPackExported { .. })
15513 | Ok(NodeResponse::ContextPackForgotten { .. })
15514 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15515 _ => false,
15516 };
15517 if contains_context {
15518 reply.result = Err(failure(
15519 NodeFailureCode::UnsupportedCapability,
15520 "history context packs were not negotiated",
15521 ));
15522 return;
15523 }
15524 let Ok(response) = reply.result.as_mut() else {
15525 return;
15526 };
15527 match response {
15528 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15529 NodeResponse::Resync {
15530 snapshot, events, ..
15531 } => {
15532 clear_snapshot_context_packs(snapshot);
15533 for event in events {
15534 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15535 record.context_id = None;
15536 record.context = None;
15537 record.exported_context = None;
15538 }
15539 }
15540 }
15541 NodeResponse::SessionRecordUpdated { record }
15542 | NodeResponse::ProviderSessionIndexed { record }
15543 | NodeResponse::NativeSessionIndexed { record, .. }
15544 | NodeResponse::SessionRecordResumed { record, .. } => {
15545 record.context_id = None;
15546 record.context = None;
15547 record.exported_context = None;
15548 }
15549 _ => {}
15550 }
15551}
15552
15553fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15554 let contains_open_record = match reply.result.as_ref() {
15555 Ok(NodeResponse::SessionRecordUpdated { record })
15556 | Ok(NodeResponse::ProviderSessionIndexed { record })
15557 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15558 !provider_id_is_legacy(&record.provider)
15559 }
15560 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15561 !provider_id_is_legacy(&selection.route.provider)
15562 || !provider_id_is_legacy(&record.provider)
15563 }
15564 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15565 !provider_id_is_legacy(&receipt.provider)
15566 }
15567 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15568 !provider_id_is_legacy(&receipt.spawn.provider)
15569 }
15570 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15571 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15572 !provider_id_is_legacy(&route.provider)
15573 }
15574 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15575 !provider_id_is_legacy(&selection.route.provider)
15576 }
15577 _ => false,
15578 };
15579 if contains_open_record {
15580 reply.result = Err(failure(
15581 NodeFailureCode::UnsupportedCapability,
15582 "open provider IDs were not negotiated",
15583 ));
15584 return;
15585 }
15586 let Ok(response) = reply.result.as_mut() else {
15587 return;
15588 };
15589 match response {
15590 NodeResponse::Snapshot { snapshot, .. } => {
15591 project_snapshot_legacy_provider_ids(snapshot);
15592 }
15593 NodeResponse::Resync { snapshot, events, .. } => {
15594 project_snapshot_legacy_provider_ids(snapshot);
15595 *events = std::mem::take(events)
15596 .into_iter()
15597 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15598 .collect();
15599 }
15600 NodeResponse::WorkspaceRegistered { workspace }
15601 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15602 | NodeResponse::WorktreeCreated { workspace, .. } => {
15603 workspace
15604 .sessions
15605 .retain(|session| provider_id_is_legacy(&session.agent_id));
15606 }
15607 NodeResponse::Armed { .. }
15608 | NodeResponse::Spawned { .. }
15609 | NodeResponse::Activated { .. }
15610 | NodeResponse::Aborted { .. }
15611 | NodeResponse::ReplyChunkAccepted { .. }
15612 | NodeResponse::CallRejected { .. }
15613 | NodeResponse::DeliveryStageBegun { .. }
15614 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15615 | NodeResponse::DeliveryCommitted { .. }
15616 | NodeResponse::DeliveryStageAborted { .. }
15617 | NodeResponse::WorkspaceInspected { .. }
15618 | NodeResponse::HostDirectoriesBrowsed { .. }
15619 | NodeResponse::WorkspaceFileRead { .. }
15620 | NodeResponse::WorkspaceFileWritten { .. }
15621 | NodeResponse::WorkspaceFileCreated { .. }
15622 | NodeResponse::WorkspaceDirectoryCreated { .. }
15623 | NodeResponse::GitHistoryRead { .. }
15624 | NodeResponse::GitDiffRead { .. }
15625 | NodeResponse::Controller { .. }
15626 | NodeResponse::SpawnAccepted { .. }
15627 | NodeResponse::SpawnSpecAccepted { .. }
15628 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15629 | NodeResponse::ManagedWorktreeCleanup { .. }
15630 | NodeResponse::NativeSessionsCataloged { .. }
15631 | NodeResponse::NativeSessionsPaged { .. }
15632 | NodeResponse::NativeSessionPreviewed { .. }
15633 | NodeResponse::SessionRecordPreviewed { .. }
15634 | NodeResponse::HistoryDiscovered { .. }
15635 | NodeResponse::HistoryLoaded { .. }
15636 | NodeResponse::ContextPackForSessionRecordExported { .. }
15637 | NodeResponse::ContextPackExported { .. }
15638 | NodeResponse::ContextPackForgotten { .. }
15639 | NodeResponse::DurableContextPackResolved { .. }
15640 | NodeResponse::ContextPackBytesRead { .. }
15641 | NodeResponse::SessionRecordUpdated { .. }
15642 | NodeResponse::ProviderSessionIndexed { .. }
15643 | NodeResponse::NativeSessionIndexed { .. }
15644 | NodeResponse::SessionRecordResumed { .. }
15645 | NodeResponse::SessionRecordForgotten { .. }
15646 | NodeResponse::WorkspaceUnregistered { .. }
15647 | NodeResponse::WorktreeRemoved { .. }
15648 | NodeResponse::Accepted
15649 | NodeResponse::ShuttingDown => {}
15650 }
15651}
15652
15653fn project_event_legacy_provider_ids(
15654 shared: &NodeShared,
15655 mut envelope: NodeEventEnvelope,
15656) -> Option<NodeEventEnvelope> {
15657 let include = match &mut envelope.event {
15658 NodeEvent::HarnessMcpReadCall { .. } => true,
15659 NodeEvent::Control { address, .. }
15660 | NodeEvent::AgentStream { address, .. }
15661 | NodeEvent::TerminalFrame { address, .. } => shared
15662 .validate_address(address)
15663 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15664 NodeEvent::WorkspaceAdded { workspace } => {
15665 workspace
15666 .sessions
15667 .retain(|session| provider_id_is_legacy(&session.agent_id));
15668 true
15669 }
15670 NodeEvent::SessionRecordUpserted { record } => {
15671 provider_id_is_legacy(&record.provider)
15672 }
15673 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15674 NodeEvent::SessionRecordRemoved { .. } => shared
15675 .history
15676 .lock()
15677 .unwrap_or_else(|poisoned| poisoned.into_inner())
15678 .removed_record_providers
15679 .get(&envelope.sequence)
15680 .is_some_and(provider_id_is_legacy),
15681 NodeEvent::ControllerChanged { .. }
15682 | NodeEvent::ManagedWorktreeUpserted { .. }
15683 | NodeEvent::ManagedWorktreeRemoved { .. }
15684 | NodeEvent::WorkspaceRemoved { .. }
15685 | NodeEvent::ResyncRequired { .. } => true,
15686 };
15687 include.then_some(envelope)
15688}
15689
15690fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15691 let Ok(response) = reply.result.as_mut() else {
15692 return;
15693 };
15694 match response {
15695 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15696 snapshot.provider_runtime_statuses.clear();
15697 }
15698 NodeResponse::Armed { .. }
15699 | NodeResponse::Spawned { .. }
15700 | NodeResponse::Activated { .. }
15701 | NodeResponse::Aborted { .. }
15702 | NodeResponse::ReplyChunkAccepted { .. }
15703 | NodeResponse::CallRejected { .. }
15704 | NodeResponse::DeliveryStageBegun { .. }
15705 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15706 | NodeResponse::DeliveryCommitted { .. }
15707 | NodeResponse::DeliveryStageAborted { .. }
15708 | NodeResponse::WorkspaceInspected { .. }
15709 | NodeResponse::HostDirectoriesBrowsed { .. }
15710 | NodeResponse::WorkspaceFileRead { .. }
15711 | NodeResponse::WorkspaceFileWritten { .. }
15712 | NodeResponse::WorkspaceFileCreated { .. }
15713 | NodeResponse::WorkspaceDirectoryCreated { .. }
15714 | NodeResponse::GitHistoryRead { .. }
15715 | NodeResponse::GitDiffRead { .. }
15716 | NodeResponse::Controller { .. }
15717 | NodeResponse::SpawnAccepted { .. }
15718 | NodeResponse::SpawnSpecAccepted { .. }
15719 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15720 | NodeResponse::ManagedWorktreeCleanup { .. }
15721 | NodeResponse::NativeSessionsCataloged { .. }
15722 | NodeResponse::NativeSessionsPaged { .. }
15723 | NodeResponse::NativeSessionPreviewed { .. }
15724 | NodeResponse::SessionRecordPreviewed { .. }
15725 | NodeResponse::HistoryDiscovered { .. }
15726 | NodeResponse::HistoryLoaded { .. }
15727 | NodeResponse::ContextPackForSessionRecordExported { .. }
15728 | NodeResponse::ContextPackExported { .. }
15729 | NodeResponse::ContextPackForgotten { .. }
15730 | NodeResponse::DurableContextPackResolved { .. }
15731 | NodeResponse::ContextPackBytesRead { .. }
15732 | NodeResponse::SessionRecordUpdated { .. }
15733 | NodeResponse::ProviderSessionIndexed { .. }
15734 | NodeResponse::NativeSessionIndexed { .. }
15735 | NodeResponse::SessionRecordResumed { .. }
15736 | NodeResponse::SessionRecordForgotten { .. }
15737 | NodeResponse::WorkspaceRegistered { .. }
15738 | NodeResponse::StandaloneWorkspaceCreated { .. }
15739 | NodeResponse::WorkspaceUnregistered { .. }
15740 | NodeResponse::WorktreeCreated { .. }
15741 | NodeResponse::WorktreeRemoved { .. }
15742 | NodeResponse::Accepted
15743 | NodeResponse::ShuttingDown => {}
15744 }
15745}
15746
15747struct ControllerReleaseGuard {
15748 shared: Arc<NodeShared>,
15749 connection_id: u64,
15750}
15751
15752impl Drop for ControllerReleaseGuard {
15753 fn drop(&mut self) {
15754 self.shared.release_controller(self.connection_id);
15755 }
15756}
15757
15758struct AbortTaskOnDrop(AbortHandle);
15759
15760impl Drop for AbortTaskOnDrop {
15761 fn drop(&mut self) {
15762 self.0.abort();
15763 }
15764}
15765
15766fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15771 std::env::var(name)
15772 .ok()
15773 .and_then(|value| value.parse::<u64>().ok())
15774 .filter(|value| *value > 0)
15775 .unwrap_or(default)
15776 .min(max)
15777}
15778
15779fn workspace_inspection_time_budget_ms() -> u64 {
15780 env_config_bounded(
15781 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15782 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15783 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15784 )
15785}
15786
15787fn workspace_inspection_entry_cap() -> usize {
15788 env_config_bounded(
15789 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15790 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15791 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15792 ) as usize
15793}
15794
15795struct WorkspaceWalkBudget {
15801 deadline: Instant,
15802 entry_cap: usize,
15803 entries_visited: u64,
15804 time_budget_exceeded: bool,
15805 entry_cap_exceeded: bool,
15806}
15807
15808impl WorkspaceWalkBudget {
15809 fn new(deadline: Instant, entry_cap: usize) -> Self {
15810 Self {
15811 deadline,
15812 entry_cap,
15813 entries_visited: 0,
15814 time_budget_exceeded: false,
15815 entry_cap_exceeded: false,
15816 }
15817 }
15818
15819 fn is_exhausted(&self) -> bool {
15820 self.time_budget_exceeded || self.entry_cap_exceeded
15821 }
15822
15823 fn record_visit(&mut self) -> bool {
15826 self.entries_visited += 1;
15827 if self.entries_visited as usize >= self.entry_cap {
15828 self.entry_cap_exceeded = true;
15829 }
15830 if Instant::now() >= self.deadline {
15831 self.time_budget_exceeded = true;
15832 }
15833 self.is_exhausted()
15834 }
15835}
15836
15837fn collect_workspace_entries(
15838 root: &Path,
15839 deadline: Instant,
15840 entry_cap: usize,
15841) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15842 let mut entries = Vec::new();
15843 let mut truncated = false;
15844 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15845 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15846 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15850 (entries, truncated, budget)
15851}
15852
15853fn workspace_inspection_truncation(
15857 walk_budget: &WorkspaceWalkBudget,
15858 git_time_budget_exceeded: bool,
15859 elapsed: Duration,
15860) -> Option<WorkspaceInspectionTruncationV1> {
15861 if !walk_budget.time_budget_exceeded
15862 && !walk_budget.entry_cap_exceeded
15863 && !git_time_budget_exceeded
15864 {
15865 return None;
15866 }
15867 Some(WorkspaceInspectionTruncationV1 {
15868 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15869 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15870 git_time_budget_exceeded,
15871 entries_visited: walk_budget.entries_visited,
15872 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15873 })
15874}
15875
15876fn walk_workspace_directory(
15877 directory: &Path,
15878 relative_directory: Option<&RepositoryPath>,
15879 depth: usize,
15880 entries: &mut Vec<WorkspaceEntry>,
15881 truncated: &mut bool,
15882 budget: &mut WorkspaceWalkBudget,
15883) {
15884 if budget.is_exhausted() {
15885 *truncated = true;
15886 return;
15887 }
15888 let read_dir = match std::fs::read_dir(directory) {
15889 Ok(read_dir) => read_dir,
15890 Err(_) => {
15891 *truncated = true;
15892 return;
15893 }
15894 };
15895 let mut children = Vec::new();
15896 for child in read_dir {
15897 match child {
15898 Ok(child) => children.push(child),
15899 Err(_) => *truncated = true,
15900 }
15901 }
15902 children.sort_by_key(|child| child.file_name());
15903 for child in children {
15904 if budget.record_visit() {
15905 *truncated = true;
15906 return;
15907 }
15908 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15909 *truncated = true;
15910 return;
15911 }
15912 let file_type = match child.file_type() {
15913 Ok(file_type) => file_type,
15914 Err(_) => {
15915 *truncated = true;
15916 continue;
15917 }
15918 };
15919 if file_type.is_symlink() {
15920 continue;
15921 }
15922 let name = match child.file_name().into_string() {
15923 Ok(name) => name,
15924 Err(_) => {
15925 *truncated = true;
15926 continue;
15927 }
15928 };
15929 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15930 continue;
15931 }
15932 let relative_path = match relative_directory {
15933 None => windows_repository_path(name),
15934 Some(parent) => parent
15935 .as_utf8()
15936 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15937 };
15938 let Some(relative_path) = relative_path else {
15939 *truncated = true;
15940 continue;
15941 };
15942 let kind = if file_type.is_dir() {
15943 WorkspaceEntryKind::Directory
15944 } else if file_type.is_file() {
15945 WorkspaceEntryKind::File
15946 } else {
15947 continue;
15948 };
15949 entries.push(WorkspaceEntry {
15950 relative_path: relative_path.clone(),
15951 kind,
15952 });
15953 if file_type.is_dir() {
15954 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15955 walk_workspace_directory(
15956 &child.path(),
15957 Some(&relative_path),
15958 depth + 1,
15959 entries,
15960 truncated,
15961 budget,
15962 );
15963 } else {
15964 *truncated = true;
15965 }
15966 }
15967 }
15968}
15969
15970fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15971 if value.contains('\\') {
15972 return None;
15973 }
15974 RepositoryPath::utf8(value).ok()
15975}
15976
15977fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15978 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15979}
15980
15981fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15990 if name.eq_ignore_ascii_case(".git")
15991 || name.eq_ignore_ascii_case("target")
15992 || name.eq_ignore_ascii_case("node_modules")
15993 {
15994 return true;
15995 }
15996 path.join("CACHEDIR.TAG").is_file()
15997}
15998
15999fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16000 matches!(
16001 status,
16002 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16003 )
16004}
16005
16006#[derive(Clone, Debug, Eq, PartialEq)]
16007enum ContextPackRepositoryHead {
16008 NotRepository,
16009 Unborn,
16010 Commit(String),
16011}
16012
16013#[derive(Clone, Debug, Eq, PartialEq)]
16014struct ContextPackRepositoryObservation {
16015 head: ContextPackRepositoryHead,
16016 git: GitSnapshot,
16017 files: Vec<ContextPackRepositoryFileSource>,
16018}
16019
16020fn stable_context_pack_repository(
16021 first: ContextPackRepositoryObservation,
16022 second: ContextPackRepositoryObservation,
16023) -> Result<ContextPackRepository, NodeFailure> {
16024 if first != second {
16025 return Err(failure(
16026 NodeFailureCode::ContextPackMaterializationFailed,
16027 "context repository changed during bounded capture",
16028 ));
16029 }
16030 Ok(ContextPackRepository::from_sources(
16031 &second.git,
16032 second.files,
16033 ))
16034}
16035
16036fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16043 if Instant::now() < deadline {
16044 return false;
16045 }
16046 append_git_diagnostic(
16047 snapshot,
16048 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16049 );
16050 true
16051}
16052
16053async fn run_git_bounded_with_deadline(
16062 root: &str,
16063 arguments: &[&str],
16064 output_limit: usize,
16065 deadline: Instant,
16066) -> io::Result<GitCommandOutput> {
16067 let remaining = deadline.checked_duration_since(Instant::now());
16068 let timeout_ms = remaining
16069 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16070 .unwrap_or(0)
16071 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16072 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16073}
16074
16075async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16082 let mut snapshot = GitSnapshot {
16083 is_repository: false,
16084 branch: None,
16085 status: Vec::new(),
16086 recent_commits: Vec::new(),
16087 worktrees: Vec::new(),
16088 managed_worktree: None,
16089 truncated: false,
16090 diagnostic: None,
16091 };
16092 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16093 return (snapshot, true);
16094 }
16095 let repository = match run_git_bounded_with_deadline(
16096 root,
16097 &["rev-parse", "--is-inside-work-tree"],
16098 4 * 1_024,
16099 deadline,
16100 )
16101 .await
16102 {
16103 Ok(output) => output,
16104 Err(error) => {
16105 append_git_diagnostic(
16106 &mut snapshot,
16107 &format!("git inspection unavailable: {error}"),
16108 );
16109 return (snapshot, false);
16110 }
16111 };
16112 snapshot.truncated |= repository.truncated;
16113 if repository.timed_out {
16114 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16115 return (snapshot, false);
16116 }
16117 if !repository.success
16118 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16119 {
16120 let stderr = String::from_utf8_lossy(&repository.stderr);
16121 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16122 append_git_diagnostic(&mut snapshot, stderr.trim());
16123 }
16124 return (snapshot, false);
16125 }
16126 snapshot.is_repository = true;
16127
16128 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16129 return (snapshot, true);
16130 }
16131 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16132 Ok(output) => {
16133 snapshot.truncated |= output.truncated;
16134 if output.timed_out {
16135 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16136 } else if output.success {
16137 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16138 if !branch.is_empty() {
16139 snapshot.branch = Some(truncate_git_field(
16140 &branch,
16141 MAX_REPOSITORY_PATH_BYTES,
16142 &mut snapshot.truncated,
16143 ));
16144 }
16145 }
16146 }
16147 Err(error) => append_git_diagnostic(
16148 &mut snapshot,
16149 &format!("git branch query failed: {error}"),
16150 ),
16151 }
16152 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16153 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16154 snapshot.truncated |= output.truncated;
16155 if output.success && !output.timed_out {
16156 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16157 if !head.is_empty() {
16158 snapshot.branch = Some(format!("detached:{head}"));
16159 }
16160 }
16161 }
16162 }
16163
16164 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16165 return (snapshot, true);
16166 }
16167 match run_git_bounded_with_deadline(
16168 root,
16169 &[
16170 "status",
16171 "--porcelain=v1",
16172 "-z",
16173 "--renames",
16174 "--untracked-files=normal",
16175 "--",
16176 ".",
16177 ],
16178 GIT_OUTPUT_MAX_BYTES,
16179 deadline,
16180 )
16181 .await
16182 {
16183 Ok(output) => {
16184 snapshot.truncated |= output.truncated;
16185 if output.timed_out {
16186 append_git_diagnostic(&mut snapshot, "git status timed out");
16187 } else if output.success {
16188 parse_git_status(&output.stdout, &mut snapshot);
16189 } else {
16190 append_git_diagnostic(
16191 &mut snapshot,
16192 String::from_utf8_lossy(&output.stderr).trim(),
16193 );
16194 }
16195 }
16196 Err(error) => append_git_diagnostic(
16197 &mut snapshot,
16198 &format!("git status failed: {error}"),
16199 ),
16200 }
16201
16202 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16203 return (snapshot, true);
16204 }
16205 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16206 match run_git_bounded_with_deadline(
16207 root,
16208 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16209 16 * 1_024,
16210 deadline,
16211 )
16212 .await
16213 {
16214 Ok(output) => {
16215 snapshot.truncated |= output.truncated;
16216 if output.timed_out {
16217 append_git_diagnostic(&mut snapshot, "git log timed out");
16218 } else if output.success {
16219 parse_git_commits(&output.stdout, &mut snapshot);
16220 } else {
16221 let stderr = String::from_utf8_lossy(&output.stderr);
16222 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16223 append_git_diagnostic(&mut snapshot, stderr.trim());
16224 }
16225 }
16226 }
16227 Err(error) => append_git_diagnostic(
16228 &mut snapshot,
16229 &format!("git log failed: {error}"),
16230 ),
16231 }
16232
16233 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16234 return (snapshot, true);
16235 }
16236 match list_git_worktrees_with_deadline(root, deadline).await {
16237 Ok(worktrees) => {
16238 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16239 }
16240 Err(error) => append_git_diagnostic(
16241 &mut snapshot,
16242 &format!("git worktree list failed: {}", error.message),
16243 ),
16244 }
16245 (snapshot, false)
16246}
16247
16248fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16249 let mut remaining = output;
16250 while !remaining.is_empty() {
16251 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16252 snapshot.truncated = true;
16253 break;
16254 }
16255 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16256 snapshot.truncated = true;
16257 break;
16258 };
16259 let record = &remaining[..record_end];
16260 remaining = &remaining[record_end + 1..];
16261 let has_previous_path = record
16262 .get(..2)
16263 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16264 let previous_path_bytes = if has_previous_path {
16265 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16266 snapshot.truncated = true;
16267 break;
16268 };
16269 let previous = &remaining[..previous_end];
16270 remaining = &remaining[previous_end + 1..];
16271 Some(previous)
16272 } else {
16273 None
16274 };
16275 if record.len() < 4
16276 || record[2] != b' '
16277 || !is_git_status_code(record[0])
16278 || !is_git_status_code(record[1])
16279 {
16280 snapshot.truncated = true;
16281 continue;
16282 }
16283 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16284 snapshot.truncated = true;
16285 continue;
16286 };
16287 let previous_path = match previous_path_bytes {
16288 Some(previous) => {
16289 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16290 snapshot.truncated = true;
16291 continue;
16292 };
16293 Some(previous)
16294 }
16295 None => None,
16296 };
16297 snapshot.status.push(GitStatusEntry {
16298 index_status: (record[0] as char).to_string(),
16299 worktree_status: (record[1] as char).to_string(),
16300 path,
16301 previous_path,
16302 });
16303 }
16304}
16305
16306fn is_git_status_code(byte: u8) -> bool {
16307 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16308}
16309
16310fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16311 for line in String::from_utf8_lossy(output).lines() {
16312 let Some((id, summary)) = line.split_once('\u{1f}') else {
16313 continue;
16314 };
16315 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16316 snapshot.truncated = true;
16317 break;
16318 }
16319 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16320 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16321 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16322 }
16323}
16324
16325fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16326 if value.len() <= max_bytes {
16327 return value.to_owned();
16328 }
16329 *truncated = true;
16330 let mut boundary = max_bytes;
16331 while boundary > 0 && !value.is_char_boundary(boundary) {
16332 boundary -= 1;
16333 }
16334 value[..boundary].to_owned()
16335}
16336
16337fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16338 if message.is_empty() {
16339 return;
16340 }
16341 let normalized = message
16342 .chars()
16343 .map(|character| if character.is_control() { ' ' } else { character })
16344 .collect::<String>();
16345 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16346 if !diagnostic.is_empty() {
16347 diagnostic.push_str("; ");
16348 }
16349 diagnostic.push_str(normalized.trim());
16350 snapshot.diagnostic = Some(truncate_git_field(
16351 &diagnostic,
16352 GIT_DIAGNOSTIC_MAX_BYTES,
16353 &mut snapshot.truncated,
16354 ));
16355}
16356
16357async fn run_git_bounded(
16358 root: &str,
16359 arguments: &[&str],
16360 output_limit: usize,
16361) -> io::Result<GitCommandOutput> {
16362 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16363}
16364
16365async fn read_git_history_bounded(
16366 root: &str,
16367 path: Option<&RepositoryPath>,
16368 before: Option<&GitObjectId>,
16369 limit: u16,
16370) -> Result<GitHistoryPage, NodeFailure> {
16371 let path = path
16372 .map(|path| {
16373 path.as_utf8().ok_or_else(|| {
16374 failure(
16375 NodeFailureCode::InvalidRepositoryPath,
16376 "git history path must use UTF-8",
16377 )
16378 })
16379 })
16380 .transpose()?
16381 .unwrap_or(".");
16382 let count = (usize::from(limit) + 1).to_string();
16383 let start = before
16384 .map(|revision| format!("{}^", revision.as_str()))
16385 .unwrap_or_else(|| "HEAD".to_owned());
16386 let arguments = [
16387 "--no-pager",
16388 "log",
16389 "--no-decorate",
16390 "--date=iso-strict",
16391 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16392 "-n",
16393 count.as_str(),
16394 start.as_str(),
16395 "--",
16396 path,
16397 ];
16398 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16399 .await
16400 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16401 if output.timed_out {
16402 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16403 }
16404 if !output.success {
16405 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16406 }
16407 let mut commits = parse_git_history(&output.stdout);
16408 let has_more = commits.len() > usize::from(limit) || output.truncated;
16409 commits.truncate(usize::from(limit));
16410 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16411 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16412}
16413
16414fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16415 String::from_utf8_lossy(output)
16416 .split('\u{1e}')
16417 .filter_map(|record| {
16418 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16419 if fields.len() != 9 {
16420 return None;
16421 }
16422 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16423 let parents = if fields[1].is_empty() {
16424 Vec::new()
16425 } else {
16426 fields[1]
16427 .split(' ')
16428 .map(|value| GitObjectId::new(value.to_owned()))
16429 .collect::<Result<Vec<_>, _>>()
16430 .ok()?
16431 };
16432 Some(GitCommitDetails {
16433 id,
16434 parents,
16435 subject: bounded_git_text(fields[2], 512),
16436 author_name: bounded_git_text(fields[3], 256),
16437 author_email: bounded_git_text(fields[4], 320),
16438 authored_at: bounded_git_text(fields[5], 64),
16439 committer_name: bounded_git_text(fields[6], 256),
16440 committer_email: bounded_git_text(fields[7], 320),
16441 committed_at: bounded_git_text(fields[8], 64),
16442 signature_status: GitSignatureStatus::NoSignature,
16443 signer: None,
16444 })
16445 })
16446 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16447 .collect()
16448}
16449
16450fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16451 let normalized = value
16452 .chars()
16453 .map(|character| if character.is_control() { ' ' } else { character })
16454 .collect::<String>();
16455 if normalized.len() <= max_bytes {
16456 return normalized;
16457 }
16458 let mut boundary = max_bytes;
16459 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16460 boundary -= 1;
16461 }
16462 normalized[..boundary].to_owned()
16463}
16464
16465async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16466 let path = request.path.as_ref().map(|path| {
16467 path.as_utf8()
16468 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16469 }).transpose()?;
16470 let mut arguments = match &request.mode {
16471 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16472 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16473 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16474 };
16475 if let GitDiffMode::Commit { revision } = &request.mode {
16476 arguments.push(revision.as_str());
16477 }
16478 arguments.push("--");
16479 if let Some(path) = path {
16480 arguments.push(path);
16481 }
16482 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16483 .await
16484 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16485 if output.timed_out {
16486 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16487 }
16488 if !output.success {
16489 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16490 }
16491 Ok(GitDiff {
16492 mode: request.mode,
16493 path: request.path,
16494 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16495 truncated: output.truncated,
16496 })
16497}
16498
16499async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16500 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16501 ResponseEnvelope { request_id: envelope.request_id, result }
16502}
16503
16504async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16505 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16506 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16507 }
16508 let read_only = matches!(
16509 &request,
16510 NodeRequest::Snapshot
16511 | NodeRequest::Resync { .. }
16512 | NodeRequest::BrowseHostDirectories { .. }
16513 | NodeRequest::InspectWorkspace { .. }
16514 | NodeRequest::ReadWorkspaceFile { .. }
16515 | NodeRequest::ReadGitHistory { .. }
16516 | NodeRequest::ReadGitDiff { .. }
16517 | NodeRequest::CatalogNativeSessions { .. }
16518 | NodeRequest::PageNativeSessions { .. }
16519 | NodeRequest::PreviewNativeSession { .. }
16520 | NodeRequest::PreviewSessionRecord { .. }
16521 );
16522 let _mutation_guard = if read_only {
16523 None
16524 } else {
16525 let guard = shared.mutation_gate.lock().await;
16526 if shared.shutdown.load(Ordering::Acquire) {
16527 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16528 }
16529 Some(guard)
16530 };
16531 match request {
16532 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16533 event_sequence: shared.current_sequence(),
16534 controller: shared.controller_state(),
16535 snapshot: shared.snapshot(),
16536 }),
16537 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16538 NodeRequest::ArmHarnessMcpReservation {
16539 reservation_id,
16540 activation_digest,
16541 spawn_spec,
16542 launch,
16543 expires_at_unix_ms,
16544 } => {
16545 shared.require_controller(connection_id, role)?;
16546 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16547 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16548 })?;
16549 let expires_at_unix_ms = registry
16550 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16551 .await
16552 .map_err(harness_mcp_failure)?;
16553 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16554 }
16555 NodeRequest::SpawnSpecWithHarnessMcp {
16556 reservation_id,
16557 activation_digest,
16558 spec,
16559 deadline_unix_ms,
16560 } => {
16561 shared.require_controller(connection_id, role)?;
16562 let receipt = shared
16563 .spawn_from_spec_with_harness_mcp(
16564 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16565 )
16566 .await?;
16567 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16568 }
16569 NodeRequest::ActivateHarnessMcpReservation {
16570 reservation_id,
16571 activation_digest,
16572 record_id,
16573 session,
16574 } => {
16575 shared.require_controller(connection_id, role)?;
16576 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16577 current.instance_id == session.session.instance_id
16578 && current.generation == session.session.generation
16579 }).and_then(|current| current.process_id).ok_or_else(|| {
16580 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16581 })?;
16582 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16583 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16584 })?.activate(
16585 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16586 ).map_err(harness_mcp_failure)?;
16587 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16588 }
16589 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16590 shared.require_controller(connection_id, role)?;
16591 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16592 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16593 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16594 if let Some(instance_id) = instance_id {
16595 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16596 }
16597 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16598 }
16599 NodeRequest::PutHarnessMcpReplyChunk {
16600 reservation_id, activation_digest, record_id, session, call_id,
16601 offset, final_chunk, chunk_hex,
16602 } => {
16603 shared.require_controller(connection_id, role)?;
16604 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16605 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16606 })?.put_reply_chunk(
16607 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16608 offset, final_chunk, &chunk_hex,
16609 ).map_err(harness_mcp_failure)?;
16610 Ok(NodeResponse::ReplyChunkAccepted {
16611 reservation_id, activation_digest, record_id, session, call_id,
16612 next_offset, completed,
16613 })
16614 }
16615 NodeRequest::RejectHarnessMcpCall {
16616 reservation_id, activation_digest, record_id, session, call_id, reason,
16617 } => {
16618 shared.require_controller(connection_id, role)?;
16619 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16620 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16621 })?.reject_call(
16622 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16623 ).map_err(harness_mcp_failure)?;
16624 Ok(NodeResponse::CallRejected {
16625 reservation_id, activation_digest, record_id, session, call_id,
16626 })
16627 }
16628 NodeRequest::BeginDeliveryStage { manifest } => {
16629 shared.require_controller(connection_id, role)?;
16630 shared.begin_delivery_stage(manifest)
16631 }
16632 NodeRequest::PutDeliveryBlobChunk {
16633 stage_id,
16634 blob_digest,
16635 offset,
16636 chunk_hex,
16637 } => {
16638 shared.require_controller(connection_id, role)?;
16639 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16640 }
16641 NodeRequest::CommitDeliveryStage { stage_id } => {
16642 shared.require_controller(connection_id, role)?;
16643 let receipt = shared.commit_delivery_stage(stage_id)?;
16644 Ok(NodeResponse::DeliveryCommitted { receipt })
16645 }
16646 NodeRequest::AbortDeliveryStage { stage_id } => {
16647 shared.require_controller(connection_id, role)?;
16648 shared.abort_delivery_stage(&stage_id)?;
16649 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16650 }
16651 NodeRequest::BrowseHostDirectories { directory, after } => {
16652 let listing = shared.browse_host_directories(directory, after).await?;
16653 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16654 }
16655 NodeRequest::InspectWorkspace { workspace_id } => {
16656 let inspection = shared.inspect_workspace(workspace_id).await?;
16657 Ok(NodeResponse::WorkspaceInspected { inspection })
16658 }
16659 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16660 let file = shared.read_workspace_file(workspace_id, path).await?;
16661 Ok(NodeResponse::WorkspaceFileRead { file })
16662 }
16663 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16664 shared.require_controller(connection_id, role)?;
16665 let file = shared
16666 .write_workspace_file(workspace_id, path, expected_revision, text)
16667 .await?;
16668 Ok(NodeResponse::WorkspaceFileWritten { file })
16669 }
16670 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16671 shared.require_controller(connection_id, role)?;
16672 let file = shared.create_workspace_file(workspace_id, path).await?;
16673 Ok(NodeResponse::WorkspaceFileCreated { file })
16674 }
16675 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16676 shared.require_controller(connection_id, role)?;
16677 let entry = shared
16678 .create_workspace_directory(workspace_id.clone(), path)
16679 .await?;
16680 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16681 }
16682 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16683 if role != ClientRole::Operator {
16684 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16685 }
16686 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16687 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16688 }
16689 let page = shared
16690 .read_git_history(workspace_id.clone(), path, before, limit)
16691 .await?;
16692 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16693 }
16694 NodeRequest::ReadGitDiff { workspace_id, request } => {
16695 if role != ClientRole::Operator {
16696 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16697 }
16698 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16699 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16700 }
16701 NodeRequest::CatalogNativeSessions { route, limit } => {
16702 if role != ClientRole::Operator {
16703 return Err(failure(
16704 NodeFailureCode::ObserverReadOnly,
16705 "native session catalog requires an operator connection",
16706 ));
16707 }
16708 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16709 return Err(failure(
16710 NodeFailureCode::InvalidRequest,
16711 "native session catalog limit is invalid",
16712 ));
16713 }
16714 let (entries, summary) = shared
16715 .catalog_native_sessions(route.clone(), limit)
16716 .await?;
16717 Ok(NodeResponse::NativeSessionsCataloged {
16718 route,
16719 entries,
16720 summary: Some(summary),
16721 })
16722 }
16723 NodeRequest::PageNativeSessions {
16724 route,
16725 window,
16726 catalog_revision,
16727 recent_cutoff_unix_ms,
16728 after_selection_id,
16729 limit,
16730 } => {
16731 if role != ClientRole::Operator {
16732 return Err(failure(
16733 NodeFailureCode::ObserverReadOnly,
16734 "native session catalog paging requires an operator connection",
16735 ));
16736 }
16737 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16738 || after_selection_id
16739 .as_deref()
16740 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16741 {
16742 return Err(failure(
16743 NodeFailureCode::InvalidRequest,
16744 "native session catalog page request is invalid",
16745 ));
16746 }
16747 let page = shared
16748 .page_native_sessions(
16749 route.clone(),
16750 window,
16751 catalog_revision,
16752 recent_cutoff_unix_ms,
16753 after_selection_id,
16754 limit,
16755 )
16756 .await?;
16757 Ok(NodeResponse::NativeSessionsPaged {
16758 route,
16759 page,
16760 })
16761 }
16762 NodeRequest::PreviewNativeSession {
16763 selection,
16764 message_limit,
16765 } => {
16766 if role != ClientRole::Operator {
16767 return Err(failure(
16768 NodeFailureCode::ObserverReadOnly,
16769 "native session preview requires an operator connection",
16770 ));
16771 }
16772 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16773 .contains(&message_limit)
16774 || selection.validate().is_err()
16775 {
16776 return Err(failure(
16777 NodeFailureCode::InvalidRequest,
16778 "native session preview request is invalid",
16779 ));
16780 }
16781 let preview = shared
16782 .preview_native_session(selection.clone(), message_limit)
16783 .await?;
16784 Ok(NodeResponse::NativeSessionPreviewed {
16785 selection,
16786 preview,
16787 })
16788 }
16789 NodeRequest::IndexNativeSession {
16790 selection,
16791 display_name,
16792 } => {
16793 shared.require_controller(connection_id, role)?;
16794 let record = shared
16795 .index_native_session(selection.clone(), display_name)
16796 .await?;
16797 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16798 }
16799 NodeRequest::PreviewSessionRecord {
16800 record_id,
16801 message_limit,
16802 } => {
16803 if role != ClientRole::Operator {
16804 return Err(failure(
16805 NodeFailureCode::ObserverReadOnly,
16806 "native session preview requires an operator connection",
16807 ));
16808 }
16809 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16810 .contains(&message_limit)
16811 {
16812 return Err(failure(
16813 NodeFailureCode::InvalidRequest,
16814 "native session preview request is invalid",
16815 ));
16816 }
16817 let record = shared.record(&record_id)?;
16818 let working_directory = shared.workspace_root(&record.workspace_id)?;
16819 if !platform::roots_equal(
16820 &working_directory,
16821 windows_path_text(&record.canonical_root),
16822 ) {
16823 return Err(failure(
16824 NodeFailureCode::SessionRecordConflict,
16825 "managed session workspace root changed; refusing to preview another directory",
16826 ));
16827 }
16828 let identity = record.provider_session.clone().ok_or_else(|| {
16829 failure(
16830 NodeFailureCode::BackendOperationFailed,
16831 "native session preview is unavailable for this session record",
16832 )
16833 })?;
16834 if identity.key != ProviderSessionKey::SessionId {
16835 return Err(failure(
16836 NodeFailureCode::BackendOperationFailed,
16837 "native session preview is unavailable for this session identity",
16838 ));
16839 }
16840 let preview = shared
16841 .preview_native_session_by_session_id(
16842 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16843 )
16844 .await?;
16845 if preview.session_id != identity.id {
16846 return Err(failure(
16847 NodeFailureCode::SessionRecordConflict,
16848 "native session preview identity changed while loading",
16849 ));
16850 }
16851 shared.revalidate_session_record_preview(&record, &identity)?;
16852 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16853 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16854 record_id: record_id.clone(),
16855 summary: SessionHistorySummaryV1::from(&preview),
16856 });
16857 Ok(NodeResponse::SessionRecordPreviewed {
16858 record_id,
16859 preview,
16860 })
16861 }
16862 NodeRequest::AcquireController { lease_ms } => {
16863 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16864 Ok(NodeResponse::Controller { controller: Some(controller) })
16865 }
16866 NodeRequest::ReleaseController => {
16867 shared.release_controller(connection_id);
16868 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16869 }
16870 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16871 shared.require_controller(connection_id, role)?;
16872 let root = require_windows_path(root)?;
16873 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16874 let workspace = shared.register_workspace(workspace_id, root).await?;
16875 Ok(NodeResponse::WorkspaceRegistered { workspace })
16876 }
16877 NodeRequest::CreateStandaloneWorkspace {
16878 workspace_id,
16879 root,
16880 initial_branch,
16881 } => {
16882 shared.require_controller(connection_id, role)?;
16883 let root = require_windows_path(root)?;
16884 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16885 let workspace = shared
16886 .create_standalone_workspace(workspace_id, root, initial_branch)
16887 .await?;
16888 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16889 }
16890 NodeRequest::UnregisterWorkspace { workspace_id } => {
16891 shared.require_controller(connection_id, role)?;
16892 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16893 shared.unregister_workspace(&workspace_id)?;
16894 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16895 }
16896 NodeRequest::CreateWorktree {
16897 source_workspace_id,
16898 workspace_id,
16899 target_root,
16900 branch,
16901 base,
16902 } => {
16903 shared.require_controller(connection_id, role)?;
16904 let target_root = require_windows_path(target_root)?;
16905 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16906 let (worktree, workspace) = shared
16907 .create_worktree(
16908 source_workspace_id,
16909 workspace_id,
16910 target_root,
16911 branch,
16912 base,
16913 )
16914 .await?;
16915 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16916 }
16917 NodeRequest::RemoveWorktree {
16918 source_workspace_id,
16919 target_root,
16920 } => {
16921 shared.require_controller(connection_id, role)?;
16922 let native_target_root = require_windows_path(target_root.clone())?;
16923 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16924 let workspace_id = shared
16925 .remove_worktree(source_workspace_id, native_target_root)
16926 .await?;
16927 Ok(NodeResponse::WorktreeRemoved {
16928 target_root,
16929 workspace_id,
16930 })
16931 }
16932 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16933 shared.require_controller(connection_id, role)?;
16934 if !terminal_size.is_valid() {
16935 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16936 }
16937 if let Some(prompt) = initial_prompt.as_deref() {
16938 validate_node_text("spawn initial prompt", prompt)?;
16939 }
16940 match shared
16941 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16942 .await
16943 {
16944 Ok(session) => {
16945 shared.mark_spawn_provisional(session.clone());
16946 Ok(NodeResponse::SpawnAccepted { session })
16947 }
16948 Err(err) => {
16949 tracing::warn!(
16950 %provider,
16951 %workspace_id,
16952 ?mode,
16953 code = ?err.code,
16954 message = %err.message,
16955 "session spawn refused"
16956 );
16957 Err(err)
16958 }
16959 }
16960 }
16961 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16962 shared.require_controller(connection_id, role)?;
16963 if !terminal_size.is_valid() {
16964 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16965 }
16966 if let Some(prompt) = initial_prompt.as_deref() {
16967 validate_node_text("spawn initial prompt", prompt)?;
16968 }
16969 let plan = backend_paths::plan_launch_options(
16970 provider.as_str(),
16971 mode,
16972 options.model.is_some(),
16973 options.effort.is_some(),
16974 )
16975 .map_err(|r| {
16976 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16977 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16978 })?;
16979 let selection = spawn_selection(&options)?;
16980 let pty_selection = match plan {
16981 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16982 _ => None,
16983 };
16984 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16985 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16986 SPAWN_SESSION_OPTIONS
16987 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16988 .await
16989 }).await
16990 }).await;
16991 match spawned {
16992 Ok(session) => {
16993 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16994 shared
16995 .pending_acp_options
16996 .lock()
16997 .unwrap_or_else(|p| p.into_inner())
16998 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16999 }
17000 shared.mark_spawn_provisional(session.clone());
17001 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17002 Ok(NodeResponse::SpawnAccepted { session })
17003 }
17004 Err(err) => {
17005 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17006 Err(err)
17007 }
17008 }
17009 }
17010 NodeRequest::SpawnSpec { spec } => {
17011 shared.require_controller(connection_id, role)?;
17012 let receipt = shared.spawn_from_spec(spec).await?;
17013 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17014 }
17015 NodeRequest::SpawnManagedWorktree { request } => {
17016 shared.require_controller(connection_id, role)?;
17017 let receipt = shared.spawn_managed_worktree(request).await?;
17018 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17019 }
17020 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17021 shared.require_controller(connection_id, role)?;
17022 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17023 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17024 }
17025 NodeRequest::CleanupManagedWorktree { lease_id } => {
17026 shared.require_controller(connection_id, role)?;
17027 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17028 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17029 }
17030 NodeRequest::DiscoverHistory { session, limit } => {
17031 controlled_session(shared, connection_id, role, &session)?;
17032 let candidates = shared.discover_history(&session, limit).await?;
17033 Ok(NodeResponse::HistoryDiscovered {
17034 session,
17035 candidates,
17036 })
17037 }
17038 NodeRequest::LoadHistory {
17039 session,
17040 candidate_id,
17041 } => {
17042 controlled_session(shared, connection_id, role, &session)?;
17043 let loaded = shared
17044 .load_history(&session, candidate_id)
17045 .await?;
17046 Ok(NodeResponse::HistoryLoaded {
17047 session,
17048 session_id: loaded.session_id,
17049 message_count: loaded.message_count,
17050 completed_turn_count: loaded.completed_turn_count,
17051 })
17052 }
17053 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17054 shared.require_controller(connection_id, role)?;
17055 let context = shared
17056 .export_context_pack_for_session_record(&record_id, &session, false)
17057 .await?;
17058 Ok(NodeResponse::ContextPackForSessionRecordExported {
17059 record_id,
17060 session,
17061 context,
17062 })
17063 }
17064 NodeRequest::ExportContextPack { session } => {
17065 controlled_session(shared, connection_id, role, &session)?;
17066 let context = shared.export_context_pack(&session).await?;
17067 Ok(NodeResponse::ContextPackExported { context })
17068 }
17069 NodeRequest::ForgetContextPack { context_id } => {
17070 shared.require_controller(connection_id, role)?;
17071 shared.forget_context_pack(&context_id)?;
17072 Ok(NodeResponse::ContextPackForgotten { context_id })
17073 }
17074 NodeRequest::ResolveDurableContextPack { context_id } => {
17075 let context = shared
17079 .context_catalog
17080 .read()
17081 .unwrap_or_else(|poisoned| poisoned.into_inner())
17082 .get(&context_id)
17083 .map(|pack| pack.receipt().clone())
17084 .ok_or_else(|| {
17085 failure(
17086 NodeFailureCode::UnknownContextPack,
17087 "context pack is not present",
17088 )
17089 })?;
17090 Ok(NodeResponse::DurableContextPackResolved { context })
17091 }
17092 NodeRequest::ReadContextPack { digest } => {
17093 let (id, byte_len, bytes) = shared
17098 .context_catalog
17099 .read()
17100 .unwrap_or_else(|poisoned| poisoned.into_inner())
17101 .get_by_digest(&digest)
17102 .map(|pack| {
17103 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17104 })
17105 .ok_or_else(|| {
17106 failure(
17107 NodeFailureCode::UnknownContextPack,
17108 &format!("context pack {digest} not on this node"),
17109 )
17110 })?;
17111 tracing::debug!(
17112 digest = digest.as_str(),
17113 byte_len,
17114 "read context pack bytes",
17115 );
17116 Ok(NodeResponse::ContextPackBytesRead {
17117 pack: ContextPackBytesRead {
17118 digest,
17119 id,
17120 byte_len,
17121 bytes,
17122 },
17123 })
17124 }
17125 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17126 let provider = controlled_session(shared, connection_id, role, &session)?;
17127 if let Some(prompt) = initial_prompt.as_deref() {
17128 validate_node_text("resume initial prompt", prompt)?;
17129 }
17130 let runtime_requirement = if initial_prompt.is_some() {
17131 ProviderRuntimeRequirement::ResumeWithPrompt
17132 } else {
17133 ProviderRuntimeRequirement::Resume
17134 };
17135 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17136 let runtime_policy = shared
17137 .admit_provider_runtime(&provider, runtime_requirement)
17138 .await?;
17139 let working_directory = shared.bound_workspace_root(&session)?;
17140 let request = ResumeLaunchRequest {
17141 working_directory,
17142 terminal_size,
17143 initial_prompt,
17144 };
17145 request
17146 .validate()
17147 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17148 let command = shared.prepare_command(ControlCommand::Resume {
17149 instance_id: session.session.instance_id,
17150 target: ResumeTarget::CurrentProvider,
17151 runtime_policy,
17152 request,
17153 });
17154 shared.arm_resume(&session, command.id, runtime_policy)?;
17155 let dispatch = shared.dispatch_envelope(command);
17156 if let Err(error) = dispatch {
17157 shared.clear_armed_resume(&session);
17158 return Err(error);
17159 }
17160 Ok(NodeResponse::Accepted)
17161 }
17162 NodeRequest::RenameSessionRecord {
17163 record_id,
17164 display_name,
17165 } => {
17166 shared.require_controller(connection_id, role)?;
17167 let record = shared.rename_session_record(&record_id, display_name)?;
17168 Ok(NodeResponse::SessionRecordUpdated { record })
17169 }
17170 NodeRequest::SetSessionTask {
17171 record_id,
17172 expected_revision,
17173 target,
17174 } => {
17175 shared.require_controller(connection_id, role)?;
17176 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17177 Ok(NodeResponse::SessionRecordUpdated { record })
17178 }
17179 NodeRequest::IndexProviderSession {
17180 workspace_id,
17181 provider,
17182 identity,
17183 display_name,
17184 } => {
17185 shared.require_controller(connection_id, role)?;
17186 let record = shared.index_provider_session(
17187 workspace_id,
17188 provider,
17189 identity,
17190 display_name,
17191 )?;
17192 Ok(NodeResponse::ProviderSessionIndexed { record })
17193 }
17194 NodeRequest::ResumeSessionRecord {
17195 record_id,
17196 terminal_size,
17197 initial_prompt,
17198 } => {
17199 shared.require_controller(connection_id, role)?;
17200 if let Some(prompt) = initial_prompt.as_deref() {
17201 validate_node_text("managed resume initial prompt", prompt)?;
17202 }
17203 let (record, session) = shared
17204 .resume_session_record(
17205 &record_id,
17206 terminal_size,
17207 initial_prompt,
17208 )
17209 .await?;
17210 Ok(NodeResponse::SessionRecordResumed { record, session })
17211 }
17212 NodeRequest::ForgetSessionRecord { record_id } => {
17213 shared.require_controller(connection_id, role)?;
17214 shared.forget_session_record(&record_id).await?;
17215 Ok(NodeResponse::SessionRecordForgotten { record_id })
17216 }
17217 NodeRequest::Prompt { session, text } => {
17218 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17219 validate_node_text("prompt", &text)?;
17220 shared.require_session_runtime_policy(
17221 &session,
17222 ProviderRuntimeRequirement::SemanticPrompt,
17223 )?;
17224 let framing = prompt_framing(&agent_id);
17225 shared
17226 .dispatch_input_bounded(
17227 &session,
17228 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17229 )
17230 .await?;
17231 Ok(NodeResponse::Accepted)
17232 }
17233 NodeRequest::Paste { session, text } => {
17234 controlled_session(shared, connection_id, role, &session)?;
17235 validate_node_text("paste", &text)?;
17236 shared.require_session_runtime_policy(
17237 &session,
17238 ProviderRuntimeRequirement::SemanticPrompt,
17239 )?;
17240 shared
17241 .dispatch_input_bounded(
17242 &session,
17243 InputAction::InsertDraft(PromptPayload {
17244 text,
17245 framing: PromptFraming::BracketedPaste,
17246 }),
17247 )
17248 .await?;
17249 Ok(NodeResponse::Accepted)
17250 }
17251 NodeRequest::Input { session, text } => {
17252 controlled_session(shared, connection_id, role, &session)?;
17253 validate_node_text("terminal input", &text)?;
17254 shared
17255 .dispatch_input_bounded(
17256 &session,
17257 InputAction::TerminalText(TerminalText { text }),
17258 )
17259 .await?;
17260 Ok(NodeResponse::Accepted)
17261 }
17262 NodeRequest::TerminalBytes { session, bytes } => {
17263 controlled_session(shared, connection_id, role, &session)?;
17264 shared.sessionize_spawn(session.session.instance_id);
17265 let action = terminal_bytes_action(bytes)?;
17266 shared
17267 .dispatch_input_bounded(&session, action)
17268 .await?;
17269 Ok(NodeResponse::Accepted)
17270 }
17271 NodeRequest::TerminalControl { session, control } => {
17272 controlled_session(shared, connection_id, role, &session)?;
17273 shared
17274 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17275 .await?;
17276 Ok(NodeResponse::Accepted)
17277 }
17278 NodeRequest::Resize { session, size } => {
17279 controlled_session(shared, connection_id, role, &session)?;
17280 shared.sessionize_spawn(session.session.instance_id);
17281 if !size.is_valid() {
17282 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17283 }
17284 shared.dispatch_resize_bounded(&session, size).await?;
17285 Ok(NodeResponse::Accepted)
17286 }
17287 NodeRequest::Interrupt { session } => {
17288 controlled_session(shared, connection_id, role, &session)?;
17289 shared
17290 .dispatch_input_bounded(
17291 &session,
17292 InputAction::TerminalControl(TerminalControl::Interrupt),
17293 )
17294 .await?;
17295 Ok(NodeResponse::Accepted)
17296 }
17297 NodeRequest::Stop { session, force } => {
17298 controlled_session(shared, connection_id, role, &session)?;
17299 shared.sessionize_spawn(session.session.instance_id);
17300 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17301 shared.retire_session_grok_leader(session.session.instance_id);
17302 Ok(NodeResponse::Accepted)
17303 }
17304 NodeRequest::Remove { session } => {
17305 controlled_session(shared, connection_id, role, &session)?;
17306 shared.remove_session(&session).await?;
17307 Ok(NodeResponse::Accepted)
17308 }
17309 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17310 shared.require_controller(connection_id, role)?;
17311 let snapshot = shared.require_acp_session(&session)?;
17312 let interaction = snapshot
17313 .provider
17314 .interactions
17315 .iter()
17316 .find(|interaction| {
17317 interaction_correlation(
17318 session.session.instance_id,
17319 session.session.generation,
17320 interaction.id.0,
17321 ) == correlation_id
17322 })
17323 .ok_or_else(|| failure(
17324 NodeFailureCode::InvalidRequest,
17325 "unknown interaction correlation",
17326 ))?;
17327 if interaction.status != ProviderInteractionStatus::Pending {
17328 return Err(failure(
17329 NodeFailureCode::BackendOperationFailed,
17330 "interaction has already been resolved",
17331 ));
17332 }
17333 response
17334 .validate_for(interaction.interaction_kind)
17335 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17336 let interaction_id = interaction.id;
17337 let response_kind = response.kind();
17338 let response_outcome = response.outcome();
17339 shared.dispatch(ControlCommand::ResolveInteraction {
17340 instance_id: session.session.instance_id,
17341 generation: session.session.generation,
17342 interaction_id,
17343 response,
17344 })?;
17345 tracing::info!(
17351 node_id = %shared.node_id,
17352 session = ?session.session,
17353 correlation_id = %correlation_id,
17354 interaction_id = interaction_id.0,
17355 response_kind = ?response_kind,
17356 outcome = ?response_outcome,
17357 "interaction resolved",
17358 );
17359 Ok(NodeResponse::Accepted)
17360 }
17361 NodeRequest::SetSessionMode { session, mode_id } => {
17362 shared.require_controller(connection_id, role)?;
17363 shared.require_acp_session(&session)?;
17364 shared.dispatch(ControlCommand::SetSessionMode {
17365 instance_id: session.session.instance_id,
17366 mode_id,
17367 })?;
17368 Ok(NodeResponse::Accepted)
17369 }
17370 NodeRequest::SetSessionConfigOption {
17371 session,
17372 option_id,
17373 value_json,
17374 } => {
17375 shared.require_controller(connection_id, role)?;
17376 shared.require_acp_session(&session)?;
17377 shared.dispatch(ControlCommand::SetSessionConfigOption {
17378 instance_id: session.session.instance_id,
17379 option_id,
17380 value_json,
17381 })?;
17382 Ok(NodeResponse::Accepted)
17383 }
17384 NodeRequest::SetSessionModel { session, model_id } => {
17385 shared.require_controller(connection_id, role)?;
17386 shared.require_acp_session(&session)?;
17387 shared.dispatch(ControlCommand::SetSessionModel {
17388 instance_id: session.session.instance_id,
17389 model_id,
17390 })?;
17391 Ok(NodeResponse::Accepted)
17392 }
17393 NodeRequest::Shutdown => {
17394 shared.require_controller(connection_id, role)?;
17395 shared.begin_shutdown_locked().await?;
17396 Ok(NodeResponse::ShuttingDown)
17397 }
17398 }
17399}
17400
17401fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17402 shared.require_controller(connection_id, role)?;
17403 shared.validate_address(session)
17404}
17405
17406fn select_history_candidate(
17412 candidates: &[HistoryCandidateSummary],
17413 provider_session_id: Option<&str>,
17414) -> Result<String, NodeFailure> {
17415 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17416 Some(id) => candidate.session_id_hint == id,
17417 None => true,
17418 });
17419 let candidate = matches.next().ok_or_else(|| {
17420 failure(
17421 NodeFailureCode::SessionRecordConflict,
17422 if provider_session_id.is_some() {
17423 "no history candidate matches the managed provider session"
17424 } else {
17425 "no history candidate exists for the managed session"
17426 },
17427 )
17428 })?;
17429 if matches.next().is_some() {
17430 return Err(failure(
17431 NodeFailureCode::SessionRecordConflict,
17432 if provider_session_id.is_some() {
17433 "history identity is ambiguous for the managed provider session"
17434 } else {
17435 "history is ambiguous without a verified provider session identity"
17436 },
17437 ));
17438 }
17439 Ok(candidate.id.clone())
17440}
17441
17442fn resolved_spawn_profile_summary(
17443 profile: &SpawnProfileDefaults,
17444 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17445) -> Option<SpawnProfileSummary> {
17446 let environment_profile = match profile.environment_profile_id.as_ref() {
17447 None => None,
17448 Some(profile_id) => {
17449 let binding = environment_profiles.get(profile_id)?;
17450 if binding.provider != profile.provider
17451 || binding.native_profile_id(profile.mode).is_none()
17452 {
17453 return None;
17454 }
17455 Some(ResolvedEnvironmentProfileReceipt {
17456 profile_id: binding.id.clone(),
17457 profile_revision: binding.revision.clone(),
17458 network_allowlist: None,
17459 browser_profile_id: None,
17460 })
17461 }
17462 };
17463 Some(SpawnProfileSummary {
17464 id: profile.profile_id.clone(),
17465 revision: profile.revision.clone(),
17466 environment_profile,
17467 })
17468}
17469
17470fn session_record_export_target_matches(
17489 expected: &ManagedSessionRecord,
17490 current: &ManagedSessionRecord,
17491 session: &SessionAddress,
17492 allow_clean_detachment: bool,
17493) -> bool {
17494 if current == expected && current.active_session.as_ref() == Some(session) {
17495 return current.state == ManagedSessionState::Live;
17496 }
17497 allow_clean_detachment
17498 && current.state == ManagedSessionState::Dormant
17499 && current.active_session.is_none()
17500 && current.record_id == expected.record_id
17501 && current.display_name == expected.display_name
17502 && current.provider == expected.provider
17503 && current.mode == expected.mode
17504 && current.workspace_id == expected.workspace_id
17505 && current.canonical_root == expected.canonical_root
17506 && current.provider_session == expected.provider_session
17507 && current.environment_profile == expected.environment_profile
17508 && current.bundle == expected.bundle
17509 && current.context_id == expected.context_id
17510 && current.context == expected.context
17511 && current.exported_context == expected.exported_context
17512 && current.task_binding == expected.task_binding
17513 && current.created_at_unix_ms == expected.created_at_unix_ms
17514 && current.last_error == expected.last_error
17515}
17516
17517fn session_record_context_export_binding_is_exact(
17518 expected: &ManagedSessionRecord,
17519 current: &ManagedSessionRecord,
17520 identity: Option<&ProviderSessionIdentity>,
17521 session: &SessionAddress,
17522 binding: &SessionBinding,
17523 provider: &AgentId,
17524 current_root: &str,
17525 allow_clean_detachment: bool,
17526) -> bool {
17527 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17528 && current.provider_session.as_ref() == identity
17529 && current.workspace_id == session.workspace_id
17530 && provider == ¤t.provider
17531 && binding.workspace_id == session.workspace_id
17532 && binding.generation == session.session.generation
17533 && binding.record_id.as_ref() == Some(¤t.record_id)
17534 && platform::roots_equal(
17535 current_root,
17536 windows_path_text(¤t.canonical_root),
17537 )
17538}
17539
17540fn session_record_context_export_source_is_usable(
17541 record: &ManagedSessionRecord,
17542 status: &SessionStatus,
17543 allow_clean_detachment: bool,
17544) -> bool {
17545 (record.state == ManagedSessionState::Live
17546 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17547 && context_pack_source_status_is_usable(status)
17548}
17549
17550fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17551 PromptFraming::BracketedPaste
17552}
17553
17554fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17555 if text.len() > MAX_NODE_TEXT_BYTES {
17556 return Err(failure(
17557 NodeFailureCode::InvalidRequest,
17558 &format!(
17559 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17560 text.len(),
17561 ),
17562 ));
17563 }
17564 Ok(())
17565}
17566
17567fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17568 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17569 return Err(failure(
17570 NodeFailureCode::InvalidRequest,
17571 &format!(
17572 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17573 bytes.len(),
17574 ),
17575 ));
17576 }
17577 Ok(())
17578}
17579
17580fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17581 validate_terminal_bytes(&bytes)?;
17582 Ok(InputAction::TerminalBytes(bytes))
17583}
17584
17585fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17586 if root.is_empty()
17587 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17588 || root.chars().any(char::is_control)
17589 {
17590 return Err(failure(
17591 NodeFailureCode::InvalidWorkspaceRoot,
17592 &format!(
17593 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17594 ),
17595 ));
17596 }
17597 Ok(())
17598}
17599
17600fn canonical_standalone_workspace_candidate(
17601 workspace_id: &WorkspaceId,
17602 root: &str,
17603) -> Result<String, NodeServerError> {
17604 let path = Path::new(root);
17605 match std::fs::symlink_metadata(path) {
17606 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17607 .map(|workspace| workspace.canonical_root),
17608 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17609 if !path.is_absolute() {
17610 return Err(NodeServerError::InvalidWorkspaceRoot {
17611 workspace_id: workspace_id.clone(),
17612 path: root.to_owned(),
17613 message: "path is not absolute".to_owned(),
17614 });
17615 }
17616 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17617 workspace_id: workspace_id.clone(),
17618 path: root.to_owned(),
17619 message: "path has no standalone directory name".to_owned(),
17620 })?;
17621 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17622 workspace_id: workspace_id.clone(),
17623 path: root.to_owned(),
17624 message: "path has no parent directory".to_owned(),
17625 })?;
17626 let canonical = std::fs::canonicalize(parent)
17627 .map(|parent| parent.join(name))
17628 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17629 workspace_id: workspace_id.clone(),
17630 path: root.to_owned(),
17631 message: error.to_string(),
17632 })?;
17633 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17634 NodeServerError::InvalidWorkspaceRoot {
17635 workspace_id: workspace_id.clone(),
17636 path: path.to_string_lossy().into_owned(),
17637 message: "canonical path is not valid Unicode".to_owned(),
17638 }
17639 })?;
17640 let canonical_root = platform::normalize_canonical_root(canonical_root);
17641 validate_workspace_root(workspace_id, &canonical_root)?;
17642 Ok(canonical_root)
17643 }
17644 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17645 workspace_id: workspace_id.clone(),
17646 path: root.to_owned(),
17647 message: error.to_string(),
17648 }),
17649 }
17650}
17651
17652fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17653 let Some(revision) = revision else {
17654 return Ok(());
17655 };
17656 if revision.is_empty()
17657 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17658 || revision.chars().any(char::is_control)
17659 {
17660 return Err(failure(
17661 NodeFailureCode::InvalidRequest,
17662 &format!(
17663 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17664 ),
17665 ));
17666 }
17667 Ok(())
17668}
17669
17670fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17671 let code = match error.kind {
17672 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17673 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17674 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17675 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17676 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17677 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17678 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17679 };
17680 failure(code, &error.message)
17681}
17682
17683fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17684 let code = match error.kind {
17685 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17686 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17687 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17688 NodeFailureCode::BackendOperationFailed
17689 }
17690 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17691 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17692 }
17693 };
17694 failure(code, error.message)
17695}
17696
17697fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17698 let (code, message) = match error.kind {
17699 GitWorktreeErrorKind::Invalid => (
17700 NodeFailureCode::InvalidRequest,
17701 "managed Git input is invalid",
17702 ),
17703 GitWorktreeErrorKind::NotRepository => (
17704 NodeFailureCode::NotGitRepository,
17705 "managed source is not a Git repository",
17706 ),
17707 GitWorktreeErrorKind::Conflict => (
17708 NodeFailureCode::WorktreeConflict,
17709 "managed Git identity conflicts with existing repository state",
17710 ),
17711 GitWorktreeErrorKind::Protected => (
17712 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17713 "managed worktree ownership validation failed",
17714 ),
17715 GitWorktreeErrorKind::Dirty => (
17716 NodeFailureCode::WorktreeDirty,
17717 "managed worktree contains uncommitted changes",
17718 ),
17719 GitWorktreeErrorKind::Locked => (
17720 NodeFailureCode::WorktreeLocked,
17721 "managed worktree is locked",
17722 ),
17723 GitWorktreeErrorKind::Failed => (
17724 NodeFailureCode::BackendOperationFailed,
17725 "managed Git operation failed",
17726 ),
17727 };
17728 NodeFailure { code, message: message.to_owned() }
17729}
17730
17731fn managed_spawn_receipt(
17732 resolved: &ResolvedSpawnSpec,
17733 incarnation_id: NodeIncarnationId,
17734 session: SessionAddress,
17735 lease: ManagedWorktreeLeaseSnapshot,
17736 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17737 bundle: Option<ResolvedBundleReceipt>,
17738 context: Option<ResolvedContextPackReceipt>,
17739) -> ManagedWorktreeSpawnReceipt {
17740 let mut managed = resolved.clone();
17741 managed.target.worktree_id = Some(lease.workspace_id.clone());
17742 ManagedWorktreeSpawnReceipt {
17743 spawn: managed.receipt_with_materialization(
17744 incarnation_id,
17745 session,
17746 environment_profile,
17747 bundle,
17748 context,
17749 ),
17750 lease,
17751 }
17752}
17753
17754fn validate_selected_worktree(
17755 source_root: &str,
17756 selected_root: &str,
17757 worktrees: &[NativeGitWorktreeSnapshot],
17758) -> Result<(), NodeFailure> {
17759 if !worktrees
17760 .iter()
17761 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17762 {
17763 return Err(failure(
17764 NodeFailureCode::WorktreeProtected,
17765 "source workspace is not an authoritative non-bare Git worktree root",
17766 ));
17767 }
17768 if !worktrees.iter().any(|worktree| {
17769 worktree_paths_equal(&worktree.path, selected_root)
17770 && !worktree.is_main
17771 && !worktree.is_bare
17772 }) {
17773 return Err(failure(
17774 NodeFailureCode::WorktreeProtected,
17775 "selected workspace is not an eligible Git worktree in the source repository",
17776 ));
17777 }
17778 Ok(())
17779}
17780
17781fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17782 let code = match error.kind() {
17783 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17784 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17785 NodeFailureCode::RepositoryPathUnsafe
17786 }
17787 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17788 WorkspaceFileReadErrorKind::NotRegularFile => {
17789 NodeFailureCode::RepositoryFileNotRegular
17790 }
17791 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17792 NodeFailureCode::RepositoryFileReadFailed
17793 }
17794 WorkspaceFileReadErrorKind::RevisionConflict => {
17795 NodeFailureCode::RepositoryFileRevisionConflict
17796 }
17797 WorkspaceFileReadErrorKind::AlreadyExists
17798 | WorkspaceFileReadErrorKind::ParentNotFound
17799 | WorkspaceFileReadErrorKind::ParentNotDirectory
17800 | WorkspaceFileReadErrorKind::Canceled => {
17801 NodeFailureCode::RepositoryFileReadFailed
17802 }
17803 };
17804 failure(code, "repository file read failed")
17805}
17806
17807fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17808 let code = match error.kind() {
17809 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17810 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17811 NodeFailureCode::RepositoryPathUnsafe
17812 }
17813 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17814 WorkspaceFileReadErrorKind::NotRegularFile => {
17815 NodeFailureCode::RepositoryFileNotRegular
17816 }
17817 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17818 NodeFailureCode::RepositoryFileWriteFailed
17819 }
17820 WorkspaceFileReadErrorKind::RevisionConflict => {
17821 NodeFailureCode::RepositoryFileRevisionConflict
17822 }
17823 WorkspaceFileReadErrorKind::AlreadyExists
17824 | WorkspaceFileReadErrorKind::ParentNotFound
17825 | WorkspaceFileReadErrorKind::ParentNotDirectory
17826 | WorkspaceFileReadErrorKind::Canceled => {
17827 NodeFailureCode::RepositoryFileWriteFailed
17828 }
17829 };
17830 failure(code, "repository file write failed")
17831}
17832
17833async fn settle_workspace_entry_create<T>(
17834 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17835 commit_state: Arc<AtomicU8>,
17836 deadline: Duration,
17837 timeout_message: &'static str,
17838 task_failure_message: &'static str,
17839) -> Result<T, NodeFailure> {
17840 let joined = match timeout(deadline, &mut task).await {
17841 Ok(joined) => joined,
17842 Err(_) => {
17843 if commit_state
17844 .compare_exchange(
17845 WORKSPACE_ENTRY_CREATE_PENDING,
17846 WORKSPACE_ENTRY_CREATE_CANCELED,
17847 Ordering::AcqRel,
17848 Ordering::Acquire,
17849 )
17850 .is_ok()
17851 {
17852 return Err(failure(
17853 NodeFailureCode::RepositoryEntryCreateTimedOut,
17854 timeout_message,
17855 ));
17856 }
17857 debug_assert_eq!(
17858 commit_state.load(Ordering::Acquire),
17859 WORKSPACE_ENTRY_CREATE_COMMITTING,
17860 );
17861 task.await
17862 }
17863 };
17864 joined
17865 .map_err(|_| {
17866 failure(
17867 NodeFailureCode::RepositoryEntryCreateFailed,
17868 task_failure_message,
17869 )
17870 })?
17871 .map_err(workspace_entry_create_failure)
17872}
17873
17874fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17875 let code = match error.kind() {
17876 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17877 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17878 NodeFailureCode::RepositoryPathUnsafe
17879 }
17880 WorkspaceFileReadErrorKind::AlreadyExists => {
17881 NodeFailureCode::RepositoryEntryAlreadyExists
17882 }
17883 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17884 NodeFailureCode::RepositoryParentNotFound
17885 }
17886 WorkspaceFileReadErrorKind::ParentNotDirectory
17887 | WorkspaceFileReadErrorKind::NotRegularFile => {
17888 NodeFailureCode::RepositoryParentNotDirectory
17889 }
17890 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17891 NodeFailureCode::RepositoryEntryCreateFailed
17892 }
17893 WorkspaceFileReadErrorKind::RevisionConflict => {
17894 NodeFailureCode::RepositoryEntryCreateFailed
17895 }
17896 WorkspaceFileReadErrorKind::Canceled => {
17897 NodeFailureCode::RepositoryEntryCreateTimedOut
17898 }
17899 };
17900 failure(code, "repository entry create failed")
17901}
17902
17903fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17904 let value = digest(&SHA256, bytes)
17905 .as_ref()
17906 .iter()
17907 .map(|byte| format!("{byte:02x}"))
17908 .collect();
17909 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17910}
17911
17912fn managed_spawn_request_digest_v2(
17913 request: &ManagedWorktreeSpawnRequestV2,
17914) -> Result<String, NodeFailure> {
17915 let encoded = serde_json::to_vec(request).map_err(|_| {
17916 failure(
17917 NodeFailureCode::InvalidRequest,
17918 "managed worktree V2 request could not be canonicalized",
17919 )
17920 })?;
17921 let mut value = String::with_capacity(71);
17922 value.push_str("sha256:");
17923 for byte in digest(&SHA256, &encoded).as_ref() {
17924 use std::fmt::Write as _;
17925 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17926 }
17927 Ok(value)
17928}
17929
17930fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17931 let code = match error {
17932 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17933 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17934 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17935 NativeSessionPreviewError::UnsupportedProvider
17936 | NativeSessionPreviewError::PreviewUnavailable
17937 | NativeSessionPreviewError::SessionNotFound
17938 | NativeSessionPreviewError::AmbiguousSession => {
17939 NodeFailureCode::BackendOperationFailed
17940 }
17941 };
17942 failure(code, "native session preview failed")
17943}
17944
17945fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17946 let code = match error.kind() {
17947 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17948 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17949 };
17950 failure(code, "host directory browse failed")
17951}
17952
17953fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17954 let code = match error {
17955 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17956 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17957 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17958 NodeFailureCode::DeliveryStageConflict
17959 }
17960 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17961 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17962 NodeFailureCode::DeliveryChunkOutOfOrder
17963 }
17964 DeliveryStoreError::BlobDigestMismatch => {
17965 NodeFailureCode::DeliveryBlobDigestMismatch
17966 }
17967 DeliveryStoreError::BundleDigestMismatch => {
17968 NodeFailureCode::DeliveryBundleDigestMismatch
17969 }
17970 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17971 DeliveryStoreError::Unavailable
17972 | DeliveryStoreError::Corrupt
17973 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17974 };
17975 failure(code, "delivery operation failed")
17976}
17977
17978fn validate_workspace_root(
17979 workspace_id: &WorkspaceId,
17980 canonical_root: &str,
17981) -> Result<(), NodeServerError> {
17982 #[cfg(windows)]
17983 if canonical_root.starts_with(r"\\") {
17984 return Err(NodeServerError::InvalidWorkspaceRoot {
17985 workspace_id: workspace_id.clone(),
17986 path: canonical_root.to_owned(),
17987 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17988 });
17989 }
17990 if !platform::workspace_root_supported(canonical_root)
17991 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17992 {
17993 return Err(NodeServerError::InvalidWorkspaceRoot {
17994 workspace_id: workspace_id.clone(),
17995 path: canonical_root.to_owned(),
17996 message: "canonical path must be absolute, bounded, and free of control characters"
17997 .to_owned(),
17998 });
17999 }
18000 Ok(())
18001}
18002
18003fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18004 NodeFailure {
18005 code,
18006 message: node_failure_category(code).to_owned(),
18007 }
18008}
18009
18010fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18011 let code = match error {
18012 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18013 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18014 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18015 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18016 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18017 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18018 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18019 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18020 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18021 };
18022 failure(code, "harness MCP proxy operation failed")
18023}
18024
18025static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18029
18030fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18037 let trace = launch.trace.as_ref()?;
18038 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18039 if trace_dir.is_empty() {
18040 return None;
18041 }
18042 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18043 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18044 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18045}
18046
18047fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18048 let remaining = deadline.saturating_duration_since(Instant::now());
18049 if remaining.is_zero() {
18050 Err(failure(
18051 NodeFailureCode::SpawnDeadlineExceeded,
18052 "spawn deadline elapsed",
18053 ))
18054 } else {
18055 Ok(remaining)
18056 }
18057}
18058
18059fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18060 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18061 match deadline {
18062 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18063 None => Ok(bounded),
18064 }
18065}
18066
18067fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18068 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18069 failure(
18070 NodeFailureCode::SpawnDeadlineExceeded,
18071 "spawn dispatch exceeded the spawn deadline",
18072 )
18073 } else {
18074 error
18075 }
18076}
18077
18078fn spawn_runtime_capabilities(
18079 capabilities: &SpawnRequiredCapabilities,
18080) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18081 capabilities
18082 .iter()
18083 .map(|capability| match capability.as_str() {
18084 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18085 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18086 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18087 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18088 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18089 }
18090 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18091 _ => Err(failure(
18092 NodeFailureCode::UnsupportedSpawnCapability,
18093 "spawn required capability is unknown",
18094 )),
18095 })
18096 .collect()
18097}
18098
18099fn persistence_failure(_error: io::Error) -> NodeFailure {
18100 NodeFailure {
18101 code: NodeFailureCode::BackendOperationFailed,
18102 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18103 }
18104}
18105
18106fn node_failure_category(code: NodeFailureCode) -> &'static str {
18107 match code {
18108 NodeFailureCode::InvalidRequest => "invalid-request",
18109 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18110 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18111 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18112 NodeFailureCode::ReservationConflict => "reservation-conflict",
18113 NodeFailureCode::ReservationExpired => "reservation-expired",
18114 NodeFailureCode::BindingMismatch => "binding-mismatch",
18115 NodeFailureCode::NotActivated => "not-activated",
18116 NodeFailureCode::CallNotFound => "call-not-found",
18117 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18118 NodeFailureCode::ResponseTooLarge => "response-too-large",
18119 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18120 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18121 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18122 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18123 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18124 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18125 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18126 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18127 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18128 NodeFailureCode::Unauthorized => "unauthorized",
18129 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18130 NodeFailureCode::ControllerBusy => "controller-busy",
18131 NodeFailureCode::ControllerRequired => "controller-required",
18132 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18133 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18134 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18135 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18136 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18137 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18138 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18139 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18140 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18141 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18142 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18143 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18144 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18145 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18146 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18147 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18148 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18149 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18150 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18151 NodeFailureCode::GitReadFailed => "git-read-failed",
18152 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18153 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18154 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18155 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18156 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18157 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18158 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18159 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18160 NodeFailureCode::TurnInFlight => "turn-in-flight",
18161 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18162 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18163 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18164 "unsupported-network-allowlist-mapping"
18165 }
18166 NodeFailureCode::BrowserStationProbeUnavailable => {
18167 "browser-station-probe-unavailable"
18168 }
18169 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18170 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18171 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18172 "environment-profile-binding-mismatch"
18173 }
18174 NodeFailureCode::UnknownBundle => "unknown-bundle",
18175 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18176 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18177 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18178 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18179 NodeFailureCode::ContextPackMaterializationFailed => {
18180 "context-pack-materialization-failed"
18181 }
18182 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18183 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18184 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18185 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18186 NodeFailureCode::LastWorkspace => "last-workspace",
18187 NodeFailureCode::NotGitRepository => "not-git-repository",
18188 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18189 NodeFailureCode::WorktreeProtected => "worktree-protected",
18190 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18191 NodeFailureCode::WorktreeLocked => "worktree-locked",
18192 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18193 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18194 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18195 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18196 "managed-worktree-profile-revision-mismatch"
18197 }
18198 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18199 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18200 "standalone-workspace-recovery-required"
18201 }
18202 NodeFailureCode::UnknownSession => "unknown-session",
18203 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18204 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18205 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18206 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18207 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18208 NodeFailureCode::WorkspaceRegistrationRequired => {
18209 "workspace-registration-required"
18210 }
18211 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18212 NodeFailureCode::StaleGeneration => "stale-generation",
18213 NodeFailureCode::BackendBusy => "backend-busy",
18214 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18215 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18216 NodeFailureCode::ShuttingDown => "shutting-down",
18217 }
18218}
18219
18220fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18221 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18222}
18223
18224fn durable_state_load_error(error: io::Error) -> NodeServerError {
18225 let category = match session_registry::state_load_refusal(&error) {
18226 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18227 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18228 }
18229 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18230 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18231 }
18232 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18233 DURABLE_STATE_CONFLICT_ERROR
18234 }
18235 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18236 };
18237 durable_state_server_error(error, category)
18238}
18239
18240fn unix_time_ms() -> u64 {
18241 std::time::SystemTime::now()
18242 .duration_since(std::time::UNIX_EPOCH)
18243 .unwrap_or_default()
18244 .as_millis()
18245 .min(u64::MAX as u128) as u64
18246}
18247
18248fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18249 if record.provider_session.is_some() {
18250 ManagedSessionState::Dormant
18251 } else {
18252 ManagedSessionState::Unavailable
18253 }
18254}
18255
18256fn active_record_state(
18257 identity_admitted: bool,
18258 provider_session_is_known: bool,
18259) -> ManagedSessionState {
18260 if identity_admitted && !provider_session_is_known {
18261 ManagedSessionState::IdentityPending
18262 } else {
18263 ManagedSessionState::Live
18264 }
18265}
18266
18267fn managed_worktree_record_holders(
18268 records: &[ManagedSessionRecord],
18269) -> Vec<(SessionRecordId, WorkspaceId)> {
18270 records
18271 .iter()
18272 .filter(|record| record.provider_session.is_some())
18273 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18274 .collect()
18275}
18276
18277fn managed_spawn_record_ownership(
18278 binding: &SessionBinding,
18279 effective_runtime_policy: ProviderRuntimePolicy,
18280) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18281 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18282 if effective_runtime_policy.provider_session_identity {
18283 (binding.record_id.clone(), None)
18284 } else {
18285 (None, binding.record_id.clone())
18286 }
18287}
18288
18289#[derive(Debug, Error)]
18290pub enum NodeServerError {
18291 #[error("node endpoint must be a bounded absolute local transport path")]
18292 InvalidEndpoint,
18293 #[error("node access token must contain 1..=4096 bytes")]
18294 InvalidAccessToken,
18295 #[error("node HTTP observer must listen on a loopback address: {0}")]
18296 InvalidApiListen(std::net::SocketAddr),
18297 #[error("node browser bridge must listen on a loopback address: {0}")]
18298 InvalidBridgeListen(std::net::SocketAddr),
18299 #[error("node browser bridge token must contain 1..=4096 bytes")]
18300 InvalidBridgeToken,
18301 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18302 BridgeUnderlayRequiresBridgeListen,
18303 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18304 BridgeUnderlayRequiresBridgeToken,
18305 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18306 InvalidBridgeUnderlayToken,
18307 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18308 InvalidBridgeUnderlayKey,
18309 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18310 InvalidCallHome(std::net::SocketAddr),
18311 #[error("node could not reach its call-home relay: {0}")]
18312 CallHomeTransport(#[source] std::io::Error),
18313 #[error("node durable state path must be an absolute file path: {0}")]
18314 InvalidStatePath(String),
18315 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18316 InvalidHarnessMcpHelper,
18317 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18318 InvalidNetworkAllowlistCatalogPath(String),
18319 #[error("network allowlist catalog could not be read: {0}")]
18320 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18321 #[error("network allowlist catalog line {line}: {message}")]
18322 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18323 #[error("network allowlist catalog: {0}")]
18324 InvalidNetworkAllowlistCatalog(String),
18325 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18326 NetworkAllowlistCatalogCapacity { max: usize },
18327 #[error("the local state directory is unavailable; supply an explicit state path")]
18328 LocalStateDirectoryUnavailable,
18329 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18330 LocalRuntimeDirectoryUnavailable,
18331 #[error("node durable state failed: {0}")]
18332 DurableState(io::Error),
18333 #[error("node requires at least one configured workspace")]
18334 NoWorkspaces,
18335 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18336 InvalidWorkspaceRoot {
18337 workspace_id: WorkspaceId,
18338 path: String,
18339 message: String,
18340 },
18341 #[error("duplicate workspace ID: {0}")]
18342 DuplicateWorkspaceId(WorkspaceId),
18343 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18344 DuplicateWorkspaceRoot {
18345 first: WorkspaceId,
18346 second: WorkspaceId,
18347 root: String,
18348 },
18349 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18350 ManagedWorktreeProfileRequired(WorkspaceId),
18351 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18352 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18353 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18354 DuplicateManagedWorktreeProfile {
18355 workspace_id: WorkspaceId,
18356 profile_id: WorktreeProfileId,
18357 },
18358 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18359 ManagedWorktreeProfileCapacity {
18360 workspace_id: WorkspaceId,
18361 max: usize,
18362 },
18363 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18364 InvalidManagedWorktreeProfile {
18365 workspace_id: WorkspaceId,
18366 profile_id: WorktreeProfileId,
18367 message: String,
18368 },
18369 #[error("duplicate node environment profile: {0}")]
18370 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18371 #[error("node environment profile provider is unavailable: {0}")]
18372 EnvironmentProfileProviderUnavailable(AgentId),
18373 #[error("node environment profile capacity is {max}")]
18374 EnvironmentProfileCapacity { max: usize },
18375 #[error("node environment profiles reuse native profile ID '{0}'")]
18376 DuplicateNativeEnvironmentProfile(String),
18377 #[error("node native environment profile is invalid: {0}")]
18378 NativeEnvironmentProfile(String),
18379 #[error("node session-environment materialization root must be absolute")]
18380 InvalidSessionEnvironmentRoot,
18381 #[error("node session-environment materializer is unavailable")]
18382 SessionEnvironmentMaterializerRequired,
18383 #[error("node session-environment materializer failed to initialize")]
18384 SessionEnvironmentMaterializer,
18385 #[error("node bundle catalog is invalid: {0}")]
18386 BundleCatalog(String),
18387 #[error("node delivery store failed to initialize: {0}")]
18388 DeliveryStore(String),
18389 #[error("node context pack store failed to initialize")]
18390 ContextPackStore,
18391 #[error("active agent registry failed: {0}")]
18392 Registry(String),
18393 #[error("node provider contract manifest is invalid: {0}")]
18394 ProviderContractManifest(String),
18395 #[error("named pipe I/O failed: {0}")]
18396 Io(#[from] io::Error),
18397 #[error("node HTTP observer failed: {0}")]
18398 HttpApi(io::Error),
18399 #[error("node browser bridge failed: {0}")]
18400 BridgeApi(io::Error),
18401 #[error(transparent)]
18402 Frame(#[from] FrameError),
18403 #[error("node handshake failed: {0}")]
18404 Handshake(String),
18405 #[error("node authentication frame was not received before the bounded deadline")]
18406 AuthenticationTimedOut,
18407 #[error("node authentication primitive failed: {0}")]
18408 Authentication(String),
18409 #[error("node incarnation identity generation failed: {0}")]
18410 IncarnationIdentity(String),
18411 #[error("node pre-authentication limiter is closed")]
18412 PreauthClosed,
18413 #[error("node authenticated connection limit was reached")]
18414 AuthenticatedConnectionLimit,
18415 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18416 SignalStreamClosed,
18417 #[error("node stop-all dispatch failed: {0:?}")]
18418 ShutdownDispatch(NodeFailure),
18419 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18420 ShutdownTimedOut { active_native_sessions: usize },
18421}
18422
18423impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18424 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18425 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18426 match error {
18427 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18428 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18429 E::InvalidEntry { line, message } => {
18430 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18431 }
18432 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18433 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18434 }
18435 }
18436}
18437
18438#[cfg(test)]
18439mod agent_stream_blocked_tests {
18440 use super::*;
18441 use gate4agent_types::{
18442 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18443 ProviderSource,
18444 };
18445
18446 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18447 ProviderSource {
18448 family,
18449 binding: AdapterBinding::new(
18450 AdapterId::new(adapter).unwrap(),
18451 "fixture/v1",
18452 AdapterVerification::SyntheticFixture,
18453 )
18454 .unwrap(),
18455 }
18456 }
18457
18458 fn provider_control_event_at(
18459 family: AdapterFamily,
18460 adapter: &str,
18461 source_sequence: u64,
18462 provider_event: ProviderEvent,
18463 ) -> ControlEvent {
18464 ControlEvent {
18465 sequence: 41,
18466 command_id: None,
18467 instance_id: AgentInstanceId(7),
18468 generation: SessionGeneration(3),
18469 event: ControlEventKind::ProviderEvent {
18470 sequence: 8,
18471 source: provider_source(family, adapter),
18472 source_sequence,
18473 event: provider_event,
18474 },
18475 }
18476 }
18477
18478 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18479 provider_control_event_at(family, "fixture", 9, provider_event)
18480 }
18481
18482 fn denied_host_request(
18483 by: ProviderHostDecisionAuthority,
18484 reason: Option<String>,
18485 ) -> ControlEvent {
18486 provider_control_event_at(
18487 AdapterFamily::Acp,
18488 "claude-code",
18489 2,
18490 ProviderEvent::HostRequestObserved {
18491 method: "terminal/create".to_owned(),
18492 params_json: String::new(),
18493 decision: ProviderHostRequestDecision::Denied { by },
18494 outcome: ProviderHostRequestOutcome::Executed,
18495 reason,
18496 },
18497 )
18498 }
18499
18500 fn blocked(
18501 event: &ControlEvent,
18502 ) -> (
18503 Option<String>,
18504 String,
18505 BlockAuthorityV1,
18506 Option<String>,
18507 String,
18508 Option<String>,
18509 ) {
18510 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18511 chunk.validate().expect("minted chunk must validate");
18512 let AgentStreamChunkKindV1::Blocked {
18513 correlation_id,
18514 tool_class,
18515 authority,
18516 reason_kind,
18517 reason,
18518 help,
18519 } = chunk.kind
18520 else {
18521 panic!("expected AgentStreamChunkKindV1::Blocked");
18522 };
18523 (correlation_id, tool_class, authority, reason_kind, reason, help)
18524 }
18525
18526 #[test]
18530 fn the_enabled_provider_matrix_is_exact() {
18531 let registry = active_registry().unwrap();
18532 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18533 assert_eq!(
18534 providers
18535 .iter()
18536 .map(|contract| contract.provider.as_str())
18537 .collect::<Vec<_>>(),
18538 vec!["shell", "claude", "codex", "grok", "kimi"],
18540 );
18541 assert!(adapters.iter().all(|contract| {
18542 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18543 }));
18544 }
18545
18546 #[test]
18549 fn gate_denied_host_request_mints_a_blocked_chunk() {
18550 let gate_text =
18551 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18552 let event = denied_host_request(
18553 ProviderHostDecisionAuthority::Gate,
18554 Some(gate_text.to_owned()),
18555 );
18556 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18557 assert_eq!(chunk.source_sequence, 2);
18558 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18559 assert_eq!(authority, BlockAuthorityV1::HostGate);
18560 assert_eq!(reason, gate_text);
18561 assert_eq!(correlation_id, None);
18562 assert_eq!(reason_kind, None);
18563 assert_eq!(help, None);
18564 assert_eq!(tool_class, "Write");
18565 }
18566
18567 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.";
18568
18569 #[test]
18575 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18576 let event = provider_control_event(
18577 AdapterFamily::Acp,
18578 ProviderEvent::ToolCompleted {
18579 id: "toolu_9".to_owned(),
18580 output: CLASSIFIER_SAMPLE.to_owned(),
18581 is_error: true,
18582 duration_ms: None,
18583 agent_id: None,
18584 non_execution_kind: Some("permission-rule".to_owned()),
18585 },
18586 );
18587 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18588 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18589 assert_eq!(reason, "Blocked by classifier");
18590 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18591 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18592 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18593 }
18594
18595 #[test]
18600 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18601 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.";
18602 let event = provider_control_event(
18603 AdapterFamily::Acp,
18604 ProviderEvent::SessionEnded {
18605 result: "JSON-RPC error -32603: Internal error".to_owned(),
18606 cost_usd: None,
18607 is_error: true,
18608 stop_reason: Some(ProviderStopReason::ProviderError {
18609 code: -32603,
18610 message: message.to_owned(),
18611 vendor_code: Some("usageLimitExceeded".to_owned()),
18612 }),
18613 },
18614 );
18615 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18616 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18617 assert_eq!(reason, message);
18618 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18619 assert_eq!(tool_class, "Session");
18620 }
18621
18622 #[test]
18625 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18626 let event = provider_control_event(
18627 AdapterFamily::Acp,
18628 ProviderEvent::SessionEnded {
18629 result: "refusal".to_owned(),
18630 cost_usd: None,
18631 is_error: true,
18632 stop_reason: Some(ProviderStopReason::Refusal),
18633 },
18634 );
18635 let (_, _, authority, _, _, _) = blocked(&event);
18636 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18637 }
18638
18639 #[test]
18643 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18644 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18645 let event = provider_control_event(
18646 AdapterFamily::Acp,
18647 ProviderEvent::ToolCompleted {
18648 id: "toolu_1".to_owned(),
18649 output: "command exited with status 1".to_owned(),
18650 is_error: true,
18651 duration_ms: Some(12),
18652 agent_id: None,
18653 non_execution_kind,
18654 },
18655 );
18656 assert!(agent_stream_chunk(&event).is_none());
18657 }
18658 let narrative = provider_control_event(
18659 AdapterFamily::Acp,
18660 ProviderEvent::ToolCompleted {
18661 id: "toolu_2".to_owned(),
18662 output: "the harness blocked the write, approval needed".to_owned(),
18663 is_error: false,
18664 duration_ms: Some(3),
18665 agent_id: None,
18666 non_execution_kind: None,
18667 },
18668 );
18669 assert!(agent_stream_chunk(&narrative).is_none());
18670 }
18671
18672 #[test]
18676 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18677 for (decision, outcome) in [
18678 (
18679 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18680 ProviderHostRequestOutcome::Executed,
18681 ),
18682 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18683 (
18684 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18685 ProviderHostRequestOutcome::Failed {
18686 error: "terminal/create spawn failed: os error 3".to_owned(),
18687 },
18688 ),
18689 ] {
18690 let event = provider_control_event_at(
18691 AdapterFamily::Acp,
18692 "claude-code",
18693 3,
18694 ProviderEvent::HostRequestObserved {
18695 method: "fs/read_text_file".to_owned(),
18696 params_json: String::new(),
18697 decision: decision.clone(),
18698 outcome,
18699 reason: None,
18700 },
18701 );
18702 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18703 }
18704 }
18705
18706 #[test]
18710 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18711 for (by, expected_authority, expected_reason) in [
18712 (
18713 ProviderHostDecisionAuthority::Operator,
18714 BlockAuthorityV1::Operator,
18715 "declined by the operator",
18716 ),
18717 (
18718 ProviderHostDecisionAuthority::DeadlinePolicy,
18719 BlockAuthorityV1::HostDeadline,
18720 "denied by host policy after the operator deadline expired",
18721 ),
18722 ] {
18723 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18724 assert_eq!(authority, expected_authority);
18725 assert_eq!(reason, expected_reason);
18726 }
18727 }
18728
18729 #[test]
18733 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18734 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18735 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18736 ProviderHostDecisionAuthority::Policy,
18737 Some(oversized),
18738 ));
18739 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18740 }
18741}
18742
18743#[cfg(test)]
18744mod standalone_capability_platform_tests {
18745 use super::*;
18746
18747 #[test]
18748 fn standalone_workspace_capability_requires_windows_identity_guards() {
18749 let capabilities = baseline_capabilities().unwrap();
18750 let advertised = capabilities.iter().any(|capability| {
18751 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18752 });
18753 assert_eq!(advertised, cfg!(windows));
18754
18755 let request = NodeRequest::CreateStandaloneWorkspace {
18756 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18757 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18758 initial_branch: None,
18759 };
18760 assert_eq!(
18761 request_uses_unnegotiated_capability(&request, &capabilities),
18762 !cfg!(windows),
18763 );
18764 }
18765}
18766
18767
18768#[cfg(test)]
18769mod station_knobs_resolve_tests {
18770 use super::*;
18771 use gate4agent_catalog::EnvMutation;
18772 use gate4agent_runtime_native::{
18773 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18774 NativeLaunchProfileId,
18775 };
18776 use std::ffi::OsString;
18777 use std::sync::Arc;
18778
18779 struct EmptyEnvironmentResolver;
18780
18781 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18782 fn resolve_child_environment(
18783 &self,
18784 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18785 Ok(vec![EnvMutation {
18786 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18787 value: None,
18788 }])
18789 }
18790 }
18791
18792 fn agent(value: &str) -> AgentId {
18793 AgentId::new(value).unwrap()
18794 }
18795
18796 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18797 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18798 let profile_revision =
18799 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18800 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18801 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18802 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18803 provider: agent("claude"),
18804 mode: SessionMode::Pty,
18805 terminal_size: gate4agent_types::TerminalSize {
18806 rows: 24,
18807 columns: 80,
18808 },
18809 prompt: None,
18810 bundle_id: None,
18811 context_id: None,
18812 environment_profile_id: Some(profile_id.clone()),
18813 };
18814 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18815 let catalog = active_registry().unwrap();
18816 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18817 let control = runtime.native_launch_profile_control();
18818 let node_profile = NodeEnvironmentProfile::new(
18819 profile_id.clone(),
18820 profile_revision,
18821 agent("claude"),
18822 [NativeLaunchProfile::new(
18823 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18824 agent("claude"),
18825 TransportKind::Pty,
18826 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18827 Arc::new(EmptyEnvironmentResolver),
18828 )
18829 .unwrap()],
18830 )
18831 .unwrap();
18832 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18833 for native_profile in native_profiles {
18834 runtime.upsert_native_launch_profile(native_profile).unwrap();
18835 }
18836 let workspace = WorkspaceConfig::new(
18837 WorkspaceId::new("primary").unwrap(),
18838 std::env::current_dir().unwrap(),
18839 )
18840 .unwrap();
18841 let shared = NodeShared::new_with_incarnation(
18842 handle,
18843 "fixture-token".to_owned(),
18844 NodeId::new("node-station-knobs-test").unwrap(),
18845 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18846 vec![workspace],
18847 vec![agent("claude")],
18848 ProviderRuntimeStatuses::default(),
18849 None,
18850 Vec::new(),
18851 Vec::new(),
18852 spawn_profiles,
18853 Some(control),
18854 None,
18855 None,
18856 None,
18857 Vec::new(),
18858 Vec::new(),
18859 Vec::new(),
18860 Vec::new(),
18861 None,
18862 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18863 );
18864 shared
18865 .environment_profiles
18866 .write()
18867 .unwrap_or_else(|poisoned| poisoned.into_inner())
18868 .insert(binding.id.clone(), binding);
18869 (shared, spawn_defaults)
18870 }
18871
18872 #[test]
18875 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18876 let (shared, defaults) = station_knobs_fixture();
18877 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18878 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18879 let spec = crate::protocol::SpawnSpec {
18880 target: crate::protocol::SpawnTarget {
18881 node_id: shared.node_id.clone(),
18882 workspace_id: WorkspaceId::new("primary").unwrap(),
18883 worktree_id: None,
18884 },
18885 profile_id: defaults.profile_id.clone(),
18886 expected_profile_revision: defaults.revision.clone(),
18887 overrides: crate::protocol::SpawnOverrides {
18888 network_allowlist: Some(unknown),
18889 browser_profile_id: Some(browser),
18890 ..crate::protocol::SpawnOverrides::default()
18891 },
18892 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18893 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18894 "station-knobs-unknown-allowlist",
18895 )
18896 .unwrap(),
18897 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18898 };
18899 let resolved = spec.resolve(&defaults).unwrap();
18900 let failure = shared
18901 .resolve_environment_profile(&resolved)
18902 .expect_err("unknown allowlist must refuse");
18903 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18904 }
18905
18906 #[test]
18912 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18913 let (shared, defaults) = station_knobs_fixture();
18914 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18915 #[cfg(not(feature = "dig2-station-probe"))]
18916 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18917 shared
18918 .network_allowlist_catalog
18919 .write()
18920 .unwrap_or_else(|poisoned| poisoned.into_inner())
18921 .insert_id(allowlist.clone())
18922 .unwrap();
18923 let spec = crate::protocol::SpawnSpec {
18924 target: crate::protocol::SpawnTarget {
18925 node_id: shared.node_id.clone(),
18926 workspace_id: WorkspaceId::new("primary").unwrap(),
18927 worktree_id: None,
18928 },
18929 profile_id: defaults.profile_id.clone(),
18930 expected_profile_revision: defaults.revision.clone(),
18931 overrides: crate::protocol::SpawnOverrides {
18932 network_allowlist: Some(allowlist.clone()),
18933 #[cfg(not(feature = "dig2-station-probe"))]
18934 browser_profile_id: Some(browser.clone()),
18935 ..crate::protocol::SpawnOverrides::default()
18936 },
18937 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18938 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18939 .unwrap(),
18940 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18941 };
18942 let resolved = spec.resolve(&defaults).unwrap();
18943 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18944 #[cfg(not(feature = "dig2-station-probe"))]
18945 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18946 let receipt = shared
18947 .resolve_environment_profile(&resolved)
18948 .expect("resolve ok")
18949 .expect("env profile present");
18950 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18951 #[cfg(not(feature = "dig2-station-probe"))]
18952 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18953 #[cfg(feature = "dig2-station-probe")]
18954 assert_eq!(receipt.browser_profile_id, None);
18955 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18956
18957 let omitted = crate::protocol::SpawnSpec {
18958 overrides: crate::protocol::SpawnOverrides::default(),
18959 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18960 .unwrap(),
18961 ..spec
18962 };
18963 let resolved_omit = omitted.resolve(&defaults).unwrap();
18964 let bare = shared
18965 .resolve_environment_profile(&resolved_omit)
18966 .expect("resolve ok")
18967 .expect("env profile present");
18968 assert_eq!(bare.network_allowlist, None);
18969 assert_eq!(bare.browser_profile_id, None);
18970 }
18971
18972 #[cfg(feature = "dig2-station-probe")]
18977 #[test]
18978 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18979 let (shared, defaults) = station_knobs_fixture();
18980 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18981 let spec = crate::protocol::SpawnSpec {
18982 target: crate::protocol::SpawnTarget {
18983 node_id: shared.node_id.clone(),
18984 workspace_id: WorkspaceId::new("primary").unwrap(),
18985 worktree_id: None,
18986 },
18987 profile_id: defaults.profile_id.clone(),
18988 expected_profile_revision: defaults.revision.clone(),
18989 overrides: crate::protocol::SpawnOverrides {
18990 browser_profile_id: Some(browser),
18991 ..crate::protocol::SpawnOverrides::default()
18992 },
18993 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18994 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18995 "station-knobs-probe-refuse",
18996 )
18997 .unwrap(),
18998 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18999 };
19000 let resolved = spec.resolve(&defaults).unwrap();
19001 let failure = shared
19002 .resolve_environment_profile(&resolved)
19003 .expect_err("browser_profile with failing probe must refuse");
19004 #[cfg(windows)]
19005 {
19006 assert!(
19007 matches!(
19008 failure.code,
19009 NodeFailureCode::BrowserStationUnreachable
19010 | NodeFailureCode::BrowserStationProbeUnavailable
19011 ),
19012 "unexpected code {:?}",
19013 failure.code
19014 );
19015 }
19016 #[cfg(not(windows))]
19017 {
19018 assert_eq!(
19019 failure.code,
19020 NodeFailureCode::BrowserStationProbeUnavailable
19021 );
19022 }
19023 }
19024
19025 #[cfg(feature = "dig2-station-probe")]
19027 #[test]
19028 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19029 let (shared, defaults) = station_knobs_fixture();
19030 let spec = crate::protocol::SpawnSpec {
19031 target: crate::protocol::SpawnTarget {
19032 node_id: shared.node_id.clone(),
19033 workspace_id: WorkspaceId::new("primary").unwrap(),
19034 worktree_id: None,
19035 },
19036 profile_id: defaults.profile_id.clone(),
19037 expected_profile_revision: defaults.revision.clone(),
19038 overrides: crate::protocol::SpawnOverrides::default(),
19039 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19040 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19041 "station-knobs-probe-omit",
19042 )
19043 .unwrap(),
19044 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19045 };
19046 let resolved = spec.resolve(&defaults).unwrap();
19047 let receipt = shared
19048 .resolve_environment_profile(&resolved)
19049 .expect("omit browser_profile must not probe")
19050 .expect("env profile present");
19051 assert_eq!(receipt.browser_profile_id, None);
19052 }
19053
19054 #[cfg(feature = "dig2-station-probe")]
19058 #[test]
19059 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19060 let (shared, _defaults) = station_knobs_fixture();
19061 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19062 let workspace_id = WorkspaceId::new("primary").unwrap();
19063 let address_a = SessionAddress {
19064 workspace_id: workspace_id.clone(),
19065 session: SessionKey {
19066 instance_id: AgentInstanceId(101),
19067 generation: SessionGeneration(1),
19068 },
19069 };
19070 let address_b = SessionAddress {
19071 workspace_id: workspace_id.clone(),
19072 session: SessionKey {
19073 instance_id: AgentInstanceId(102),
19074 generation: SessionGeneration(1),
19075 },
19076 };
19077 let guard_a = crate::dig2_station_lease::acquire_lease(
19078 &shared.browser_station_leases,
19079 profile.clone(),
19080 address_a.session.instance_id,
19081 1,
19082 )
19083 .expect("first lease");
19084 let busy = crate::dig2_station_lease::acquire_lease(
19085 &shared.browser_station_leases,
19086 profile.clone(),
19087 address_b.session.instance_id,
19088 2,
19089 )
19090 .expect_err("second holder must refuse");
19091 assert!(matches!(
19092 busy,
19093 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19094 ));
19095 shared.bind_session_with_policy(
19097 &address_a,
19098 ProviderRuntimePolicy::raw_pty(),
19099 None,
19100 );
19101 guard_a.retain();
19102 assert!(shared.remove_binding(&address_a).is_some());
19103 crate::dig2_station_lease::acquire_lease(
19104 &shared.browser_station_leases,
19105 profile,
19106 address_b.session.instance_id,
19107 3,
19108 )
19109 .expect("reacquire after clear")
19110 .retain();
19111 assert!(
19112 crate::dig2_station_lease::release_lease_for_holder(
19113 &shared.browser_station_leases,
19114 address_b.session.instance_id,
19115 )
19116 );
19117 }
19118
19119 #[cfg(not(feature = "dig2-station-probe"))]
19122 #[test]
19123 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19124 let (shared, defaults) = station_knobs_fixture();
19125 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19126 let spec = crate::protocol::SpawnSpec {
19127 target: crate::protocol::SpawnTarget {
19128 node_id: shared.node_id.clone(),
19129 workspace_id: WorkspaceId::new("primary").unwrap(),
19130 worktree_id: None,
19131 },
19132 profile_id: defaults.profile_id.clone(),
19133 expected_profile_revision: defaults.revision.clone(),
19134 overrides: crate::protocol::SpawnOverrides {
19135 browser_profile_id: Some(browser.clone()),
19136 ..crate::protocol::SpawnOverrides::default()
19137 },
19138 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19139 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19140 .unwrap(),
19141 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19142 };
19143 let resolved = spec.resolve(&defaults).unwrap();
19144 let receipt = shared
19145 .resolve_environment_profile(&resolved)
19146 .expect("stub resolve")
19147 .expect("env profile");
19148 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19149 }
19150
19151
19152 #[test]
19155 fn network_allowlist_catalog_loads_from_temp_file() {
19156 let unique = std::time::SystemTime::now()
19157 .duration_since(std::time::UNIX_EPOCH)
19158 .unwrap()
19159 .as_nanos();
19160 let path = std::env::temp_dir().join(format!(
19161 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19162 std::process::id(),
19163 ));
19164 std::fs::write(
19165 &path,
19166 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19167 )
19168 .unwrap();
19169 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19170 .expect("temp catalog file must load");
19171 let _ = std::fs::remove_file(&path);
19172 assert_eq!(loaded.len(), 2);
19173 let (shared, defaults) = station_knobs_fixture();
19174 {
19175 let mut catalog = shared
19176 .network_allowlist_catalog
19177 .write()
19178 .unwrap_or_else(|poisoned| poisoned.into_inner());
19179 *catalog = loaded;
19180 }
19181 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19182 let spec = crate::protocol::SpawnSpec {
19183 target: crate::protocol::SpawnTarget {
19184 node_id: shared.node_id.clone(),
19185 workspace_id: WorkspaceId::new("primary").unwrap(),
19186 worktree_id: None,
19187 },
19188 profile_id: defaults.profile_id.clone(),
19189 expected_profile_revision: defaults.revision.clone(),
19190 overrides: crate::protocol::SpawnOverrides {
19191 network_allowlist: Some(allowlist.clone()),
19192 ..crate::protocol::SpawnOverrides::default()
19193 },
19194 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19195 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19196 "station-knobs-catalog-file",
19197 )
19198 .unwrap(),
19199 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19200 };
19201 let resolved = spec.resolve(&defaults).unwrap();
19202 let receipt = shared
19203 .resolve_environment_profile(&resolved)
19204 .expect("registered via file load")
19205 .expect("env profile present");
19206 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19207 }
19208
19209 #[test]
19212 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19213 let (shared, _defaults) = station_knobs_fixture();
19214 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19215 shared
19216 .network_allowlist_catalog
19217 .write()
19218 .unwrap_or_else(|poisoned| poisoned.into_inner())
19219 .insert_id(allowlist.clone())
19220 .unwrap();
19221 let snapshot = shared.snapshot();
19222 let inventory = snapshot
19223 .launch_inventory
19224 .expect("launch inventory present");
19225 let ids = inventory
19226 .network_allowlists
19227 .expect("non-empty catalog surfaces ids");
19228 assert_eq!(ids, vec![allowlist]);
19229 }
19230
19231 #[test]
19235 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19236 let (shared, defaults) = station_knobs_fixture();
19237 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19238 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19239 id: allowlist.clone(),
19240 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19241 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19242 peer: "127.0.0.1:443".to_owned(),
19243 }],
19244 provider_native: Some(
19245 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19246 codex_network_access: Some(true),
19247 },
19248 ),
19249 };
19250 shared
19251 .network_allowlist_catalog
19252 .write()
19253 .unwrap_or_else(|poisoned| poisoned.into_inner())
19254 .insert(entry)
19255 .unwrap();
19256 let spec = crate::protocol::SpawnSpec {
19257 target: crate::protocol::SpawnTarget {
19258 node_id: shared.node_id.clone(),
19259 workspace_id: WorkspaceId::new("primary").unwrap(),
19260 worktree_id: None,
19261 },
19262 profile_id: defaults.profile_id.clone(),
19263 expected_profile_revision: defaults.revision.clone(),
19264 overrides: crate::protocol::SpawnOverrides {
19265 network_allowlist: Some(allowlist),
19266 ..crate::protocol::SpawnOverrides::default()
19267 },
19268 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19269 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19270 "station-knobs-unsupported-mapping",
19271 )
19272 .unwrap(),
19273 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19274 };
19275 let resolved = spec.resolve(&defaults).unwrap();
19276 assert_eq!(resolved.provider.as_str(), "claude");
19277 let failure = shared
19278 .resolve_environment_profile(&resolved)
19279 .expect_err("claude + codex_network_access must refuse");
19280 assert_eq!(
19281 failure.code,
19282 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19283 );
19284 }
19285
19286 #[test]
19290 fn resolve_codex_network_access_overlay_moderate_only() {
19291 let (shared, defaults) = station_knobs_fixture();
19292 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19293 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19294 id: allowlist.clone(),
19295 permits: Vec::new(),
19296 provider_native: Some(
19297 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19298 codex_network_access: Some(true),
19299 },
19300 ),
19301 };
19302 shared
19303 .network_allowlist_catalog
19304 .write()
19305 .unwrap_or_else(|poisoned| poisoned.into_inner())
19306 .insert(entry)
19307 .unwrap();
19308
19309 let overlay = shared
19310 .resolve_codex_network_access_overlay_args(
19311 &agent("codex"),
19312 SessionMode::Pty,
19313 ApprovalLevel::Moderate,
19314 Some(&allowlist),
19315 )
19316 .expect("Moderate Codex maps network_access");
19317 assert_eq!(
19318 overlay,
19319 vec![
19320 std::ffi::OsString::from("-c"),
19321 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19322 ]
19323 );
19324
19325 let acp = shared
19326 .resolve_codex_network_access_overlay_args(
19327 &agent("codex"),
19328 SessionMode::Acp,
19329 ApprovalLevel::Moderate,
19330 Some(&allowlist),
19331 )
19332 .unwrap();
19333 assert_eq!(acp, overlay);
19334
19335 let inline = shared
19336 .resolve_codex_network_access_overlay_args(
19337 &agent("codex"),
19338 SessionMode::Inline,
19339 ApprovalLevel::Moderate,
19340 Some(&allowlist),
19341 )
19342 .unwrap_err();
19343 assert_eq!(
19344 inline.code,
19345 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19346 );
19347
19348 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19349 let spec = crate::protocol::SpawnSpec {
19350 target: crate::protocol::SpawnTarget {
19351 node_id: shared.node_id.clone(),
19352 workspace_id: WorkspaceId::new("primary").unwrap(),
19353 worktree_id: None,
19354 },
19355 profile_id: defaults.profile_id.clone(),
19356 expected_profile_revision: defaults.revision.clone(),
19357 overrides: crate::protocol::SpawnOverrides {
19358 provider: crate::protocol::SpawnOverride::Set {
19359 value: agent("codex"),
19360 },
19361 approval_level: Some(level),
19362 network_allowlist: Some(allowlist.clone()),
19363 ..crate::protocol::SpawnOverrides::default()
19364 },
19365 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19366 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19367 "codex-net-refuse-{}",
19368 match level {
19369 ApprovalLevel::ReadOnly => "ro",
19370 ApprovalLevel::FullAuto => "fa",
19371 _ => "other",
19372 }
19373 ))
19374 .unwrap(),
19375 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19376 };
19377 let resolved = spec.resolve(&defaults).unwrap();
19378 let failure = shared
19379 .resolve_environment_profile(&resolved)
19380 .expect_err("RO/FullAuto must refuse");
19381 assert_eq!(
19382 failure.code,
19383 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19384 );
19385 }
19386 }
19387
19388 #[test]
19392 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19393 let (shared, _defaults) = station_knobs_fixture();
19394 let allowlist =
19395 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19396 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19397 id: allowlist.clone(),
19398 permits: vec![
19399 crate::network_allowlist_catalog::NetworkPermitSketch {
19400 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19401 peer: "github.com:443".to_owned(),
19402 },
19403 crate::network_allowlist_catalog::NetworkPermitSketch {
19404 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19405 peer: "*.npmjs.org".to_owned(),
19406 },
19407 ],
19408 provider_native: None,
19409 };
19410 shared
19411 .network_allowlist_catalog
19412 .write()
19413 .unwrap_or_else(|poisoned| poisoned.into_inner())
19414 .insert(entry)
19415 .unwrap();
19416
19417 let unique = std::time::SystemTime::now()
19418 .duration_since(std::time::UNIX_EPOCH)
19419 .unwrap()
19420 .as_nanos();
19421 let provider_home = std::env::temp_dir().join(format!(
19422 "gate4agent-claude-provider-home-{}-{unique}",
19423 std::process::id(),
19424 ));
19425 std::fs::create_dir_all(&provider_home).unwrap();
19426
19427 let overlay = shared
19428 .resolve_network_allowlist_overlay_args(
19429 &agent("claude"),
19430 SessionMode::Pty,
19431 ApprovalLevel::Moderate,
19432 Some(&allowlist),
19433 Some(provider_home.as_path()),
19434 )
19435 .expect("Claude PTY settings overlay on unix");
19436 let settings_path = provider_home.join(
19437 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19438 );
19439 assert_eq!(
19440 overlay,
19441 vec![
19442 std::ffi::OsString::from("--settings"),
19443 std::ffi::OsString::from(settings_path.as_os_str()),
19444 ]
19445 );
19446 let body = std::fs::read_to_string(&settings_path).unwrap();
19447 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19448 assert_eq!(value["sandbox"]["enabled"], true);
19449 assert_eq!(
19450 value["sandbox"]["network"]["allowedDomains"],
19451 serde_json::json!(["github.com", "*.npmjs.org"])
19452 );
19453 assert!(settings_path.starts_with(&provider_home));
19454
19455 let acp = shared
19456 .resolve_network_allowlist_overlay_args(
19457 &agent("claude"),
19458 SessionMode::Acp,
19459 ApprovalLevel::Moderate,
19460 Some(&allowlist),
19461 Some(provider_home.as_path()),
19462 )
19463 .expect_err("Claude ACP must refuse settings overlay");
19464 assert_eq!(
19465 acp.code,
19466 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19467 );
19468
19469 let no_home = shared
19470 .resolve_network_allowlist_overlay_args(
19471 &agent("claude"),
19472 SessionMode::Pty,
19473 ApprovalLevel::Moderate,
19474 Some(&allowlist),
19475 None,
19476 )
19477 .expect_err("Claude overlay requires ProviderHome");
19478 assert_eq!(
19479 no_home.code,
19480 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19481 );
19482
19483 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19484 shared
19485 .network_allowlist_catalog
19486 .write()
19487 .unwrap_or_else(|poisoned| poisoned.into_inner())
19488 .insert_id(bare.clone())
19489 .unwrap();
19490 let empty = shared
19491 .resolve_network_allowlist_overlay_args(
19492 &agent("claude"),
19493 SessionMode::Pty,
19494 ApprovalLevel::Moderate,
19495 Some(&bare),
19496 Some(provider_home.as_path()),
19497 )
19498 .unwrap();
19499 assert!(empty.is_empty());
19500
19501 let _ = std::fs::remove_dir_all(&provider_home);
19502 }
19503
19504 #[test]
19506 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19507 let (shared, _defaults) = station_knobs_fixture();
19508 let allowlist =
19509 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19510 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19511 id: allowlist.clone(),
19512 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19513 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19514 peer: "example.com:443".to_owned(),
19515 }],
19516 provider_native: None,
19517 };
19518 shared
19519 .network_allowlist_catalog
19520 .write()
19521 .unwrap_or_else(|poisoned| poisoned.into_inner())
19522 .insert(entry)
19523 .unwrap();
19524 for provider in ["kimi", "grok"] {
19525 let overlay = shared
19526 .resolve_network_allowlist_overlay_args(
19527 &agent(provider),
19528 SessionMode::Pty,
19529 ApprovalLevel::Moderate,
19530 Some(&allowlist),
19531 None,
19532 )
19533 .unwrap();
19534 assert!(
19535 overlay.is_empty(),
19536 "{provider} must not invent network argv from permits"
19537 );
19538 }
19539 }
19540
19541 #[test]
19543 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19544 let unique = std::time::SystemTime::now()
19545 .duration_since(std::time::UNIX_EPOCH)
19546 .unwrap()
19547 .as_nanos();
19548 let path = std::env::temp_dir().join(format!(
19549 "gate4agent-node-allowlist-json-{}-{unique}.json",
19550 std::process::id(),
19551 ));
19552 std::fs::write(
19553 &path,
19554 r#"{
19555 "schema_version": 2,
19556 "entries": [
19557 {
19558 "id": "egress-default",
19559 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19560 },
19561 { "id": "lab-egress" }
19562 ]
19563 }"#,
19564 )
19565 .unwrap();
19566 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19567 .expect("json v2 catalog must load");
19568 let _ = std::fs::remove_file(&path);
19569 assert_eq!(loaded.len(), 2);
19570 let entry = loaded
19571 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19572 .unwrap();
19573 assert_eq!(entry.permits.len(), 1);
19574 let (shared, defaults) = station_knobs_fixture();
19575 {
19576 let mut catalog = shared
19577 .network_allowlist_catalog
19578 .write()
19579 .unwrap_or_else(|poisoned| poisoned.into_inner());
19580 *catalog = loaded;
19581 }
19582 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19583 let spec = crate::protocol::SpawnSpec {
19584 target: crate::protocol::SpawnTarget {
19585 node_id: shared.node_id.clone(),
19586 workspace_id: WorkspaceId::new("primary").unwrap(),
19587 worktree_id: None,
19588 },
19589 profile_id: defaults.profile_id.clone(),
19590 expected_profile_revision: defaults.revision.clone(),
19591 overrides: crate::protocol::SpawnOverrides {
19592 network_allowlist: Some(allowlist.clone()),
19593 ..crate::protocol::SpawnOverrides::default()
19594 },
19595 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19596 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19597 "station-knobs-catalog-json",
19598 )
19599 .unwrap(),
19600 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19601 };
19602 let resolved = spec.resolve(&defaults).unwrap();
19603 let receipt = shared
19604 .resolve_environment_profile(&resolved)
19605 .expect("permits-only entry ok for claude")
19606 .expect("env profile present");
19607 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19608 let inventory = shared
19609 .snapshot()
19610 .launch_inventory
19611 .expect("launch inventory present");
19612 let ids = inventory.network_allowlists.expect("ids surface");
19613 assert_eq!(
19614 ids,
19615 vec![
19616 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19617 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19618 ]
19619 );
19620 }
19621
19622
19623}
19624
19625
19626#[cfg(test)]
19627mod workspace_environment_collision_tests {
19628 use super::*;
19629 use crate::session_environment::{
19630 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19631 };
19632 use gate4agent_catalog::EnvMutation;
19633 use gate4agent_runtime_native::{
19634 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19635 NativeLaunchProfileId,
19636 };
19637 use std::ffi::OsString;
19638 use std::sync::Arc;
19639
19640 struct EmptyEnvironmentResolver;
19641
19642 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19643 fn resolve_child_environment(
19644 &self,
19645 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19646 Ok(vec![EnvMutation {
19647 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19648 value: None,
19649 }])
19650 }
19651 }
19652
19653 fn agent(value: &str) -> AgentId {
19654 AgentId::new(value).unwrap()
19655 }
19656
19657 fn claude_config_dir_collision_fixture() -> (
19659 NodeShared,
19660 ResolvedEnvironmentProfileReceipt,
19661 ResolvedEnvironmentProfileReceipt,
19662 ) {
19663 let catalog = active_registry().unwrap();
19664 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19665 let control = runtime.native_launch_profile_control();
19666 let workspace = WorkspaceConfig::new(
19667 WorkspaceId::new("primary").unwrap(),
19668 std::env::current_dir().unwrap(),
19669 )
19670 .unwrap();
19671 let shared = NodeShared::new_with_incarnation(
19672 handle,
19673 "fixture-token".to_owned(),
19674 NodeId::new("node-env-collision-test").unwrap(),
19675 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19676 vec![workspace],
19677 vec![agent("claude")],
19678 ProviderRuntimeStatuses::default(),
19679 None,
19680 Vec::new(),
19681 Vec::new(),
19682 SpawnProfileRegistry::default(),
19683 Some(control),
19684 None,
19685 None,
19686 None,
19687 Vec::new(),
19688 Vec::new(),
19689 Vec::new(),
19690 Vec::new(),
19691 None,
19692 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19693 );
19694
19695 let mut receipts = Vec::new();
19696 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19697 let profile_id =
19698 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19699 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19700 "claude-config-dir-{suffix}-r1"
19701 ))
19702 .unwrap();
19703 let materialization = NodeSessionMaterializationProfile::new(
19704 Vec::new(),
19705 vec![NodeSessionPathBinding::new(
19706 "CLAUDE_CONFIG_DIR",
19707 NodeSessionPathClass::ProviderHome,
19708 )
19709 .unwrap()],
19710 Vec::new(),
19711 )
19712 .unwrap();
19713 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19714 profile_id.clone(),
19715 profile_revision.clone(),
19716 agent("claude"),
19717 [NativeLaunchProfile::new(
19718 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19719 .unwrap(),
19720 agent("claude"),
19721 TransportKind::Pty,
19722 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19723 Arc::new(EmptyEnvironmentResolver),
19724 )
19725 .unwrap()],
19726 Some(materialization),
19727 )
19728 .unwrap();
19729 let (binding, native_profiles, materialization) = node_profile.into_parts();
19730 for native_profile in native_profiles {
19731 runtime.upsert_native_launch_profile(native_profile).unwrap();
19732 }
19733 shared
19734 .environment_profiles
19735 .write()
19736 .unwrap_or_else(|poisoned| poisoned.into_inner())
19737 .insert(binding.id.clone(), binding);
19738 shared
19739 .environment_materialization_profiles
19740 .write()
19741 .unwrap_or_else(|poisoned| poisoned.into_inner())
19742 .insert(profile_id.clone(), materialization.unwrap());
19743 receipts.push(ResolvedEnvironmentProfileReceipt {
19744 profile_id,
19745 profile_revision,
19746 network_allowlist: None,
19747 browser_profile_id: None,
19748 });
19749 }
19750 let profile_b = receipts.pop().unwrap();
19751 let profile_a = receipts.pop().unwrap();
19752 (shared, profile_a, profile_b)
19753 }
19754
19755 #[test]
19756 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19757 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19758 let primary = WorkspaceId::new("primary").unwrap();
19759 let worktree = WorkspaceId::new("worktree-b").unwrap();
19760 let address = SessionAddress {
19761 workspace_id: primary.clone(),
19762 session: SessionKey {
19763 instance_id: AgentInstanceId(41),
19764 generation: SessionGeneration(1),
19765 },
19766 };
19767 shared.bind_session_with_policy(
19768 &address,
19769 ProviderRuntimePolicy::raw_pty(),
19770 Some(profile_a.clone()),
19771 );
19772
19773 let refused = shared
19774 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19775 .unwrap_err();
19776 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19777
19778 let refused_none = shared
19780 .ensure_workspace_environment_profile_compatible(&primary, None)
19781 .unwrap_err();
19782 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19783
19784 shared
19786 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19787 .unwrap();
19788
19789 shared
19791 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19792 .unwrap();
19793 }
19794
19795 #[tokio::test]
19796 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19797 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19798 let primary = WorkspaceId::new("primary").unwrap();
19799 let address = SessionAddress {
19800 workspace_id: primary.clone(),
19801 session: SessionKey {
19802 instance_id: AgentInstanceId(41),
19803 generation: SessionGeneration(1),
19804 },
19805 };
19806 shared.bind_session_with_policy(
19807 &address,
19808 ProviderRuntimePolicy::raw_pty(),
19809 Some(profile_a),
19810 );
19811
19812 let failure = shared
19813 .spawn_session_with_deadline(
19814 primary,
19815 agent("claude"),
19816 SessionMode::Pty,
19817 gate4agent_types::TerminalSize {
19818 rows: 24,
19819 columns: 80,
19820 },
19821 None,
19822 Some(profile_b),
19823 None,
19824 None,
19825 None,
19826 None,
19827 SpawnRecordPolicy::ProviderIdentityOnly,
19828 Some(Instant::now() + Duration::from_secs(30)),
19829 &[],
19830 None,
19831 ApprovalLevel::default(),
19832 None,
19833 None,
19834 )
19835 .await
19836 .unwrap_err();
19837 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19838 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19840 assert!(shared.handle.snapshot().sessions.is_empty());
19841 }
19842}
19843
19844#[cfg(test)]
19845mod spawn_characterization_tests {
19846 use super::*;
19847 use crate::session_environment::{
19848 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19849 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19850 NodeSessionPathClass,
19851 };
19852 use gate4agent_catalog::EnvMutation;
19853 use gate4agent_runtime_native::{
19854 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19855 NativeLaunchProfile, NativeLaunchProfileId,
19856 };
19857 use std::ffi::OsString;
19858 use std::sync::Arc;
19859
19860 struct EmptyEnvironmentResolver;
19861
19862 struct FixtureSecretResolver {
19863 deny: Arc<AtomicBool>,
19864 }
19865
19866 impl NodeSecretResolver for FixtureSecretResolver {
19867 fn resolve(
19868 &self,
19869 _reference: &NodeSecretReference,
19870 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19871 if self.deny.load(Ordering::Acquire) {
19872 Err(NodeSecretResolveError::Denied)
19873 } else {
19874 NodeSecretValue::text("fixture-secret")
19875 .map_err(|_| NodeSecretResolveError::Unavailable)
19876 }
19877 }
19878 }
19879
19880 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19881 fn resolve_child_environment(
19882 &self,
19883 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19884 Ok(vec![EnvMutation {
19885 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19886 value: None,
19887 }])
19888 }
19889 }
19890
19891 fn agent(value: &str) -> AgentId {
19892 AgentId::new(value).unwrap()
19893 }
19894
19895 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19896 let unique = std::time::SystemTime::now()
19897 .duration_since(std::time::UNIX_EPOCH)
19898 .unwrap()
19899 .as_nanos();
19900 std::env::temp_dir().join(format!(
19901 "gate4agent-node-{label}-{}-{unique}",
19902 std::process::id(),
19903 ))
19904 }
19905
19906 fn materialization_test_shared(
19907 deny_secret: bool,
19908 ) -> (
19909 NodeShared,
19910 NativeRuntime,
19911 ResolvedEnvironmentProfileReceipt,
19912 PathBuf,
19913 Arc<AtomicBool>,
19914 ) {
19915 let workspace = WorkspaceConfig::new(
19916 WorkspaceId::new("primary").unwrap(),
19917 std::env::current_dir().unwrap(),
19918 )
19919 .unwrap();
19920 materialization_test_shared_with_workspace(deny_secret, workspace)
19921 }
19922
19923 fn materialization_test_shared_with_workspace(
19924 deny_secret: bool,
19925 workspace: WorkspaceConfig,
19926 ) -> (
19927 NodeShared,
19928 NativeRuntime,
19929 ResolvedEnvironmentProfileReceipt,
19930 PathBuf,
19931 Arc<AtomicBool>,
19932 ) {
19933 let root = temporary_workspace_root(if deny_secret {
19934 "materialization-denied"
19935 } else {
19936 "materialization-ready"
19937 });
19938 std::fs::create_dir_all(&root).unwrap();
19939 let materialization_root = root.join("session-environments");
19940 let deny = Arc::new(AtomicBool::new(deny_secret));
19941 let resolver = Arc::new(FixtureSecretResolver {
19942 deny: Arc::clone(&deny),
19943 });
19944 let materializer = SessionEnvironmentMaterializer::new(
19945 materialization_root,
19946 resolver,
19947 )
19948 .unwrap();
19949 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19950 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19951 "materialized-claude-r1",
19952 )
19953 .unwrap();
19954 let materialization = NodeSessionMaterializationProfile::new(
19955 vec![NodeSessionEnvironmentMutation::SetSecret {
19956 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19957 reference: NodeSecretReference::new("fixture-token").unwrap(),
19958 }],
19959 vec![NodeSessionPathBinding::new(
19960 "GATE4AGENT_PROVIDER_HOME",
19961 NodeSessionPathClass::ProviderHome,
19962 )
19963 .unwrap()],
19964 vec![NodeSessionFile::secret(
19965 NodeSessionPathClass::Config,
19966 "auth/token",
19967 NodeSecretReference::new("fixture-token").unwrap(),
19968 )
19969 .unwrap()],
19970 )
19971 .unwrap();
19972 let catalog = active_registry().unwrap();
19973 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19974 let control = runtime.native_launch_profile_control();
19975 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19976 profile_id.clone(),
19977 profile_revision.clone(),
19978 agent("claude"),
19979 [NativeLaunchProfile::new(
19980 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19981 agent("claude"),
19982 TransportKind::Pty,
19983 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19984 Arc::new(EmptyEnvironmentResolver),
19985 )
19986 .unwrap()],
19987 Some(materialization),
19988 )
19989 .unwrap();
19990 let (binding, native_profiles, materialization) = node_profile.into_parts();
19991 for native_profile in native_profiles {
19992 runtime.upsert_native_launch_profile(native_profile).unwrap();
19993 }
19994 let shared = NodeShared::new_with_incarnation(
19995 handle,
19996 "fixture-token".to_owned(),
19997 NodeId::new("node-materialization-test").unwrap(),
19998 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19999 vec![workspace],
20000 vec![agent("claude")],
20001 ProviderRuntimeStatuses::default(),
20002 None,
20003 Vec::new(),
20004 Vec::new(),
20005 SpawnProfileRegistry::default(),
20006 Some(control),
20007 None,
20008 Some(materializer),
20009 None,
20010 Vec::new(),
20011 Vec::new(),
20012 Vec::new(),
20013 Vec::new(),
20014 None,
20015 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20016 );
20017 shared
20018 .environment_profiles
20019 .write()
20020 .unwrap_or_else(|poisoned| poisoned.into_inner())
20021 .insert(binding.id.clone(), binding);
20022 shared
20023 .environment_materialization_profiles
20024 .write()
20025 .unwrap_or_else(|poisoned| poisoned.into_inner())
20026 .insert(profile_id.clone(), materialization.unwrap());
20027 (
20028 shared,
20029 runtime,
20030 ResolvedEnvironmentProfileReceipt {
20031 profile_id,
20032 profile_revision,
20033 network_allowlist: None,
20034 browser_profile_id: None,
20035 },
20036 root,
20037 deny,
20038 )
20039 }
20040
20041
20042 async fn characterization_spawn(
20045 shared: &NodeShared,
20046 mode: SessionMode,
20047 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20048 required: &[ProviderRuntimeCapability],
20049 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20050 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20051 shared
20052 .spawn_session_with_deadline(
20053 WorkspaceId::new("primary").unwrap(),
20054 agent("claude"),
20055 mode,
20056 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20057 None,
20058 environment_profile,
20059 None,
20060 None,
20061 None,
20062 Some(ProviderRuntimePolicy::raw_pty()),
20063 SpawnRecordPolicy::ProviderIdentityOnly,
20064 Some(Instant::now() + Duration::from_secs(30)),
20065 required,
20066 harness_mcp,
20067 ApprovalLevel::default(),
20068 None,
20069 None,
20070 )
20071 .await
20072 }
20073
20074 fn assert_no_session_state(shared: &NodeShared) {
20075 assert!(shared.handle.snapshot().sessions.is_empty());
20076 assert!(shared
20077 .session_bindings
20078 .lock()
20079 .unwrap_or_else(|poisoned| poisoned.into_inner())
20080 .is_empty());
20081 assert!(shared
20082 .materializations
20083 .lock()
20084 .unwrap_or_else(|poisoned| poisoned.into_inner())
20085 .is_empty());
20086 }
20087
20088 #[tokio::test]
20089 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20090 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20091 let error = characterization_spawn(
20092 &shared,
20093 SessionMode::Pty,
20094 None,
20095 &[ProviderRuntimeCapability::SemanticReadiness],
20096 None,
20097 )
20098 .await
20099 .unwrap_err();
20100 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20101 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20102 assert_no_session_state(&shared);
20103 drop(shared);
20104 drop(runtime);
20105 std::fs::remove_dir_all(root).unwrap();
20106 }
20107
20108 #[tokio::test]
20109 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20110 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20111 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20112 .await
20113 .unwrap_err();
20114 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20115 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20116 assert_no_session_state(&shared);
20117 drop(shared);
20118 drop(runtime);
20119 std::fs::remove_dir_all(root).unwrap();
20120 }
20121
20122 #[test]
20123 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20124 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20125 let address = SessionAddress {
20126 workspace_id: WorkspaceId::new("primary").unwrap(),
20127 session: SessionKey {
20128 instance_id: AgentInstanceId(77),
20129 generation: SessionGeneration(1),
20130 },
20131 };
20132 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20133 let removed = shared.remove_binding(&address).expect("binding removed");
20134 assert!(removed.environment_profile.is_some());
20135 assert!(shared.remove_binding(&address).is_none());
20136 assert_no_session_state(&shared);
20137 drop(shared);
20138 drop(runtime);
20139 std::fs::remove_dir_all(root).unwrap();
20140 }
20141
20142 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20143 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20144 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20145 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20146 .expect("review current test executable as fixture helper");
20147 let registry = HarnessMcpProxyRegistry::new(helper);
20148 let spec = crate::protocol::SpawnSpec {
20149 target: crate::protocol::SpawnTarget {
20150 node_id: NodeId::new("node-characterization").unwrap(),
20151 workspace_id: WorkspaceId::new("primary").unwrap(),
20152 worktree_id: None,
20153 },
20154 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20155 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20156 overrides: crate::protocol::SpawnOverrides::default(),
20157 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20158 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20159 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20160 };
20161 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20162 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20163 let launch = HarnessMcpLaunchV1 {
20164 server_name: "fixture-harness".to_owned(),
20165 args: vec!["--session-proxy".to_owned()],
20166 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20167 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20168 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20169 trace: None,
20170 scrub_env: Vec::new(),
20171 };
20172 let expires = unix_time_ms().saturating_add(30_000);
20173 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20174 registry
20175 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20176 .unwrap()
20177 }
20178
20179 #[tokio::test]
20180 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20181 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20182 let prepared = characterization_prepared_harness("claude").await;
20183 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20184 .await
20185 .unwrap_err();
20186 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20188 let mismatched = characterization_prepared_harness("codex").await;
20189 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20190 .await
20191 .unwrap_err();
20192 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20193 assert_no_session_state(&shared);
20194 drop(shared);
20195 drop(runtime);
20196 std::fs::remove_dir_all(root).unwrap();
20197 }
20198
20199 #[tokio::test]
20200 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20201 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20202 shared.native_launch_profile_control = None;
20203 let prepared = characterization_prepared_harness("claude").await;
20204 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20205 .await
20206 .unwrap_err();
20207 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20208 assert_no_session_state(&shared);
20209 drop(shared);
20210 drop(runtime);
20211 std::fs::remove_dir_all(root).unwrap();
20212 }
20213}
20214
20215#[cfg(all(test, windows))]
20216mod tests {
20217 use super::*;
20218 use gate4agent_types::ProviderActivity;
20219 use crate::session_environment::{
20220 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20221 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20222 NodeSessionPathClass,
20223 };
20224 use gate4agent_catalog::EnvMutation;
20225 use gate4agent_runtime_native::{
20226 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20227 NativeLaunchProfile, NativeLaunchProfileId,
20228 };
20229 use std::ffi::OsString;
20230 use std::sync::Arc;
20231
20232 struct EmptyEnvironmentResolver;
20233
20234 struct FixtureSecretResolver {
20235 deny: Arc<AtomicBool>,
20236 }
20237
20238 impl NodeSecretResolver for FixtureSecretResolver {
20239 fn resolve(
20240 &self,
20241 _reference: &NodeSecretReference,
20242 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20243 if self.deny.load(Ordering::Acquire) {
20244 Err(NodeSecretResolveError::Denied)
20245 } else {
20246 NodeSecretValue::text("fixture-secret")
20247 .map_err(|_| NodeSecretResolveError::Unavailable)
20248 }
20249 }
20250 }
20251
20252 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20253 fn resolve_child_environment(
20254 &self,
20255 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20256 Ok(vec![EnvMutation {
20257 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20258 value: None,
20259 }])
20260 }
20261 }
20262
20263 fn agent(value: &str) -> AgentId {
20264 AgentId::new(value).unwrap()
20265 }
20266
20267 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20268 let catalog = active_registry().unwrap();
20269 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20270 let workspace = WorkspaceConfig::new(
20271 WorkspaceId::new("primary").unwrap(),
20272 std::env::current_dir().unwrap(),
20273 )
20274 .unwrap()
20275 .with_worktree_service_mode(mode);
20276 NodeShared::new(
20277 handle,
20278 "fixture-token".to_owned(),
20279 NodeId::new("node-terminal-test").unwrap(),
20280 vec![workspace],
20281 vec![agent("claude")],
20282 )
20283 }
20284
20285 fn terminal_test_shared() -> NodeShared {
20286 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20287 }
20288
20289 #[cfg(feature = "fixture")]
20290 #[test]
20291 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20292 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20293 assert_eq!(probe.latest_code(), None);
20294
20295 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20296 assert_eq!(
20297 probe.latest_code(),
20298 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20299 );
20300
20301 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20302 assert_eq!(
20303 probe.latest_code(),
20304 Some(NodeFailureCode::SpawnDeadlineExceeded),
20305 );
20306
20307 probe.record(None);
20308 assert_eq!(probe.latest_code(), None);
20309 }
20310
20311 #[test]
20312 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20313 let source = temporary_workspace_root("launch-inventory-source");
20314 let allocation = temporary_workspace_root("launch-inventory-allocation");
20315 std::fs::create_dir_all(&source).unwrap();
20316 std::fs::create_dir_all(&allocation).unwrap();
20317 let workspace_id = WorkspaceId::new("primary").unwrap();
20318 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20319 .unwrap()
20320 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20321 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20322 workspace = workspace
20323 .with_managed_worktree_profile(
20324 ManagedWorktreeProfile::new(
20325 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20326 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20327 &allocation,
20328 "gate4agent",
20329 "HEAD",
20330 ManagedWorktreeRetention::RemoveWhenReleased,
20331 )
20332 .unwrap(),
20333 )
20334 .unwrap();
20335 }
20336 let overflow = workspace.clone().with_managed_worktree_profile(
20337 ManagedWorktreeProfile::new(
20338 WorktreeProfileId::new("overflow").unwrap(),
20339 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20340 &allocation,
20341 "gate4agent",
20342 "HEAD",
20343 ManagedWorktreeRetention::Retain,
20344 )
20345 .unwrap(),
20346 );
20347 assert!(matches!(
20348 overflow,
20349 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20350 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20351 ..
20352 }),
20353 ));
20354
20355 let catalog = active_registry().unwrap();
20356 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20357 let shared = NodeShared::new(
20358 handle,
20359 "fixture-token".to_owned(),
20360 NodeId::new("node-launch-inventory").unwrap(),
20361 vec![workspace],
20362 vec![agent("claude")],
20363 );
20364 let snapshot = shared.snapshot();
20365 let launch = snapshot.launch_inventory.as_ref().unwrap();
20366 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20367 assert!(launch.bundles.as_ref().unwrap().is_empty());
20368 let profiles = snapshot.workspaces[0]
20369 .managed_worktree_profiles
20370 .as_ref()
20371 .unwrap();
20372 assert_eq!(
20373 snapshot.workspaces[0].worktree_service_mode,
20374 Some(WorktreeServiceMode::Managed),
20375 );
20376 assert_eq!(
20377 profiles.profiles.len(),
20378 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20379 );
20380 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20381 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20382 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20383 }
20384 let legacy = snapshot_for_wire(
20385 &shared,
20386 true,
20387 true,
20388 false,
20389 false,
20390 true,
20391 false,
20392 true,
20393 false,
20394 true,
20395 );
20396 assert!(legacy.launch_inventory.is_none());
20397 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20398
20399 drop(shared);
20400 std::fs::remove_dir_all(source).unwrap();
20401 std::fs::remove_dir_all(allocation).unwrap();
20402 }
20403
20404 #[test]
20405 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20406 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20407 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20408 }));
20409 let source = gate4agent_types::ProviderSource {
20410 family: AdapterFamily::PtySemantic,
20411 binding: AdapterBinding::new(
20412 gate4agent_types::AdapterId::new("claude").unwrap(),
20413 "fixture/v1",
20414 gate4agent_types::AdapterVerification::SyntheticFixture,
20415 )
20416 .unwrap(),
20417 };
20418 let provider = ProviderSnapshot {
20419 sequence: 17,
20420 session: Some(ProviderSessionIdentity {
20421 key: ProviderSessionKey::SessionId,
20422 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20423 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20424 }),
20425 model: Some("PRIVATE_MODEL".to_owned()),
20426 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20427 completed_turns: 3,
20428 usage: gate4agent_types::TokenUsage {
20429 input_tokens: 10,
20430 output_tokens: 20,
20431 cache_read_tokens: 30,
20432 cache_write_tokens: 40,
20433 reasoning_tokens: 50,
20434 context_window: Some(200_000),
20435 },
20436 lead_activity: ProviderActivity::WaitingForInput,
20437 activity: ProviderActivity::WaitingForInput,
20438 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20439 active_tools: (0..9)
20440 .map(|index| gate4agent_types::ActiveProviderTool {
20441 id: format!("private-tool-id-{index}"),
20442 name: if index == 0 {
20443 format!(" Read\u{0000}{}", "x".repeat(80))
20444 } else {
20445 format!("Tool-{index}")
20446 },
20447 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20448 })
20449 .collect(),
20450 interactions: vec![gate4agent_types::ProviderInteraction {
20451 id: gate4agent_types::ProviderInteractionId(17),
20452 source: source.clone(),
20453 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20454 interaction_kind: ProviderInteractionKind::Approval,
20455 tool_name: " Approve\u{0007}Tool ".to_owned(),
20456 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20457 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20458 resume_lead_activity: Some(ProviderActivity::Working),
20459 status: ProviderInteractionStatus::Pending,
20460 }],
20461 subagents: vec![gate4agent_types::ProviderSubagent {
20462 source: source.clone(),
20463 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20464 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20465 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20466 }],
20467 sources: vec![gate4agent_types::ProviderSourceCursor {
20468 source,
20469 sequence: 17,
20470 gap_count: 2,
20471 stale: true,
20472 }],
20473 last_event: Some(ProviderEvent::Error {
20474 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20475 }),
20476 gap_count: 2,
20477 stale: true,
20478 };
20479 let address = terminal_address(3);
20480 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20481 assert_eq!(entry.address, address);
20482 assert_eq!(entry.progress.provider_sequence, 17);
20483 assert_eq!(entry.progress.active_tool_count, 9);
20484 assert_eq!(
20485 entry.progress.active_tool_labels.len(),
20486 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20487 );
20488 assert_eq!(entry.progress.subagent_count, 1);
20489 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20490 assert!(entry.progress.truncated);
20491 let encoded = serde_json::to_string(&entry).unwrap();
20492 for forbidden in [
20493 "PRIVATE_PROVIDER_SESSION",
20494 "PRIVATE_PROVIDER_PATH",
20495 "PRIVATE_MODEL",
20496 "PRIVATE_TOOL_INVENTORY",
20497 "PRIVATE_CURRENT_PROMPT",
20498 "PRIVATE_TOOL_INPUT_JSON",
20499 "PRIVATE_PROVIDER_REQUEST",
20500 "PRIVATE_INTERACTION_PROMPT",
20501 "PRIVATE_INTERACTION_AGENT",
20502 "PRIVATE_SUBAGENT_ID",
20503 "PRIVATE_SUBAGENT_TYPE",
20504 "PRIVATE_SUBAGENT_DESCRIPTION",
20505 "PRIVATE_PROVIDER_ERROR",
20506 "input_json",
20507 "current_prompt",
20508 "session_id",
20509 ] {
20510 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20511 }
20512
20513 let snapshot = NodeSnapshot {
20514 node_id: NodeId::new("node-progress-test").unwrap(),
20515 enabled_providers: vec![agent("claude")],
20516 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20517 workspaces: Vec::new(),
20518 session_records: Vec::new(),
20519 managed_worktrees: Vec::new(),
20520 launch_inventory: None,
20521 agent_progress: vec![entry],
20522 };
20523 let mut legacy_reply = ResponseEnvelope {
20524 request_id: 1,
20525 result: Ok(NodeResponse::Snapshot {
20526 event_sequence: 0,
20527 controller: None,
20528 snapshot: snapshot.clone(),
20529 }),
20530 };
20531 project_response_without_agent_progress(&mut legacy_reply);
20532 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20533 panic!("agent progress projection changed response kind");
20534 };
20535 assert!(legacy.agent_progress.is_empty());
20536 assert_eq!(snapshot.agent_progress.len(), 1);
20537 }
20538
20539 fn terminal_address(generation: u64) -> SessionAddress {
20540 SessionAddress {
20541 workspace_id: WorkspaceId::new("primary").unwrap(),
20542 session: SessionKey {
20543 instance_id: AgentInstanceId(41),
20544 generation: SessionGeneration(generation),
20545 },
20546 }
20547 }
20548
20549 fn spawn_spec_fixture() -> SpawnSpec {
20550 SpawnSpec {
20551 target: crate::protocol::SpawnTarget {
20552 node_id: NodeId::new("node-terminal-test").unwrap(),
20553 workspace_id: WorkspaceId::new("primary").unwrap(),
20554 worktree_id: None,
20555 },
20556 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20557 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20558 .unwrap(),
20559 overrides: crate::protocol::SpawnOverrides::default(),
20560 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20561 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20562 required_capabilities: SpawnRequiredCapabilities::default(),
20563 }
20564 }
20565
20566 fn environment_profile_test_shared(
20567 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20568 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20569 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20570 "local-claude-r1",
20571 )
20572 .unwrap();
20573 let spawn_profiles = SpawnProfileRegistry::new([
20574 crate::protocol::SpawnProfileDefaults {
20575 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20576 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20577 provider: agent("claude"),
20578 mode: SessionMode::Pty,
20579 terminal_size: gate4agent_types::TerminalSize {
20580 rows: 24,
20581 columns: 80,
20582 },
20583 prompt: None,
20584 bundle_id: None,
20585 context_id: None,
20586 environment_profile_id: Some(profile_id.clone()),
20587 },
20588 ])
20589 .unwrap();
20590 let catalog = active_registry().unwrap();
20591 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20592 let control = runtime.native_launch_profile_control();
20593 let node_profile = NodeEnvironmentProfile::new(
20594 profile_id.clone(),
20595 profile_revision.clone(),
20596 agent("claude"),
20597 [NativeLaunchProfile::new(
20598 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20599 agent("claude"),
20600 TransportKind::Pty,
20601 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20602 Arc::new(EmptyEnvironmentResolver),
20603 )
20604 .unwrap()],
20605 )
20606 .unwrap();
20607 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20608 for native_profile in native_profiles {
20609 runtime.upsert_native_launch_profile(native_profile).unwrap();
20610 }
20611 let workspace = WorkspaceConfig::new(
20612 WorkspaceId::new("primary").unwrap(),
20613 std::env::current_dir().unwrap(),
20614 )
20615 .unwrap();
20616 let shared = NodeShared::new_with_incarnation(
20617 handle,
20618 "fixture-token".to_owned(),
20619 NodeId::new("node-terminal-test").unwrap(),
20620 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20621 vec![workspace],
20622 vec![agent("claude")],
20623 ProviderRuntimeStatuses::default(),
20624 None,
20625 Vec::new(),
20626 Vec::new(),
20627 spawn_profiles,
20628 Some(control),
20629 None,
20630 None,
20631 None,
20632 Vec::new(),
20633 Vec::new(),
20634 Vec::new(),
20635 Vec::new(),
20636 None,
20637 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20638 );
20639 shared
20640 .environment_profiles
20641 .write()
20642 .unwrap_or_else(|poisoned| poisoned.into_inner())
20643 .insert(binding.id.clone(), binding);
20644 (
20645 shared,
20646 runtime,
20647 ResolvedEnvironmentProfileReceipt {
20648 profile_id,
20649 profile_revision,
20650 network_allowlist: None,
20651 browser_profile_id: None,
20652 },
20653 )
20654 }
20655
20656 fn materialization_test_shared(
20657 deny_secret: bool,
20658 ) -> (
20659 NodeShared,
20660 NativeRuntime,
20661 ResolvedEnvironmentProfileReceipt,
20662 PathBuf,
20663 Arc<AtomicBool>,
20664 ) {
20665 let workspace = WorkspaceConfig::new(
20666 WorkspaceId::new("primary").unwrap(),
20667 std::env::current_dir().unwrap(),
20668 )
20669 .unwrap();
20670 materialization_test_shared_with_workspace(deny_secret, workspace)
20671 }
20672
20673 fn materialization_test_shared_with_workspace(
20674 deny_secret: bool,
20675 workspace: WorkspaceConfig,
20676 ) -> (
20677 NodeShared,
20678 NativeRuntime,
20679 ResolvedEnvironmentProfileReceipt,
20680 PathBuf,
20681 Arc<AtomicBool>,
20682 ) {
20683 let root = temporary_workspace_root(if deny_secret {
20684 "materialization-denied"
20685 } else {
20686 "materialization-ready"
20687 });
20688 std::fs::create_dir_all(&root).unwrap();
20689 let materialization_root = root.join("session-environments");
20690 let deny = Arc::new(AtomicBool::new(deny_secret));
20691 let resolver = Arc::new(FixtureSecretResolver {
20692 deny: Arc::clone(&deny),
20693 });
20694 let materializer = SessionEnvironmentMaterializer::new(
20695 materialization_root,
20696 resolver,
20697 )
20698 .unwrap();
20699 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20700 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20701 "materialized-claude-r1",
20702 )
20703 .unwrap();
20704 let materialization = NodeSessionMaterializationProfile::new(
20705 vec![NodeSessionEnvironmentMutation::SetSecret {
20706 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20707 reference: NodeSecretReference::new("fixture-token").unwrap(),
20708 }],
20709 vec![NodeSessionPathBinding::new(
20710 "GATE4AGENT_PROVIDER_HOME",
20711 NodeSessionPathClass::ProviderHome,
20712 )
20713 .unwrap()],
20714 vec![NodeSessionFile::secret(
20715 NodeSessionPathClass::Config,
20716 "auth/token",
20717 NodeSecretReference::new("fixture-token").unwrap(),
20718 )
20719 .unwrap()],
20720 )
20721 .unwrap();
20722 let catalog = active_registry().unwrap();
20723 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20724 let control = runtime.native_launch_profile_control();
20725 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20726 profile_id.clone(),
20727 profile_revision.clone(),
20728 agent("claude"),
20729 [NativeLaunchProfile::new(
20730 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20731 agent("claude"),
20732 TransportKind::Pty,
20733 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20734 Arc::new(EmptyEnvironmentResolver),
20735 )
20736 .unwrap()],
20737 Some(materialization),
20738 )
20739 .unwrap();
20740 let (binding, native_profiles, materialization) = node_profile.into_parts();
20741 for native_profile in native_profiles {
20742 runtime.upsert_native_launch_profile(native_profile).unwrap();
20743 }
20744 let shared = NodeShared::new_with_incarnation(
20745 handle,
20746 "fixture-token".to_owned(),
20747 NodeId::new("node-materialization-test").unwrap(),
20748 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20749 vec![workspace],
20750 vec![agent("claude")],
20751 ProviderRuntimeStatuses::default(),
20752 None,
20753 Vec::new(),
20754 Vec::new(),
20755 SpawnProfileRegistry::default(),
20756 Some(control),
20757 None,
20758 Some(materializer),
20759 None,
20760 Vec::new(),
20761 Vec::new(),
20762 Vec::new(),
20763 Vec::new(),
20764 None,
20765 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20766 );
20767 shared
20768 .environment_profiles
20769 .write()
20770 .unwrap_or_else(|poisoned| poisoned.into_inner())
20771 .insert(binding.id.clone(), binding);
20772 shared
20773 .environment_materialization_profiles
20774 .write()
20775 .unwrap_or_else(|poisoned| poisoned.into_inner())
20776 .insert(profile_id.clone(), materialization.unwrap());
20777 (
20778 shared,
20779 runtime,
20780 ResolvedEnvironmentProfileReceipt {
20781 profile_id,
20782 profile_revision,
20783 network_allowlist: None,
20784 browser_profile_id: None,
20785 },
20786 root,
20787 deny,
20788 )
20789 }
20790
20791 #[cfg(feature = "fixture")]
20792 #[derive(Clone, Copy)]
20793 enum CodexHomeProfile {
20794 Missing,
20795 WrongClass,
20796 Exact,
20797 }
20798
20799 #[cfg(feature = "fixture")]
20800 fn codex_bundle_test_shared(
20801 home_profile: CodexHomeProfile,
20802 ) -> (
20803 NodeShared,
20804 NativeRuntime,
20805 ResolvedEnvironmentProfileReceipt,
20806 NodeBundle,
20807 PathBuf,
20808 ) {
20809 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20810 const CLAUDE_MANIFEST: &[u8] =
20811 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20812 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20813 const DIGEST: &str =
20814 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20815
20816 let (shared, mut runtime, _claude_receipt, root, _deny) =
20817 materialization_test_shared(false);
20818 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20819 CodexHomeProfile::Missing => "codex-home-missing",
20820 CodexHomeProfile::WrongClass => "codex-home-wrong",
20821 CodexHomeProfile::Exact => "codex-home-exact",
20822 })
20823 .unwrap();
20824 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20825 "codex-home-r1",
20826 )
20827 .unwrap();
20828 let materialization = match home_profile {
20829 CodexHomeProfile::Missing => None,
20830 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20831 Some(NodeSessionMaterializationProfile::new(
20832 Vec::new(),
20833 vec![NodeSessionPathBinding::new(
20834 "CODEX_HOME",
20835 if matches!(home_profile, CodexHomeProfile::Exact) {
20836 NodeSessionPathClass::ProviderHome
20837 } else {
20838 NodeSessionPathClass::Config
20839 },
20840 )
20841 .unwrap()],
20842 Vec::new(),
20843 )
20844 .unwrap())
20845 }
20846 };
20847 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20848 profile_id.clone(),
20849 profile_revision.clone(),
20850 agent("codex"),
20851 [NativeLaunchProfile::new(
20852 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20853 agent("codex"),
20854 TransportKind::Pty,
20855 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20856 Arc::new(EmptyEnvironmentResolver),
20857 )
20858 .unwrap()],
20859 materialization,
20860 )
20861 .unwrap();
20862 let (binding, native_profiles, materialization) = node_profile.into_parts();
20863 for native_profile in native_profiles {
20864 runtime.upsert_native_launch_profile(native_profile).unwrap();
20865 }
20866 shared
20867 .environment_profiles
20868 .write()
20869 .unwrap_or_else(|poisoned| poisoned.into_inner())
20870 .insert(binding.id.clone(), binding);
20871 if let Some(materialization) = materialization {
20872 shared
20873 .environment_materialization_profiles
20874 .write()
20875 .unwrap_or_else(|poisoned| poisoned.into_inner())
20876 .insert(profile_id.clone(), materialization);
20877 }
20878
20879 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20880 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20881 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20882 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20883 std::fs::write(
20884 bundle_source.join(".claude-plugin/plugin.json"),
20885 CLAUDE_MANIFEST,
20886 )
20887 .unwrap();
20888 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20889 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20890 let bundle = NodeBundle::new(
20891 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20892 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20893 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20894 &bundle_source,
20895 )
20896 .unwrap();
20897 *shared
20898 .bundle_catalog
20899 .write()
20900 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20901 BundleCatalog::new([bundle.clone()]).unwrap();
20902 (
20903 shared,
20904 runtime,
20905 ResolvedEnvironmentProfileReceipt {
20906 profile_id,
20907 profile_revision,
20908 network_allowlist: None,
20909 browser_profile_id: None,
20910 },
20911 bundle,
20912 root,
20913 )
20914 }
20915
20916 #[cfg(feature = "fixture")]
20917 #[test]
20918 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20919 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20920 let (shared, runtime, receipt, bundle, root) =
20921 codex_bundle_test_shared(home_profile);
20922 let error = shared
20923 .bundle_layout(
20924 &agent("codex"),
20925 SessionMode::Pty,
20926 Some(&receipt),
20927 &bundle,
20928 )
20929 .unwrap_err();
20930 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20931 assert!(shared
20932 .materializations
20933 .lock()
20934 .unwrap_or_else(|poisoned| poisoned.into_inner())
20935 .is_empty());
20936 let materialization_root = root.join("session-environments");
20937 assert!(!std::fs::read_dir(&materialization_root)
20938 .unwrap()
20939 .filter_map(Result::ok)
20940 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20941 drop(shared);
20942 drop(runtime);
20943 let _ = std::fs::remove_dir_all(root);
20944 }
20945 }
20946
20947 #[cfg(feature = "fixture")]
20948 #[test]
20949 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20950 let (shared, runtime, receipt, bundle, root) =
20951 codex_bundle_test_shared(CodexHomeProfile::Exact);
20952 let address = SessionAddress {
20953 workspace_id: WorkspaceId::new("primary").unwrap(),
20954 session: SessionKey {
20955 instance_id: AgentInstanceId(310),
20956 generation: SessionGeneration(1),
20957 },
20958 };
20959 let (overlay, guard) = shared
20960 .prepare_session_materialization(
20961 &address,
20962 &agent("codex"),
20963 SessionMode::Pty,
20964 Some(&receipt),
20965 Some(&bundle.receipt()),
20966 None,
20967 None,
20968 )
20969 .unwrap()
20970 .unwrap();
20971 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20972 let ownership = shared
20973 .materializations
20974 .lock()
20975 .unwrap_or_else(|poisoned| poisoned.into_inner())
20976 .get(guard.id().unwrap())
20977 .unwrap()
20978 .clone();
20979 let (profile, _, _) = shared
20980 .materialization_profile(
20981 &agent("codex"),
20982 SessionMode::Pty,
20983 Some(&receipt),
20984 Some(&bundle.receipt()),
20985 None,
20986 )
20987 .unwrap()
20988 .unwrap();
20989 let environment = shared
20990 .session_environment_materializer
20991 .as_ref()
20992 .unwrap()
20993 .resolve_environment(&ownership, &profile)
20994 .unwrap();
20995 assert!(environment.iter().any(|mutation| {
20996 mutation.key == OsString::from("CODEX_HOME")
20997 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20998 }));
20999 assert_eq!(
21000 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21001 .unwrap(),
21002 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21003 );
21004 assert!(std::fs::read_dir(ownership.bundle_root())
21005 .unwrap()
21006 .next()
21007 .is_none());
21008 drop(guard);
21009 drop(shared);
21010 drop(runtime);
21011 let _ = std::fs::remove_dir_all(root);
21012 }
21013
21014 #[cfg(feature = "fixture")]
21015 #[test]
21016 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21017 let (shared, runtime, receipt, bundle, root) =
21018 codex_bundle_test_shared(CodexHomeProfile::Exact);
21019 let bundle_receipt = bundle.receipt();
21020 let address = SessionAddress {
21021 workspace_id: WorkspaceId::new("primary").unwrap(),
21022 session: SessionKey {
21023 instance_id: AgentInstanceId(311),
21024 generation: SessionGeneration(1),
21025 },
21026 };
21027 let (_overlay, guard) = shared
21028 .prepare_session_materialization(
21029 &address,
21030 &agent("codex"),
21031 SessionMode::Pty,
21032 Some(&receipt),
21033 Some(&bundle_receipt),
21034 None,
21035 None,
21036 )
21037 .unwrap()
21038 .unwrap();
21039 let materialization_id = guard.id().unwrap().clone();
21040 let skill_path = shared
21041 .materializations
21042 .lock()
21043 .unwrap_or_else(|poisoned| poisoned.into_inner())
21044 .get(&materialization_id)
21045 .unwrap()
21046 .provider_home()
21047 .join("skills/review-code/SKILL.md");
21048 let record_id = shared
21049 .bind_spawn_session_with_materialization(
21050 &address,
21051 agent("codex"),
21052 SessionMode::Pty,
21053 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21054 SpawnRecordPolicy::ProviderIdentityOnly,
21055 Some(receipt.clone()),
21056 Some(bundle_receipt.clone()),
21057 None,
21058 Some(materialization_id.clone()),
21059 )
21060 .unwrap()
21061 .unwrap();
21062 guard.retain();
21063 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21064
21065 let error = match shared.resolve_record_materialization(
21066 &record_id,
21067 &agent("codex"),
21068 SessionMode::Pty,
21069 Some(&receipt),
21070 Some(&bundle_receipt),
21071 None,
21072 ) {
21073 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21074 Err(error) => error,
21075 };
21076 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21077 assert_eq!(
21078 shared
21079 .materializations
21080 .lock()
21081 .unwrap_or_else(|poisoned| poisoned.into_inner())
21082 .get(&materialization_id)
21083 .unwrap()
21084 .state(),
21085 MaterializationState::RecoveryRequired,
21086 );
21087 drop(shared);
21088 drop(runtime);
21089 let _ = std::fs::remove_dir_all(root);
21090 }
21091
21092 fn bind_record_owned_materialization_fixture(
21093 shared: &NodeShared,
21094 receipt: &ResolvedEnvironmentProfileReceipt,
21095 instance_id: u64,
21096 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21097 let address = SessionAddress {
21098 workspace_id: WorkspaceId::new("primary").unwrap(),
21099 session: SessionKey {
21100 instance_id: AgentInstanceId(instance_id),
21101 generation: SessionGeneration(1),
21102 },
21103 };
21104 let (overlay, materialization_guard) = shared
21105 .prepare_session_materialization(
21106 &address,
21107 &agent("claude"),
21108 SessionMode::Pty,
21109 Some(receipt),
21110 None,
21111 None,
21112 None,
21113 )
21114 .unwrap()
21115 .unwrap();
21116 let materialization_id = materialization_guard.id().unwrap().clone();
21117 let materialization_root = shared
21118 .materializations
21119 .lock()
21120 .unwrap_or_else(|poisoned| poisoned.into_inner())
21121 .get(&materialization_id)
21122 .unwrap()
21123 .root()
21124 .to_path_buf();
21125 let selection = shared
21126 .select_environment_profile(
21127 address.session.instance_id,
21128 &agent("claude"),
21129 SessionMode::Pty,
21130 Some(receipt),
21131 )
21132 .unwrap()
21133 .unwrap();
21134 assert!(shared
21135 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21136 .unwrap()
21137 .is_none());
21138 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21139 let record_id = shared
21140 .bind_spawn_session_with_materialization(
21141 &address,
21142 agent("claude"),
21143 SessionMode::Pty,
21144 policy,
21145 SpawnRecordPolicy::ProviderIdentityOnly,
21146 Some(receipt.clone()),
21147 None,
21148 None,
21149 Some(materialization_id.clone()),
21150 )
21151 .unwrap()
21152 .unwrap();
21153 selection.retain();
21154 materialization_guard.retain();
21155 (address, record_id, materialization_id, materialization_root)
21156 }
21157
21158 #[test]
21159 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21160 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21161 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21162 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21163 }));
21164 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21165 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21166 let mut spec = spawn_spec_fixture();
21167 spec.expected_profile_revision =
21168 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21169 assert!(matches!(
21170 &spec.overrides.environment_profile_id,
21171 crate::protocol::SpawnOverride::Inherit,
21172 ));
21173 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21174 .requires_child_environment_profile_capability());
21175 assert!(request_requires_child_environment_profile(
21176 &shared,
21177 &NodeRequest::SpawnSpec { spec: spec.clone() },
21178 ));
21179
21180 let address = terminal_address(1);
21181 shared.bind_session_with_policy(
21182 &address,
21183 ProviderRuntimePolicy::raw_pty(),
21184 Some(environment_profile.clone()),
21185 );
21186 assert!(request_requires_child_environment_profile(
21187 &shared,
21188 &NodeRequest::Input {
21189 session: address.clone(),
21190 text: "x".to_owned(),
21191 },
21192 ));
21193
21194 let mut record = record("claude", "profile-record");
21195 record.active_session = Some(address.clone());
21196 record.environment_profile = Some(environment_profile.clone());
21197 let record_id = record.record_id.clone();
21198 shared.insert_record(record.clone()).unwrap();
21199 assert!(request_requires_child_environment_profile(
21200 &shared,
21201 &NodeRequest::ResumeSessionRecord {
21202 record_id,
21203 terminal_size: gate4agent_types::TerminalSize {
21204 rows: 24,
21205 columns: 80,
21206 },
21207 initial_prompt: None,
21208 },
21209 ));
21210
21211 let snapshot = snapshot_for_wire(
21212 &shared, false, true, true, true, false, false, false, false, true,
21213 );
21214 assert_eq!(snapshot.session_records.len(), 1);
21215 assert!(snapshot.session_records[0].environment_profile.is_none());
21216 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21217 sequence: 1,
21218 event: NodeEvent::SessionRecordUpserted { record },
21219 });
21220 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21221 panic!("record projection changed the event kind");
21222 };
21223 assert!(record.environment_profile.is_none());
21224
21225 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21226 let mut reply = ResponseEnvelope {
21227 request_id: 1,
21228 result: Ok(NodeResponse::SpawnSpecAccepted {
21229 receipt: resolved.receipt_with_environment(
21230 shared.incarnation_id,
21231 address,
21232 Some(environment_profile),
21233 ),
21234 }),
21235 };
21236 project_response_without_child_environment_profile(&mut reply);
21237 assert_eq!(
21238 reply.result.unwrap_err().code,
21239 NodeFailureCode::UnsupportedCapability,
21240 );
21241 }
21242
21243 #[test]
21244 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21245 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21246 let snapshot = shared.snapshot();
21247 let profiles = snapshot
21248 .launch_inventory
21249 .as_ref()
21250 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21251 .unwrap();
21252 assert_eq!(profiles.len(), 1);
21253 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21254 assert!(snapshot.requires_child_environment_profile_capability());
21255
21256 let mut legacy = snapshot.clone();
21257 clear_snapshot_child_environment_profiles(&mut legacy);
21258 assert!(legacy
21259 .launch_inventory
21260 .as_ref()
21261 .unwrap()
21262 .spawn_profiles
21263 .as_ref()
21264 .unwrap()[0]
21265 .environment_profile
21266 .is_none());
21267 assert!(!legacy.requires_child_environment_profile_capability());
21268
21269 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21270 let bindings = shared
21271 .environment_profiles
21272 .read()
21273 .unwrap_or_else(|poisoned| poisoned.into_inner())
21274 .clone();
21275 let mut provider_mismatch = bindings.clone();
21276 provider_mismatch
21277 .values_mut()
21278 .next()
21279 .unwrap()
21280 .provider = agent("codex");
21281 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21282 let mut mode_mismatch = profile;
21283 mode_mismatch.mode = SessionMode::Inline;
21284 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21285
21286 shared
21287 .environment_profiles
21288 .write()
21289 .unwrap_or_else(|poisoned| poisoned.into_inner())
21290 .clear();
21291 assert!(shared
21292 .snapshot()
21293 .launch_inventory
21294 .unwrap()
21295 .spawn_profiles
21296 .unwrap()
21297 .is_empty());
21298 }
21299
21300 #[test]
21301 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21302 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21303 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21304 }));
21305 let shared = terminal_test_shared();
21306 let bundle = ResolvedBundleReceipt {
21307 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21308 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21309 digest: crate::protocol::SpawnBundleDigest::new(format!(
21310 "sha256:{}",
21311 "0".repeat(64),
21312 ))
21313 .unwrap(),
21314 };
21315 let mut bundled = record("claude", "bundle-record");
21316 bundled.bundle = Some(bundle.clone());
21317 let record_id = bundled.record_id.clone();
21318 shared.insert_record(bundled.clone()).unwrap();
21319 assert!(request_requires_session_bundle(
21320 &shared,
21321 &NodeRequest::ForgetSessionRecord {
21322 record_id: record_id.clone(),
21323 },
21324 ));
21325
21326 let snapshot = snapshot_for_wire(
21327 &shared, false, true, true, true, true, false, false, false, true,
21328 );
21329 assert!(snapshot.session_records[0].bundle.is_none());
21330 let launch = snapshot.launch_inventory.as_ref().unwrap();
21331 assert!(launch.spawn_profiles.is_some());
21332 assert!(launch.bundles.is_none());
21333 let event = project_event_without_session_bundle(NodeEventEnvelope {
21334 sequence: 1,
21335 event: NodeEvent::SessionRecordUpserted { record: bundled },
21336 });
21337 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21338 panic!("record projection changed the event kind");
21339 };
21340 assert!(record.bundle.is_none());
21341
21342 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21343 let mut reply = ResponseEnvelope {
21344 request_id: 1,
21345 result: Ok(NodeResponse::SpawnSpecAccepted {
21346 receipt: resolved.receipt_with_materialization(
21347 shared.incarnation_id,
21348 terminal_address(17),
21349 None,
21350 Some(bundle),
21351 None,
21352 ),
21353 }),
21354 };
21355 project_response_without_session_bundle(&mut reply);
21356 assert_eq!(
21357 reply.result.unwrap_err().code,
21358 NodeFailureCode::UnsupportedCapability,
21359 );
21360 }
21361
21362 #[test]
21363 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21364 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21365 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21366 }));
21367 let context_id = SpawnContextId::new("context-review").unwrap();
21368 let context = ResolvedContextPackReceipt {
21369 id: context_id.clone(),
21370 digest: crate::protocol::SpawnContextDigest::new(format!(
21371 "sha256:{}",
21372 "c".repeat(64),
21373 ))
21374 .unwrap(),
21375 lineage: ContextPackLineageReceipt {
21376 source_node_id: NodeId::new("node-source").unwrap(),
21377 source_session: terminal_address(1),
21378 source_provider: agent("kimi"),
21379 },
21380 source_message_count: 2,
21381 retained_message_count: 2,
21382 byte_len: 256,
21383 truncated: false,
21384 };
21385 assert!(context.is_valid());
21386 let mut bound_record = record("codex", "context-record");
21387 bound_record.context_id = Some(context_id);
21388 bound_record.context = Some(context.clone());
21389 let session = gate4agent_types::SessionSnapshot {
21390 instance_id: AgentInstanceId(91),
21391 agent_id: agent("kimi"),
21392 transport: TransportKind::Pty,
21393 generation: SessionGeneration(3),
21394 status: SessionStatus::Running,
21395 pending_operation: None,
21396 pending_input: None,
21397 process_id: Some(9001),
21398 terminal_size: None,
21399 terminal_frame: None,
21400 terminal_stale: None,
21401 session_options: None,
21402 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21403 history: gate4agent_types::HistorySnapshot {
21404 pending: None,
21405 candidates: vec![HistoryCandidateSummary {
21406 id: "candidate-opaque".to_owned(),
21407 session_id_hint: "session-hint".to_owned(),
21408 modified_at_unix_ms: Some(1),
21409 }],
21410 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21411 loaded: Some(HistorySessionRecord {
21412 session_id: "private-vendor-session".to_owned(),
21413 title: Some("private title".to_owned()),
21414 cwd: Some(r"C:\private\history-root".to_owned()),
21415 model: Some("private-model".to_owned()),
21416 message_count: 2,
21417 completed_turn_count: None,
21418 total_tokens: 19,
21419 messages: vec![
21420 gate4agent_types::HistoryMessageRecord {
21421 role: gate4agent_types::HistoryMessageRole::User,
21422 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21423 },
21424 gate4agent_types::HistoryMessageRecord {
21425 role: gate4agent_types::HistoryMessageRole::Assistant,
21426 text: "private answer".to_owned(),
21427 },
21428 ],
21429 }),
21430 last_error: None,
21431 },
21432 resume: gate4agent_types::ResumeSnapshot::default(),
21433 foreground: gate4agent_types::ForegroundSnapshot::default(),
21434 provider: gate4agent_types::ProviderSnapshot::default(),
21435 screen_state: gate4agent_types::PtyScreenState::default(),
21436 };
21437 let original = NodeSnapshot {
21438 node_id: NodeId::new("node-terminal-test").unwrap(),
21439 enabled_providers: vec![agent("kimi"), agent("codex")],
21440 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21441 workspaces: vec![WorkspaceSnapshot {
21442 workspace_id: WorkspaceId::new("primary").unwrap(),
21443 canonical_root: opaque_windows_path(
21444 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21445 ),
21446 sessions: vec![session],
21447 worktree_service_mode: None,
21448 managed_worktree_profiles: None,
21449 }],
21450 session_records: vec![bound_record],
21451 managed_worktrees: Vec::new(),
21452 launch_inventory: None,
21453 agent_progress: Vec::new(),
21454 };
21455
21456 let mut negotiated = original.clone();
21457 project_snapshot_history_for_wire(&mut negotiated, true);
21458 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21459 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21460 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21461 assert!(!serde_json::to_string(&negotiated)
21462 .unwrap()
21463 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21464
21465 let mut legacy = original;
21466 project_snapshot_history_for_wire(&mut legacy, false);
21467 clear_snapshot_context_packs(&mut legacy);
21468 assert_eq!(
21469 legacy.workspaces[0].sessions[0].history,
21470 gate4agent_types::HistorySnapshot::default(),
21471 );
21472 assert!(legacy.session_records[0].context_id.is_none());
21473 assert!(legacy.session_records[0].context.is_none());
21474 let encoded = serde_json::to_string(&legacy).unwrap();
21475 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21476 assert!(!encoded.contains("context-review"));
21477
21478 let mut reply = ResponseEnvelope {
21479 request_id: 7,
21480 result: Ok(NodeResponse::ContextPackExported { context }),
21481 };
21482 project_response_without_context_pack(&mut reply);
21483 assert_eq!(
21484 reply.result.unwrap_err().code,
21485 NodeFailureCode::UnsupportedCapability,
21486 );
21487 }
21488
21489 #[test]
21490 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21491 let shared = terminal_test_shared();
21492 let history = HistorySessionRecord {
21493 session_id: "fixture-history".to_owned(),
21494 title: None,
21495 cwd: None,
21496 model: None,
21497 message_count: 1,
21498 completed_turn_count: None,
21499 total_tokens: 0,
21500 messages: vec![gate4agent_types::HistoryMessageRecord {
21501 role: gate4agent_types::HistoryMessageRole::User,
21502 text: "bounded handoff".to_owned(),
21503 }],
21504 };
21505 let pack = NodeContextPack::export(
21506 ContextPackLineageReceipt {
21507 source_node_id: shared.node_id.clone(),
21508 source_session: terminal_address(1),
21509 source_provider: agent("claude"),
21510 },
21511 &history,
21512 )
21513 .unwrap();
21514 let context = shared
21515 .context_catalog
21516 .write()
21517 .unwrap_or_else(|poisoned| poisoned.into_inner())
21518 .insert(pack)
21519 .unwrap();
21520 let mut retained = record("codex", "context-retained-record");
21521 retained.context_id = Some(context.id.clone());
21522 retained.context = Some(context.clone());
21523 let record_id = retained.record_id.clone();
21524 shared.insert_record(retained).unwrap();
21525 assert_eq!(
21526 shared.forget_context_pack(&context.id).unwrap_err().code,
21527 NodeFailureCode::ContextPackBusy,
21528 );
21529 assert!(shared
21530 .context_catalog
21531 .read()
21532 .unwrap_or_else(|poisoned| poisoned.into_inner())
21533 .get(&context.id)
21534 .is_some());
21535 shared
21536 .session_records
21537 .lock()
21538 .unwrap_or_else(|poisoned| poisoned.into_inner())
21539 .records
21540 .remove(&record_id);
21541
21542 let mut spec = spawn_spec_fixture();
21543 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21544 value: context.id.clone(),
21545 };
21546 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21547 let receipt = resolved.receipt_with_materialization(
21548 shared.incarnation_id,
21549 terminal_address(2),
21550 None,
21551 None,
21552 Some(context.clone()),
21553 );
21554 assert!(receipt.context_binding_is_valid());
21555 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21556
21557 shared.forget_context_pack(&context.id).unwrap();
21558 assert!(shared
21559 .context_catalog
21560 .read()
21561 .unwrap_or_else(|poisoned| poisoned.into_inner())
21562 .get(&context.id)
21563 .is_none());
21564 assert!(shared
21565 .replay_spawn_spec(&spec, Instant::now())
21566 .unwrap()
21567 .is_none());
21568 }
21569
21570 #[test]
21571 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21572 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21573 let address = terminal_address(1);
21574 let selection = shared
21575 .select_environment_profile(
21576 address.session.instance_id,
21577 &agent("claude"),
21578 SessionMode::Pty,
21579 Some(&environment_profile),
21580 )
21581 .unwrap()
21582 .unwrap();
21583 shared.bind_session_with_policy(
21584 &address,
21585 ProviderRuntimePolicy::raw_pty(),
21586 Some(environment_profile.clone()),
21587 );
21588 selection.retain();
21589 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21590 assert!(matches!(
21591 runtime.remove_native_launch_profile(&native_id),
21592 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21593 ));
21594 assert!(shared.remove_binding(&address).is_some());
21595 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21596
21597 let mismatched = ResolvedEnvironmentProfileReceipt {
21598 profile_id: environment_profile.profile_id,
21599 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21600 "local-claude-r2",
21601 )
21602 .unwrap(),
21603 network_allowlist: None,
21604 browser_profile_id: None,
21605 };
21606 let error = match shared.select_environment_profile(
21607 AgentInstanceId(42),
21608 &agent("claude"),
21609 SessionMode::Pty,
21610 Some(&mismatched),
21611 ) {
21612 Ok(_) => panic!("mismatched environment profile revision was selected"),
21613 Err(error) => error,
21614 };
21615 assert_eq!(
21616 error.code,
21617 NodeFailureCode::EnvironmentProfileBindingMismatch,
21618 );
21619 assert!(!shared
21620 .session_bindings
21621 .lock()
21622 .unwrap_or_else(|poisoned| poisoned.into_inner())
21623 .contains_key(&AgentInstanceId(42)));
21624 }
21625
21626 #[test]
21627 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21628 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21629 let address = terminal_address(1);
21630 let selection = shared
21631 .select_environment_profile(
21632 address.session.instance_id,
21633 &agent("claude"),
21634 SessionMode::Pty,
21635 Some(&environment_profile),
21636 )
21637 .unwrap()
21638 .unwrap();
21639 let overlay = NativeInstanceLaunchOverlay::new(
21640 agent("claude"),
21641 TransportKind::Pty,
21642 vec![EnvMutation {
21643 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21644 value: Some(OsString::from("fixture-secret")),
21645 }],
21646 vec![
21647 OsString::from("--plugin-dir"),
21648 std::env::current_dir().unwrap().into_os_string(),
21649 ],
21650 )
21651 .unwrap();
21652 let instance_overlay = shared
21653 .install_prepared_launch_overlay(
21654 address.session.instance_id,
21655 PreparedNativeLaunchOverlay::Instance(overlay),
21656 )
21657 .unwrap()
21658 .unwrap();
21659 let bundle = ResolvedBundleReceipt {
21660 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21661 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21662 digest: crate::protocol::SpawnBundleDigest::new(format!(
21663 "sha256:{}",
21664 "0".repeat(64),
21665 ))
21666 .unwrap(),
21667 };
21668 shared.bind_session_with_materialization(
21669 &address,
21670 ProviderRuntimePolicy::raw_pty(),
21671 Some(environment_profile),
21672 Some(bundle),
21673 None,
21674 None,
21675 );
21676 selection.retain();
21677 instance_overlay.retain();
21678
21679 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21680 assert!(matches!(
21681 runtime.remove_native_launch_profile(&native_id),
21682 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21683 ));
21684 assert!(shared.remove_binding(&address).is_some());
21685 assert!(!shared
21686 .native_launch_profile_control
21687 .as_ref()
21688 .unwrap()
21689 .clear_native_instance_launch_overlay(address.session.instance_id));
21690 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21691 }
21692
21693 #[test]
21694 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21695 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21696 let address = SessionAddress {
21697 workspace_id: WorkspaceId::new("primary").unwrap(),
21698 session: SessionKey {
21699 instance_id: AgentInstanceId(201),
21700 generation: SessionGeneration(1),
21701 },
21702 };
21703 let error = match shared.prepare_session_materialization(
21704 &address,
21705 &agent("claude"),
21706 SessionMode::Pty,
21707 Some(&receipt),
21708 None,
21709 None,
21710 None,
21711 ) {
21712 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21713 Err(error) => error,
21714 };
21715 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21716 assert!(shared.handle.snapshot().sessions.is_empty());
21717 assert!(shared
21718 .session_bindings
21719 .lock()
21720 .unwrap_or_else(|poisoned| poisoned.into_inner())
21721 .is_empty());
21722 assert!(shared
21723 .materializations
21724 .lock()
21725 .unwrap_or_else(|poisoned| poisoned.into_inner())
21726 .is_empty());
21727 drop(shared);
21728 drop(runtime);
21729 std::fs::remove_dir_all(root).unwrap();
21730 }
21731
21732 #[test]
21733 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21734 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21735 let address = SessionAddress {
21736 workspace_id: WorkspaceId::new("primary").unwrap(),
21737 session: SessionKey {
21738 instance_id: AgentInstanceId(202),
21739 generation: SessionGeneration(1),
21740 },
21741 };
21742 let (overlay, materialization_guard) = shared
21743 .prepare_session_materialization(
21744 &address,
21745 &agent("claude"),
21746 SessionMode::Pty,
21747 Some(&receipt),
21748 None,
21749 None,
21750 None,
21751 )
21752 .unwrap()
21753 .unwrap();
21754 let materialization_id = materialization_guard.id().unwrap().clone();
21755 let materialization_root = shared
21756 .materializations
21757 .lock()
21758 .unwrap_or_else(|poisoned| poisoned.into_inner())
21759 .get(&materialization_id)
21760 .unwrap()
21761 .root()
21762 .to_path_buf();
21763 let selection = shared
21764 .select_environment_profile(
21765 address.session.instance_id,
21766 &agent("claude"),
21767 SessionMode::Pty,
21768 Some(&receipt),
21769 )
21770 .unwrap()
21771 .unwrap();
21772 assert!(shared
21773 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21774 .unwrap()
21775 .is_none());
21776 shared.bind_session_with_materialization(
21777 &address,
21778 ProviderRuntimePolicy::raw_pty(),
21779 Some(receipt),
21780 None,
21781 None,
21782 Some(materialization_id),
21783 );
21784 selection.retain();
21785 materialization_guard.retain();
21786
21787 assert!(materialization_root.is_dir());
21788 assert!(shared
21789 .session_bindings
21790 .lock()
21791 .unwrap_or_else(|poisoned| poisoned.into_inner())
21792 .contains_key(&address.session.instance_id));
21793
21794 let removed = shared.remove_binding(&address).unwrap();
21795 shared
21796 .cleanup_session_owned_materialization(&address, &removed)
21797 .unwrap();
21798 assert!(!materialization_root.exists());
21799 assert!(shared
21800 .materializations
21801 .lock()
21802 .unwrap_or_else(|poisoned| poisoned.into_inner())
21803 .is_empty());
21804 drop(shared);
21805 drop(runtime);
21806 std::fs::remove_dir_all(root).unwrap();
21807 }
21808
21809 #[tokio::test]
21810 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21811 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21812 let address = SessionAddress {
21813 workspace_id: WorkspaceId::new("primary").unwrap(),
21814 session: SessionKey {
21815 instance_id: AgentInstanceId(203),
21816 generation: SessionGeneration(1),
21817 },
21818 };
21819 let (overlay, materialization_guard) = shared
21820 .prepare_session_materialization(
21821 &address,
21822 &agent("claude"),
21823 SessionMode::Pty,
21824 Some(&receipt),
21825 None,
21826 None,
21827 None,
21828 )
21829 .unwrap()
21830 .unwrap();
21831 let materialization_id = materialization_guard.id().unwrap().clone();
21832 let materialization_root = shared
21833 .materializations
21834 .lock()
21835 .unwrap_or_else(|poisoned| poisoned.into_inner())
21836 .get(&materialization_id)
21837 .unwrap()
21838 .root()
21839 .to_path_buf();
21840 let selection = shared
21841 .select_environment_profile(
21842 address.session.instance_id,
21843 &agent("claude"),
21844 SessionMode::Pty,
21845 Some(&receipt),
21846 )
21847 .unwrap()
21848 .unwrap();
21849 assert!(shared
21850 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21851 .unwrap()
21852 .is_none());
21853 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21854 let record_id = shared
21855 .bind_spawn_session_with_materialization(
21856 &address,
21857 agent("claude"),
21858 SessionMode::Pty,
21859 policy,
21860 SpawnRecordPolicy::ProviderIdentityOnly,
21861 Some(receipt.clone()),
21862 None,
21863 None,
21864 Some(materialization_id.clone()),
21865 )
21866 .unwrap()
21867 .unwrap();
21868 selection.retain();
21869 materialization_guard.retain();
21870 assert_eq!(
21871 shared
21872 .materializations
21873 .lock()
21874 .unwrap_or_else(|poisoned| poisoned.into_inner())
21875 .get(&materialization_id)
21876 .unwrap()
21877 .owner(),
21878 &MaterializationOwner::Record {
21879 record_id: record_id.clone(),
21880 },
21881 );
21882
21883 let removed = shared.remove_binding(&address).unwrap();
21884 shared
21885 .cleanup_session_owned_materialization(&address, &removed)
21886 .unwrap();
21887 assert!(materialization_root.is_dir());
21888 assert!(shared
21889 .resolve_record_materialization(
21890 &record_id,
21891 &agent("claude"),
21892 SessionMode::Pty,
21893 Some(&receipt),
21894 None,
21895 None,
21896 )
21897 .unwrap()
21898 .is_some());
21899 {
21900 let mut records = shared
21901 .session_records
21902 .lock()
21903 .unwrap_or_else(|poisoned| poisoned.into_inner());
21904 let record = records.records.get_mut(&record_id).unwrap();
21905 record.active_session = None;
21906 record.state = ManagedSessionState::Unavailable;
21907 }
21908 shared.forget_session_record(&record_id).await.unwrap();
21909 assert!(!materialization_root.exists());
21910 assert!(shared
21911 .materializations
21912 .lock()
21913 .unwrap_or_else(|poisoned| poisoned.into_inner())
21914 .is_empty());
21915 drop(shared);
21916 drop(runtime);
21917 std::fs::remove_dir_all(root).unwrap();
21918 }
21919
21920 #[test]
21921 fn record_materialization_revalidation_failure_persists_recovery_required() {
21922 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21923 let state_path = root.join("node-state-v6.json");
21924 shared.state_path = Some(state_path.clone());
21925 let (_address, _record_id, materialization_id, materialization_root) =
21926 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21927 std::fs::write(
21928 materialization_root.join(".gate4agent-materialization-owner"),
21929 b"tampered",
21930 )
21931 .unwrap();
21932
21933 let error = match shared.resolve_record_materialization(
21934 &_record_id,
21935 &agent("claude"),
21936 SessionMode::Pty,
21937 Some(&receipt),
21938 None,
21939 None,
21940 ) {
21941 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21942 Err(error) => error,
21943 };
21944 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21945 assert_eq!(
21946 shared
21947 .materializations
21948 .lock()
21949 .unwrap_or_else(|poisoned| poisoned.into_inner())
21950 .get(&materialization_id)
21951 .unwrap()
21952 .state(),
21953 MaterializationState::RecoveryRequired,
21954 );
21955 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21956 assert_eq!(loaded.materializations.len(), 1);
21957 assert_eq!(
21958 loaded.materializations[0].state(),
21959 MaterializationState::RecoveryRequired,
21960 );
21961 drop(shared);
21962 drop(runtime);
21963 std::fs::remove_dir_all(root).unwrap();
21964 }
21965
21966 #[test]
21967 fn transient_record_materialization_resolution_failure_remains_ready() {
21968 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21969 let (_address, record_id, materialization_id, _materialization_root) =
21970 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21971 deny.store(true, Ordering::Release);
21972
21973 let error = match shared.resolve_record_materialization(
21974 &record_id,
21975 &agent("claude"),
21976 SessionMode::Pty,
21977 Some(&receipt),
21978 None,
21979 None,
21980 ) {
21981 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21982 Err(error) => error,
21983 };
21984 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21985 assert_eq!(
21986 shared
21987 .materializations
21988 .lock()
21989 .unwrap_or_else(|poisoned| poisoned.into_inner())
21990 .get(&materialization_id)
21991 .unwrap()
21992 .state(),
21993 MaterializationState::Ready,
21994 );
21995 drop(shared);
21996 drop(runtime);
21997 std::fs::remove_dir_all(root).unwrap();
21998 }
21999
22000 #[test]
22001 fn startup_missing_record_materialization_is_not_resumable() {
22002 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22003 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22004 shared
22005 .insert_record(ManagedSessionRecord {
22006 record_id: record_id.clone(),
22007 display_name: "missing materialization".to_owned(),
22008 provider: agent("claude"),
22009 mode: SessionMode::Pty,
22010 state: ManagedSessionState::Dormant,
22011 workspace_id: WorkspaceId::new("primary").unwrap(),
22012 canonical_root: opaque_windows_path(
22013 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22014 ),
22015 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22016 key: gate4agent_types::ProviderSessionKey::SessionId,
22017 id: "provider-session-missing-materialization".to_owned(),
22018 transcript_path: None,
22019 }),
22020 active_session: None,
22021 environment_profile: Some(receipt),
22022 bundle: None,
22023 context_id: None,
22024 context: None,
22025 exported_context: None,
22026 task_binding: None,
22027 created_at_unix_ms: 1,
22028 updated_at_unix_ms: 1,
22029 last_error: None,
22030 })
22031 .unwrap();
22032
22033 shared.reconcile_materializations();
22034 let record = shared.record(&record_id).unwrap();
22035 assert_eq!(record.state, ManagedSessionState::Unavailable);
22036 assert!(record.provider_session.is_none());
22037 assert_eq!(
22038 record.last_error.as_deref(),
22039 Some("environment-profile-unavailable"),
22040 );
22041 drop(shared);
22042 drop(runtime);
22043 std::fs::remove_dir_all(root).unwrap();
22044 }
22045
22046 #[tokio::test]
22047 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22048 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22049 let lease = install_managed_lease(&shared);
22050 let address = SessionAddress {
22051 workspace_id: WorkspaceId::new("primary").unwrap(),
22052 session: SessionKey {
22053 instance_id: AgentInstanceId(204),
22054 generation: SessionGeneration(1),
22055 },
22056 };
22057 let (overlay, materialization_guard) = shared
22058 .prepare_session_materialization(
22059 &address,
22060 &agent("claude"),
22061 SessionMode::Pty,
22062 Some(&receipt),
22063 None,
22064 None,
22065 Some(lease.lease_id.clone()),
22066 )
22067 .unwrap()
22068 .unwrap();
22069 let materialization_id = materialization_guard.id().unwrap().clone();
22070 let materialization_root = shared
22071 .materializations
22072 .lock()
22073 .unwrap_or_else(|poisoned| poisoned.into_inner())
22074 .get(&materialization_id)
22075 .unwrap()
22076 .root()
22077 .to_path_buf();
22078 let selection = shared
22079 .select_environment_profile(
22080 address.session.instance_id,
22081 &agent("claude"),
22082 SessionMode::Pty,
22083 Some(&receipt),
22084 )
22085 .unwrap()
22086 .unwrap();
22087 assert!(shared
22088 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22089 .unwrap()
22090 .is_none());
22091 shared.bind_session_with_materialization(
22092 &address,
22093 ProviderRuntimePolicy::raw_pty(),
22094 Some(receipt),
22095 None,
22096 None,
22097 Some(materialization_id),
22098 );
22099 selection.retain();
22100 materialization_guard.retain();
22101
22102 let blocked = shared
22103 .cleanup_managed_worktree(&lease.lease_id, false)
22104 .await
22105 .unwrap_err();
22106 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22107 assert!(materialization_root.is_dir());
22108 let removed = shared.remove_binding(&address).unwrap();
22109 shared
22110 .cleanup_session_owned_materialization(&address, &removed)
22111 .unwrap();
22112 assert!(!materialization_root.exists());
22113 drop(shared);
22114 drop(runtime);
22115 std::fs::remove_dir_all(root).unwrap();
22116 }
22117
22118 #[tokio::test]
22119 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22120 let git_root = temporary_workspace_root("raw-managed-inventory");
22121 let source = git_root.join("source");
22122 let allocation = git_root.join("allocation");
22123 std::fs::create_dir_all(&source).unwrap();
22124 std::fs::create_dir_all(&allocation).unwrap();
22125 run_test_git(&source, &["init"]);
22126 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22127 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22128 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22129 run_test_git(&source, &["add", "seed.txt"]);
22130 run_test_git(&source, &["commit", "-m", "seed"]);
22131 let profile = ManagedWorktreeProfile::new(
22132 WorktreeProfileId::new("default").unwrap(),
22133 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22134 &allocation,
22135 "gate4agent",
22136 "HEAD",
22137 ManagedWorktreeRetention::RemoveWhenReleased,
22138 )
22139 .unwrap();
22140 let workspace = WorkspaceConfig::new(
22141 WorkspaceId::new("primary").unwrap(),
22142 &source,
22143 )
22144 .unwrap()
22145 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22146 .with_managed_worktree_profile(profile.clone())
22147 .unwrap();
22148 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22149 materialization_test_shared_with_workspace(false, workspace);
22150 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22151 let source_text = source.to_string_lossy().into_owned();
22152 let target_text = target.to_string_lossy().into_owned();
22153 let created = create_git_worktree(
22154 &source_text,
22155 &target_text,
22156 "gate4agent/mw-raw-inventory",
22157 Some("HEAD"),
22158 )
22159 .await
22160 .unwrap();
22161 let lease = ManagedWorktreeLeaseRecord {
22162 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22163 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22164 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22165 profile_id: profile.profile_id().clone(),
22166 profile_revision: profile.revision().clone(),
22167 target_root: created.path.clone(),
22168 branch: "gate4agent/mw-raw-inventory".to_owned(),
22169 base_commit: created.head.clone(),
22170 expected_head: Some(created.head.clone()),
22171 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22172 state: ManagedWorktreeLeaseState::Ready,
22173 session_holders: Vec::new(),
22174 record_holders: Vec::new(),
22175 cleanup_failure: None,
22176 created_at_unix_ms: 1,
22177 updated_at_unix_ms: 1,
22178 };
22179 shared
22180 .workspaces
22181 .write()
22182 .unwrap_or_else(|poisoned| poisoned.into_inner())
22183 .insert(lease.workspace_id.clone(), created.path.clone());
22184 *shared
22185 .managed_worktrees
22186 .lock()
22187 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22188 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22189 let address = SessionAddress {
22190 workspace_id: lease.workspace_id.clone(),
22191 session: SessionKey {
22192 instance_id: AgentInstanceId(205),
22193 generation: SessionGeneration(1),
22194 },
22195 };
22196 let (overlay, materialization_guard) = shared
22197 .prepare_session_materialization(
22198 &address,
22199 &agent("claude"),
22200 SessionMode::Pty,
22201 Some(&receipt),
22202 None,
22203 None,
22204 Some(lease.lease_id.clone()),
22205 )
22206 .unwrap()
22207 .unwrap();
22208 let materialization_id = materialization_guard.id().unwrap().clone();
22209 let materialization_root = shared
22210 .materializations
22211 .lock()
22212 .unwrap_or_else(|poisoned| poisoned.into_inner())
22213 .get(&materialization_id)
22214 .unwrap()
22215 .root()
22216 .to_path_buf();
22217 let selection = shared
22218 .select_environment_profile(
22219 address.session.instance_id,
22220 &agent("claude"),
22221 SessionMode::Pty,
22222 Some(&receipt),
22223 )
22224 .unwrap()
22225 .unwrap();
22226 assert!(shared
22227 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22228 .unwrap()
22229 .is_none());
22230 let record_id = shared
22231 .bind_spawn_session_with_materialization(
22232 &address,
22233 agent("claude"),
22234 SessionMode::Pty,
22235 ProviderRuntimePolicy::raw_pty(),
22236 SpawnRecordPolicy::Always,
22237 Some(receipt.clone()),
22238 None,
22239 None,
22240 Some(materialization_id.clone()),
22241 )
22242 .unwrap()
22243 .unwrap();
22244 selection.retain();
22245 materialization_guard.retain();
22246 let record = shared.record(&record_id).unwrap();
22247 assert_eq!(record.state, ManagedSessionState::Live);
22248 assert!(record.provider_session.is_none());
22249 assert!(record.environment_profile.is_none());
22250 assert!(record.bundle.is_none());
22251 assert!(record.context_id.is_none());
22252 assert!(record.context.is_none());
22253 assert_eq!(record.active_session.as_ref(), Some(&address));
22254 assert_eq!(
22255 shared
22256 .materializations
22257 .lock()
22258 .unwrap_or_else(|poisoned| poisoned.into_inner())
22259 .get(&materialization_id)
22260 .unwrap()
22261 .owner(),
22262 &MaterializationOwner::Session {
22263 incarnation_id: shared.incarnation_id,
22264 instance_id: address.session.instance_id,
22265 generation: address.session.generation,
22266 },
22267 );
22268 {
22269 let mut bindings = shared
22270 .session_bindings
22271 .lock()
22272 .unwrap_or_else(|poisoned| poisoned.into_inner());
22273 bindings
22274 .get_mut(&address.session.instance_id)
22275 .unwrap()
22276 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22277 }
22278 let lease_snapshot = shared
22279 .managed_worktrees
22280 .lock()
22281 .unwrap_or_else(|poisoned| poisoned.into_inner())
22282 .bind_session(
22283 &lease.lease_id,
22284 ManagedWorktreeSessionHolder {
22285 incarnation_id: shared.incarnation_id,
22286 instance_id: address.session.instance_id,
22287 generation: address.session.generation,
22288 },
22289 None,
22290 unix_time_ms(),
22291 )
22292 .unwrap();
22293 assert_eq!(lease_snapshot.active_session_count, 1);
22294 assert_eq!(lease_snapshot.managed_record_count, 0);
22295 shared.persist_state().unwrap();
22296 assert!(shared
22297 .managed_worktrees
22298 .lock()
22299 .unwrap_or_else(|poisoned| poisoned.into_inner())
22300 .get(&lease.lease_id)
22301 .unwrap()
22302 .record_holders
22303 .is_empty());
22304 shared
22305 .handle
22306 .dispatch(shared.prepare_command(ControlCommand::Register {
22307 instance_id: address.session.instance_id,
22308 agent_id: agent("claude"),
22309 transport: TransportKind::Pty,
22310 }))
22311 .unwrap();
22312 runtime.tick().await;
22313 shared.reconcile_managed_record(
22314 &record_id,
22315 &address,
22316 &ControlEvent {
22317 sequence: 1,
22318 command_id: None,
22319 instance_id: address.session.instance_id,
22320 generation: address.session.generation,
22321 event: ControlEventKind::Removed,
22322 },
22323 false,
22324 false,
22325 );
22326 let remove = shared.remove_session(&address);
22327 let drive_runtime = async {
22328 runtime.tick().await;
22329 };
22330 let (removed, ()) = tokio::join!(remove, drive_runtime);
22331 removed.unwrap();
22332
22333 assert!(!materialization_root.exists());
22334 assert!(!Path::new(&created.path).exists());
22335 assert_eq!(
22336 shared
22337 .managed_worktrees
22338 .lock()
22339 .unwrap_or_else(|poisoned| poisoned.into_inner())
22340 .get(&lease.lease_id)
22341 .unwrap()
22342 .state,
22343 ManagedWorktreeLeaseState::Removed,
22344 );
22345 let renamed = shared
22346 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22347 .unwrap();
22348 assert_eq!(renamed.record_id, record_id);
22349 assert_eq!(renamed.display_name, "raw managed inventory");
22350 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22351 assert!(renamed.active_session.is_none());
22352
22353 drop(shared);
22354 drop(runtime);
22355 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22356 std::fs::remove_dir_all(git_root).unwrap();
22357 }
22358
22359 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22360 let root = std::env::current_dir().unwrap()
22361 .join("managed-test-target")
22362 .to_string_lossy()
22363 .into_owned();
22364 let lease = ManagedWorktreeLeaseRecord {
22365 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22366 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22367 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22368 profile_id: WorktreeProfileId::new("default").unwrap(),
22369 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22370 target_root: root.clone(),
22371 branch: "gate4agent/mw-test".to_owned(),
22372 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22373 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22374 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22375 state: ManagedWorktreeLeaseState::Ready,
22376 session_holders: Vec::new(),
22377 record_holders: Vec::new(),
22378 cleanup_failure: None,
22379 created_at_unix_ms: 1,
22380 updated_at_unix_ms: 1,
22381 };
22382 shared.workspaces.write()
22383 .unwrap_or_else(|poisoned| poisoned.into_inner())
22384 .insert(lease.workspace_id.clone(), root);
22385 *shared.managed_worktrees.lock()
22386 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22387 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22388 lease
22389 }
22390
22391 #[cfg(feature = "fixture")]
22392 #[test]
22393 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22394 let mut shared = terminal_test_shared();
22395 shared.fixture_semantic_hook_policy = true;
22396 let lease = install_managed_lease(&shared);
22397 let address = SessionAddress {
22398 workspace_id: lease.workspace_id.clone(),
22399 session: SessionKey {
22400 instance_id: AgentInstanceId(206),
22401 generation: SessionGeneration(1),
22402 },
22403 };
22404 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22405 let effective_runtime_policy =
22406 shared.effective_spawn_runtime_policy(pre_admission_policy);
22407 assert_ne!(effective_runtime_policy, pre_admission_policy);
22408 assert!(effective_runtime_policy.provider_session_identity);
22409
22410 let record_id = shared
22411 .bind_spawn_session(
22412 &address,
22413 agent("claude"),
22414 SessionMode::Pty,
22415 effective_runtime_policy,
22416 SpawnRecordPolicy::Always,
22417 None,
22418 )
22419 .unwrap()
22420 .unwrap();
22421 assert_eq!(
22422 shared.record(&record_id).unwrap().state,
22423 ManagedSessionState::IdentityPending,
22424 );
22425 let (record_holder, raw_inventory_record_id) = {
22426 let mut bindings = shared
22427 .session_bindings
22428 .lock()
22429 .unwrap_or_else(|poisoned| poisoned.into_inner());
22430 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22431 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22432 managed_spawn_record_ownership(binding, effective_runtime_policy)
22433 };
22434 assert_eq!(record_holder.as_ref(), Some(&record_id));
22435 assert!(raw_inventory_record_id.is_none());
22436 let snapshot = shared
22437 .managed_worktrees
22438 .lock()
22439 .unwrap_or_else(|poisoned| poisoned.into_inner())
22440 .bind_session(
22441 &lease.lease_id,
22442 ManagedWorktreeSessionHolder {
22443 incarnation_id: shared.incarnation_id,
22444 instance_id: address.session.instance_id,
22445 generation: address.session.generation,
22446 },
22447 record_holder,
22448 unix_time_ms(),
22449 )
22450 .unwrap();
22451 assert_eq!(snapshot.managed_record_count, 1);
22452 assert_eq!(
22453 shared
22454 .managed_worktrees
22455 .lock()
22456 .unwrap_or_else(|poisoned| poisoned.into_inner())
22457 .get(&lease.lease_id)
22458 .unwrap()
22459 .record_holders,
22460 vec![record_id.clone()],
22461 );
22462
22463 let identity = ProviderSessionIdentity {
22464 key: ProviderSessionKey::SessionId,
22465 id: "monitoring-fixture-managed-session".to_owned(),
22466 transcript_path: None,
22467 };
22468 shared.publish_control(ControlEvent {
22469 sequence: 1,
22470 command_id: None,
22471 instance_id: address.session.instance_id,
22472 generation: address.session.generation,
22473 event: ControlEventKind::ProviderEvent {
22474 sequence: 1,
22475 source: gate4agent_types::ProviderSource {
22476 family: AdapterFamily::Hook,
22477 binding: AdapterBinding::new(
22478 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22479 "fixture/v1",
22480 gate4agent_types::AdapterVerification::SyntheticFixture,
22481 )
22482 .unwrap(),
22483 },
22484 source_sequence: 1,
22485 event: ProviderEvent::SessionIdentityObserved {
22486 identity: identity.clone(),
22487 },
22488 },
22489 });
22490
22491 let live = shared.record(&record_id).unwrap();
22492 assert_eq!(live.state, ManagedSessionState::Live);
22493 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22494 assert_eq!(live.active_session.as_ref(), Some(&address));
22495 assert_eq!(
22496 shared
22497 .managed_worktrees
22498 .lock()
22499 .unwrap_or_else(|poisoned| poisoned.into_inner())
22500 .get(&lease.lease_id)
22501 .unwrap()
22502 .record_holders,
22503 vec![record_id],
22504 );
22505 }
22506
22507 fn managed_v2_test_shared(
22508 allocation_root: &Path,
22509 state_path: Option<PathBuf>,
22510 profile_revision: &str,
22511 ) -> NodeShared {
22512 let profile = ManagedWorktreeProfile::new(
22513 WorktreeProfileId::new("default").unwrap(),
22514 WorktreeProfileRevision::new(profile_revision).unwrap(),
22515 allocation_root,
22516 "gate4agent",
22517 "HEAD",
22518 ManagedWorktreeRetention::RemoveWhenReleased,
22519 )
22520 .unwrap();
22521 let workspace = WorkspaceConfig::new(
22522 WorkspaceId::new("primary").unwrap(),
22523 std::env::current_dir().unwrap(),
22524 )
22525 .unwrap()
22526 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22527 .with_managed_worktree_profile(profile)
22528 .unwrap();
22529 let catalog = active_registry().unwrap();
22530 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22531 let mut shared = NodeShared::new(
22532 handle,
22533 "fixture-token".to_owned(),
22534 NodeId::new("node-terminal-test").unwrap(),
22535 vec![workspace],
22536 vec![agent("claude")],
22537 );
22538 shared.state_path = state_path;
22539 shared
22540 }
22541
22542 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22543 let mut spawn_spec = spawn_spec_fixture();
22544 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22545 ManagedWorktreeSpawnRequestV2 {
22546 spawn_spec,
22547 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22548 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22549 }
22550 }
22551
22552 #[test]
22553 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22554 let shared = terminal_test_shared();
22555 let mut lease = install_managed_lease(&shared);
22556 let session = SessionAddress {
22557 workspace_id: lease.workspace_id.clone(),
22558 session: SessionKey {
22559 instance_id: AgentInstanceId(73),
22560 generation: SessionGeneration(4),
22561 },
22562 };
22563 lease.state = ManagedWorktreeLeaseState::InUse;
22564 lease.session_holders.push(ManagedWorktreeSessionHolder {
22565 incarnation_id: shared.incarnation_id,
22566 instance_id: session.session.instance_id,
22567 generation: session.session.generation,
22568 });
22569 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22570 assert!(resolved.target.worktree_id.is_none());
22571
22572 let receipt = managed_spawn_receipt(
22573 &resolved,
22574 shared.incarnation_id,
22575 session.clone(),
22576 lease.snapshot(),
22577 None,
22578 None,
22579 None,
22580 );
22581 assert!(resolved.target.worktree_id.is_none());
22582 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22583 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22584 assert_eq!(receipt.spawn.session, session);
22585 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22586 let encoded = serde_json::to_vec(&receipt).unwrap();
22587 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22588 assert_eq!(decoded, receipt);
22589 }
22590
22591 #[tokio::test]
22592 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22593 let root = temporary_workspace_root("managed-v2-revision");
22594 let allocation = root.join("allocation");
22595 std::fs::create_dir_all(&allocation).unwrap();
22596 let shared = managed_v2_test_shared(
22597 &allocation,
22598 Some(root.join("node-state.json")),
22599 "v1",
22600 );
22601 let mut request = managed_v2_request("managed-v2-revision");
22602 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22603
22604 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22605
22606 assert_eq!(
22607 failure.code,
22608 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22609 );
22610 assert!(shared
22611 .managed_worktrees
22612 .lock()
22613 .unwrap_or_else(|poisoned| poisoned.into_inner())
22614 .records()
22615 .is_empty());
22616 assert!(shared
22617 .managed_spawn_replays
22618 .lock()
22619 .unwrap_or_else(|poisoned| poisoned.into_inner())
22620 .is_empty());
22621 std::fs::remove_dir_all(root).unwrap();
22622 }
22623
22624 #[test]
22625 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22626 let root = temporary_workspace_root("managed-v2-concurrent");
22627 let allocation = root.join("allocation");
22628 std::fs::create_dir_all(&allocation).unwrap();
22629 let shared = Arc::new(managed_v2_test_shared(
22630 &allocation,
22631 Some(root.join("node-state.json")),
22632 "v1",
22633 ));
22634 let request = managed_v2_request("managed-v2-concurrent");
22635 let barrier = Arc::new(std::sync::Barrier::new(3));
22636 let mut joins = Vec::new();
22637 for _ in 0..2 {
22638 let shared = Arc::clone(&shared);
22639 let request = request.clone();
22640 let barrier = Arc::clone(&barrier);
22641 joins.push(std::thread::spawn(move || {
22642 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22643 barrier.wait();
22644 shared.acquire_durable_managed_spawn_v2(&request, digest)
22645 }));
22646 }
22647 barrier.wait();
22648 let results = joins
22649 .into_iter()
22650 .map(|join| join.join().unwrap())
22651 .collect::<Vec<_>>();
22652 assert_eq!(
22653 results
22654 .iter()
22655 .filter(|result| {
22656 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22657 })
22658 .count(),
22659 1,
22660 );
22661 assert_eq!(
22662 results
22663 .iter()
22664 .filter(|result| {
22665 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22666 })
22667 .count(),
22668 1,
22669 );
22670 assert_eq!(
22671 shared
22672 .managed_spawn_replays
22673 .lock()
22674 .unwrap_or_else(|poisoned| poisoned.into_inner())
22675 .len(),
22676 1,
22677 );
22678 assert!(shared
22679 .managed_worktrees
22680 .lock()
22681 .unwrap_or_else(|poisoned| poisoned.into_inner())
22682 .records()
22683 .is_empty());
22684 drop(shared);
22685 std::fs::remove_dir_all(root).unwrap();
22686 }
22687
22688 #[tokio::test]
22689 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22690 let root = temporary_workspace_root("managed-v2-linked-pending");
22691 let allocation = root.join("allocation");
22692 std::fs::create_dir_all(&allocation).unwrap();
22693 let state_path = root.join("node-state.json");
22694 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22695 let lease = install_managed_lease(&shared);
22696 let request = managed_v2_request("managed-v2-linked-pending");
22697 assert!(matches!(
22698 shared
22699 .acquire_durable_managed_spawn_v2(
22700 &request,
22701 managed_spawn_request_digest_v2(&request).unwrap(),
22702 )
22703 .unwrap(),
22704 DurableManagedSpawnReplayDecision::Reserved,
22705 ));
22706 {
22707 let _transaction = shared
22708 .state_transaction
22709 .lock()
22710 .unwrap_or_else(|poisoned| poisoned.into_inner());
22711 shared
22712 .link_durable_managed_spawn_v2_lease_locked(
22713 &request.spawn_spec.idempotency_key,
22714 &lease.lease_id,
22715 )
22716 .unwrap();
22717 shared.persist_state_locked().unwrap();
22718 }
22719 drop(shared);
22720
22721 let mut loaded = session_registry::load(
22722 Some(&state_path),
22723 &NodeId::new("node-terminal-test").unwrap(),
22724 )
22725 .unwrap();
22726 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22727 *reopened
22728 .managed_worktrees
22729 .lock()
22730 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22731 ManagedWorktreeRegistry::from_records(
22732 std::mem::take(&mut loaded.managed_worktrees),
22733 std::mem::take(&mut loaded.managed_worktree_tombstones),
22734 )
22735 .unwrap();
22736 *reopened
22737 .managed_spawn_replays
22738 .lock()
22739 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22740 .managed_spawn_replays
22741 .into_iter()
22742 .map(|record| (record.idempotency_key.clone(), record))
22743 .collect();
22744 let lease_count = reopened
22745 .managed_worktrees
22746 .lock()
22747 .unwrap_or_else(|poisoned| poisoned.into_inner())
22748 .records()
22749 .len();
22750
22751 let failure = reopened
22752 .spawn_managed_worktree_v2(request)
22753 .await
22754 .unwrap_err();
22755
22756 assert_eq!(
22757 failure.code,
22758 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22759 );
22760 assert_eq!(
22761 reopened
22762 .managed_worktrees
22763 .lock()
22764 .unwrap_or_else(|poisoned| poisoned.into_inner())
22765 .records()
22766 .len(),
22767 lease_count,
22768 );
22769 std::fs::remove_dir_all(root).unwrap();
22770 }
22771
22772 #[tokio::test]
22773 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22774 let root = temporary_workspace_root("managed-v2-reopen");
22775 let allocation = root.join("allocation");
22776 std::fs::create_dir_all(&allocation).unwrap();
22777 let state_path = root.join("node-state.json");
22778 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22779 let lease = install_managed_lease(&shared);
22780 let request = managed_v2_request("managed-v2-reopen");
22781 let session = SessionAddress {
22782 workspace_id: lease.workspace_id.clone(),
22783 session: SessionKey {
22784 instance_id: AgentInstanceId(91),
22785 generation: SessionGeneration(1),
22786 },
22787 };
22788 let snapshot = {
22789 let mut registry = shared
22790 .managed_worktrees
22791 .lock()
22792 .unwrap_or_else(|poisoned| poisoned.into_inner());
22793 let current = registry.get_mut(&lease.lease_id).unwrap();
22794 current.state = ManagedWorktreeLeaseState::InUse;
22795 current.session_holders.push(ManagedWorktreeSessionHolder {
22796 incarnation_id: shared.incarnation_id,
22797 instance_id: session.session.instance_id,
22798 generation: session.session.generation,
22799 });
22800 current.snapshot()
22801 };
22802 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22803 let receipt = managed_spawn_receipt(
22804 &resolved,
22805 shared.incarnation_id,
22806 session,
22807 snapshot,
22808 None,
22809 None,
22810 None,
22811 );
22812 assert!(matches!(
22813 shared
22814 .acquire_durable_managed_spawn_v2(
22815 &request,
22816 managed_spawn_request_digest_v2(&request).unwrap(),
22817 )
22818 .unwrap(),
22819 DurableManagedSpawnReplayDecision::Reserved,
22820 ));
22821 {
22822 let _transaction = shared
22823 .state_transaction
22824 .lock()
22825 .unwrap_or_else(|poisoned| poisoned.into_inner());
22826 shared
22827 .link_durable_managed_spawn_v2_lease_locked(
22828 &request.spawn_spec.idempotency_key,
22829 &lease.lease_id,
22830 )
22831 .unwrap();
22832 shared.persist_state_locked().unwrap();
22833 }
22834 shared
22835 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22836 .unwrap();
22837 let legacy_same_key = ManagedWorktreeSpawnRequest {
22838 spawn_spec: request.spawn_spec.clone(),
22839 worktree_profile_id: request.worktree_profile_id.clone(),
22840 };
22841 shared.remember_managed_spawn_attempt(
22842 Some(&request.spawn_spec.idempotency_key),
22843 legacy_same_key.clone(),
22844 Ok(receipt.clone()),
22845 Instant::now(),
22846 );
22847 assert!(shared
22848 .replay_managed_spawn(&legacy_same_key, Instant::now())
22849 .unwrap()
22850 .is_none());
22851 drop(shared);
22852
22853 let mut loaded = session_registry::load(
22854 Some(&state_path),
22855 &NodeId::new("node-terminal-test").unwrap(),
22856 )
22857 .unwrap();
22858 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22859 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22860 *reopened
22861 .managed_worktrees
22862 .lock()
22863 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22864 ManagedWorktreeRegistry::from_records(
22865 std::mem::take(&mut loaded.managed_worktrees),
22866 std::mem::take(&mut loaded.managed_worktree_tombstones),
22867 )
22868 .unwrap();
22869 *reopened
22870 .managed_spawn_replays
22871 .lock()
22872 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22873 .managed_spawn_replays
22874 .into_iter()
22875 .map(|record| (record.idempotency_key.clone(), record))
22876 .collect();
22877 let lease_count_before = reopened
22878 .managed_worktrees
22879 .lock()
22880 .unwrap_or_else(|poisoned| poisoned.into_inner())
22881 .records()
22882 .len();
22883
22884 assert_eq!(
22885 reopened
22886 .spawn_managed_worktree_v2(request.clone())
22887 .await
22888 .unwrap(),
22889 receipt,
22890 );
22891 assert_eq!(
22892 reopened
22893 .managed_worktrees
22894 .lock()
22895 .unwrap_or_else(|poisoned| poisoned.into_inner())
22896 .records()
22897 .len(),
22898 lease_count_before,
22899 );
22900 let mut changed = request;
22901 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22902 let failure = reopened
22903 .spawn_managed_worktree_v2(changed)
22904 .await
22905 .unwrap_err();
22906 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22907 assert_eq!(
22908 reopened
22909 .managed_worktrees
22910 .lock()
22911 .unwrap_or_else(|poisoned| poisoned.into_inner())
22912 .records()
22913 .len(),
22914 lease_count_before,
22915 );
22916 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22917 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22918 let failure = reopened
22919 .spawn_managed_worktree_v2(new_key)
22920 .await
22921 .unwrap_err();
22922 assert_eq!(
22923 failure.code,
22924 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22925 );
22926 assert_eq!(
22927 reopened
22928 .managed_worktrees
22929 .lock()
22930 .unwrap_or_else(|poisoned| poisoned.into_inner())
22931 .records()
22932 .len(),
22933 lease_count_before,
22934 );
22935 assert_eq!(
22936 reopened
22937 .managed_spawn_replays
22938 .lock()
22939 .unwrap_or_else(|poisoned| poisoned.into_inner())
22940 .len(),
22941 1,
22942 );
22943 std::fs::remove_dir_all(root).unwrap();
22944 }
22945
22946 #[test]
22947 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22948 let shared = terminal_test_shared();
22949 let request = ManagedWorktreeSpawnRequest {
22950 spawn_spec: spawn_spec_fixture(),
22951 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22952 };
22953 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22954 shared.remember_managed_spawn(
22955 request.clone(),
22956 Err(expected.clone()),
22957 Instant::now(),
22958 );
22959
22960 assert_eq!(
22961 shared
22962 .replay_managed_spawn(&request, Instant::now())
22963 .unwrap()
22964 .unwrap(),
22965 Err(expected),
22966 );
22967 assert!(shared
22968 .managed_spawn_replays
22969 .lock()
22970 .unwrap_or_else(|poisoned| poisoned.into_inner())
22971 .is_empty());
22972 }
22973
22974 #[tokio::test]
22975 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22976 let shared = terminal_test_shared();
22977 let lease = install_managed_lease(&shared);
22978 let raw = shared.spawn_session(
22979 lease.workspace_id.clone(),
22980 agent("claude"),
22981 SessionMode::Pty,
22982 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22983 None,
22984 ).await.unwrap_err();
22985 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22986 let mut spec = spawn_spec_fixture();
22987 spec.target.workspace_id = lease.workspace_id.clone();
22988 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22989 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22990 let mut selected = spawn_spec_fixture();
22991 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22992 selected.target.worktree_id = Some(lease.workspace_id);
22993 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22994 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22995 assert!(shared.handle.snapshot().sessions.is_empty());
22996 }
22997
22998 #[test]
22999 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23000 let shared = terminal_test_shared();
23001 let lease = install_managed_lease(&shared);
23002 let snapshot = snapshot_for_wire(
23003 &shared, true, true, true, false, true, true, false, false, true,
23004 );
23005 assert!(snapshot.managed_worktrees.is_empty());
23006 assert!(snapshot.workspaces.iter().all(|workspace| {
23007 workspace.managed_worktree_profiles.is_none()
23008 }));
23009 let mut reply = ResponseEnvelope {
23010 request_id: 1,
23011 result: Ok(NodeResponse::Resync {
23012 event_sequence: 1,
23013 oldest_available_sequence: 1,
23014 snapshot: shared.snapshot(),
23015 events: vec![NodeEventEnvelope {
23016 sequence: 1,
23017 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23018 }],
23019 }),
23020 };
23021 project_response_without_managed_worktrees(&mut reply);
23022 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23023 panic!("projection changed the response kind");
23024 };
23025 assert!(snapshot.managed_worktrees.is_empty());
23026 assert!(events.is_empty());
23027 let mut git = git_snapshot_for_parser();
23028 git.branch = Some(lease.branch.clone());
23029 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23030 lease_id: lease.lease_id.clone(),
23031 source_workspace_id: lease.source_workspace_id.clone(),
23032 branch: lease.branch.clone(),
23033 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23034 active_session_count: 1,
23035 managed_record_count: 0,
23036 });
23037 let mut inspection_reply = ResponseEnvelope {
23038 request_id: 2,
23039 result: Ok(NodeResponse::WorkspaceInspected {
23040 inspection: WorkspaceInspection {
23041 workspace_id: lease.workspace_id.clone(),
23042 entries: Vec::new(),
23043 tree_truncated: false,
23044 git,
23045 truncation: None,
23046 },
23047 }),
23048 };
23049 project_response_without_managed_worktrees(&mut inspection_reply);
23050 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23051 panic!("projection changed the inspection response kind");
23052 };
23053 assert!(inspection.git.managed_worktree.is_none());
23054 let failure = managed_git_worktree_failure(GitWorktreeError {
23055 kind: GitWorktreeErrorKind::Failed,
23056 message: r"git failed at C:\private\managed-target".to_owned(),
23057 });
23058 assert!(!failure.message.contains("private"));
23059 assert!(!failure.message.contains("managed-target"));
23060 }
23061
23062 #[tokio::test]
23063 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23064 let root = temporary_workspace_root("managed-restart");
23065 let source = root.join("source");
23066 let allocation = root.join("allocation");
23067 std::fs::create_dir_all(&source).unwrap();
23068 std::fs::create_dir_all(&allocation).unwrap();
23069 run_test_git(&source, &["init"]);
23070 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23071 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23072 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23073 run_test_git(&source, &["add", "seed.txt"]);
23074 run_test_git(&source, &["commit", "-m", "seed"]);
23075 let profile = ManagedWorktreeProfile::new(
23076 WorktreeProfileId::new("default").unwrap(),
23077 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23078 &allocation,
23079 "gate4agent",
23080 "HEAD",
23081 ManagedWorktreeRetention::Retain,
23082 ).unwrap();
23083 let workspace = WorkspaceConfig::new(
23084 WorkspaceId::new("primary").unwrap(),
23085 &source,
23086 ).unwrap()
23087 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23088 .with_managed_worktree_profile(profile.clone()).unwrap();
23089 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23090 let source_text = workspace.canonical_root().to_owned();
23091 let target_text = target.to_string_lossy().into_owned();
23092 let created = create_git_worktree(
23093 &source_text,
23094 &target_text,
23095 "gate4agent/mw-restart",
23096 Some("HEAD"),
23097 ).await.unwrap();
23098 let lease = ManagedWorktreeLeaseRecord {
23099 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23100 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23101 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23102 profile_id: profile.profile_id().clone(),
23103 profile_revision: profile.revision().clone(),
23104 target_root: created.path.clone(),
23105 branch: "gate4agent/mw-restart".to_owned(),
23106 base_commit: created.head.clone(),
23107 expected_head: None,
23108 retention: ManagedWorktreeRetention::Retain,
23109 state: ManagedWorktreeLeaseState::Allocating,
23110 session_holders: Vec::new(),
23111 record_holders: Vec::new(),
23112 cleanup_failure: None,
23113 created_at_unix_ms: 1,
23114 updated_at_unix_ms: 1,
23115 };
23116 let catalog = active_registry().unwrap();
23117 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23118 let shared = NodeShared::new_with_incarnation(
23119 handle,
23120 "fixture-token".to_owned(),
23121 NodeId::new("node-managed-restart").unwrap(),
23122 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23123 vec![workspace],
23124 vec![agent("claude")],
23125 ProviderRuntimeStatuses::default(),
23126 None,
23127 Vec::new(),
23128 Vec::new(),
23129 SpawnProfileRegistry::default(),
23130 None,
23131 None,
23132 None,
23133 None,
23134 Vec::new(),
23135 vec![lease.clone()],
23136 Vec::new(),
23137 Vec::new(),
23138 None,
23139 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23140 );
23141 shared.reconcile_managed_worktrees().await;
23142 let retained = shared.managed_worktrees.lock()
23143 .unwrap_or_else(|poisoned| poisoned.into_inner())
23144 .get(&lease.lease_id).unwrap().clone();
23145 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23146 assert!(shared.workspaces.read()
23147 .unwrap_or_else(|poisoned| poisoned.into_inner())
23148 .contains_key(&lease.workspace_id));
23149 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23150 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23151 assert!(!Path::new(&created.path).exists());
23152 std::fs::remove_dir_all(root).unwrap();
23153 }
23154
23155 fn run_test_git(root: &Path, arguments: &[&str]) {
23156 let output = std::process::Command::new("git")
23157 .arg("-C").arg(root).args(arguments).output().unwrap();
23158 assert!(
23159 output.status.success(),
23160 "git {:?} failed: {}",
23161 arguments,
23162 String::from_utf8_lossy(&output.stderr),
23163 );
23164 }
23165
23166 #[tokio::test]
23167 async fn spawn_spec_validation_fails_before_session_mutation() {
23168 let shared = terminal_test_shared();
23169 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23170 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23171 }));
23172 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23173 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23174 }));
23175 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23176 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23177 }));
23178
23179 let mut target_mismatch = spawn_spec_fixture();
23180 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23181 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23182 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23183
23184 let mut unknown_profile = spawn_spec_fixture();
23185 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23186 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23187 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23188 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23189
23190 let mut revision_mismatch = spawn_spec_fixture();
23191 revision_mismatch.idempotency_key =
23192 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23193 revision_mismatch.expected_profile_revision =
23194 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23195 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23196 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23197
23198 let mut unsupported_materializer = spawn_spec_fixture();
23199 unsupported_materializer.idempotency_key =
23200 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23201 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23202 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23203 };
23204 let failure = shared
23205 .spawn_from_spec(unsupported_materializer)
23206 .await
23207 .unwrap_err();
23208 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23209
23210 let failure = shared
23211 .spawn_session_with_deadline(
23212 WorkspaceId::new("primary").unwrap(),
23213 agent("claude"),
23214 SessionMode::Pty,
23215 gate4agent_types::TerminalSize {
23216 rows: 24,
23217 columns: 80,
23218 },
23219 None,
23220 None,
23221 None,
23222 None,
23223 None,
23224 None,
23225 SpawnRecordPolicy::ProviderIdentityOnly,
23226 Some(Instant::now()),
23227 &[],
23228 None,
23229 ApprovalLevel::default(),
23230 None,
23231 None,
23232 )
23233 .await
23234 .unwrap_err();
23235 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23236 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23237 assert!(shared.handle.snapshot().sessions.is_empty());
23238 }
23239
23240 #[test]
23241 fn worktree_selection_requires_its_separate_negotiated_capability() {
23242 let mut spec = spawn_spec_fixture();
23243 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23244 let request = NodeRequest::SpawnSpec { spec };
23245 let spawn_spec_capability =
23246 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23247 assert!(request_uses_unnegotiated_capability(
23248 &request,
23249 &[spawn_spec_capability.clone()],
23250 ));
23251 let worktree_capability =
23252 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23253 assert!(request_uses_unnegotiated_capability(
23254 &request,
23255 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23256 ));
23257 let profile_revision_capability =
23258 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23259 assert!(request_uses_unnegotiated_capability(
23260 &request,
23261 &[
23262 spawn_spec_capability.clone(),
23263 worktree_capability.clone(),
23264 profile_revision_capability.clone(),
23265 ],
23266 ));
23267 let bundle_materialization_capability =
23274 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23275 assert!(request_uses_unnegotiated_capability(
23276 &request,
23277 &[
23278 spawn_spec_capability.clone(),
23279 worktree_capability.clone(),
23280 profile_revision_capability.clone(),
23281 bundle_materialization_capability.clone(),
23282 ],
23283 ));
23284 let history_context_pack_capability =
23285 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23286 assert!(!request_uses_unnegotiated_capability(
23287 &request,
23288 &[
23289 spawn_spec_capability,
23290 worktree_capability,
23291 profile_revision_capability,
23292 bundle_materialization_capability,
23293 history_context_pack_capability,
23294 ],
23295 ));
23296 }
23297
23298 #[tokio::test]
23299 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23300 let mut shared = terminal_test_shared();
23301 let spec = spawn_spec_fixture();
23302 let receipt = shared
23303 .resolve_spawn_spec(&spec)
23304 .unwrap()
23305 .receipt(shared.incarnation_id, terminal_address(1));
23306 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23307
23308 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23309 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23310 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23311
23312 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23313
23314 let mut revision_conflicting = spec.clone();
23315 revision_conflicting.expected_profile_revision =
23316 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23317 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23318 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23319
23320 let mut stale_cache_miss = spec.clone();
23321 stale_cache_miss.idempotency_key =
23322 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23323 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23324 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23325
23326 let mut conflicting = spec;
23327 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23328 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23329 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23330 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23331 assert!(shared.handle.snapshot().sessions.is_empty());
23332 }
23333
23334 #[tokio::test]
23335 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23336 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23337 let mut spec = spawn_spec_fixture();
23338 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23339 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23340 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23341
23342 let failure = shared
23343 .create_worktree(
23344 WorkspaceId::new("primary").unwrap(),
23345 WorkspaceId::new("new-worktree").unwrap(),
23346 "not-an-absolute-path".to_owned(),
23347 "invalid branch".to_owned(),
23348 None,
23349 )
23350 .await
23351 .unwrap_err();
23352 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23353
23354 let failure = shared
23355 .remove_worktree(
23356 WorkspaceId::new("primary").unwrap(),
23357 "not-an-absolute-path".to_owned(),
23358 )
23359 .await
23360 .unwrap_err();
23361 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23362 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23363 assert!(shared.handle.snapshot().sessions.is_empty());
23364 }
23365
23366 #[tokio::test]
23367 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23368 let root = temporary_workspace_root("manual-worktree-compensation");
23369 let source = root.join("source");
23370 let target = root.join("target");
23371 let invalid_state_parent = root.join("state-parent-is-a-file");
23372 let invalid_state_path = invalid_state_parent.join("state.json");
23373 std::fs::create_dir_all(&source).unwrap();
23374 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23375 run_test_git(&source, &["init"]);
23376 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23377 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23378 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23379 run_test_git(&source, &["add", "seed.txt"]);
23380 run_test_git(&source, &["commit", "-m", "seed"]);
23381
23382 let catalog = active_registry().unwrap();
23383 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23384 let workspace = WorkspaceConfig::new(
23385 WorkspaceId::new("primary").unwrap(),
23386 &source,
23387 ).unwrap();
23388 let mut shared = NodeShared::new(
23389 handle,
23390 "fixture-token".to_owned(),
23391 NodeId::new("node-worktree-compensation").unwrap(),
23392 vec![workspace],
23393 vec![agent("claude")],
23394 );
23395 shared.state_path = Some(invalid_state_path);
23396
23397 let error = shared.create_worktree(
23398 WorkspaceId::new("primary").unwrap(),
23399 WorkspaceId::new("compensated").unwrap(),
23400 target.to_string_lossy().into_owned(),
23401 "gate4agent/compensated".to_owned(),
23402 Some("HEAD".to_owned()),
23403 ).await.unwrap_err();
23404
23405 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23406 assert!(!target.exists());
23407 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23408 .iter()
23409 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23410 std::fs::remove_dir_all(root).unwrap();
23411 }
23412
23413 #[test]
23414 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23415 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23416 NativeGitWorktreeSnapshot {
23417 path: path.to_owned(),
23418 head: "0123456789abcdef".to_owned(),
23419 branch: Some("refs/heads/test".to_owned()),
23420 is_bare,
23421 is_main,
23422 locked: false,
23423 lock_reason: None,
23424 prunable: false,
23425 prunable_reason: None,
23426 workspace_id: None,
23427 }
23428 }
23429
23430 let source = r"C:\repo";
23431 let selected = r"C:\repo-worktree";
23432 let listed = vec![
23433 worktree(source, true, false),
23434 worktree(selected, false, false),
23435 ];
23436 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23437
23438 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23439 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23440
23441 let bare = vec![
23442 worktree(source, true, false),
23443 worktree(selected, false, true),
23444 ];
23445 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23446 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23447
23448 let foreign_failure =
23449 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23450 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23451 }
23452
23453 fn terminal_frame(sequence: u64) -> TerminalFrame {
23454 TerminalFrame {
23455 sequence,
23456 size: gate4agent_types::TerminalSize {
23457 rows: 24,
23458 columns: 80,
23459 },
23460 cursor_row: 2,
23461 cursor_column: 3,
23462 contents: format!("frame-{sequence}"),
23463 formatted: vec![1, 2, 3],
23464 scrollback_formatted: Vec::new(),
23465 alternate_screen: false,
23466 mouse_protocol_enabled: false,
23467 mouse_protocol_encoding: Default::default(),
23468 produced_at_unix_ms: 0,
23469 screen_state: gate4agent_types::PtyScreenState::default(),
23470 bracketed_paste: None,
23471 }
23472 }
23473
23474 #[test]
23475 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23476 let shared = terminal_test_shared();
23477 let mut receiver = shared.terminal_event_tx.subscribe();
23478 let original = terminal_address(1);
23479
23480 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23481 let first = receiver.try_recv().unwrap();
23482 assert_eq!(first.len(), 1);
23483 assert!(matches!(
23484 &first[0].event,
23485 NodeEvent::TerminalFrame { address, frame }
23486 if address == &original && frame.sequence == 7
23487 ));
23488
23489 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23490 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23491 assert!(matches!(
23492 receiver.try_recv(),
23493 Err(broadcast::error::TryRecvError::Empty)
23494 ));
23495
23496 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23497 let advanced = receiver.try_recv().unwrap();
23498 assert_eq!(advanced[0].sequence, 2);
23499
23500 let resumed = terminal_address(2);
23501 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23502 let reset = receiver.try_recv().unwrap();
23503 assert_eq!(reset[0].sequence, 3);
23504 assert!(matches!(
23505 &reset[0].event,
23506 NodeEvent::TerminalFrame { address, frame }
23507 if address == &resumed && frame.sequence == 1
23508 ));
23509 shared.clear_terminal_frame_watermark(&resumed);
23510 assert!(!shared
23511 .terminal_frame_watermarks
23512 .lock()
23513 .unwrap_or_else(|poisoned| poisoned.into_inner())
23514 .contains_key(&resumed.session.instance_id));
23515 }
23516
23517 #[test]
23518 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23519 let shared = terminal_test_shared();
23520 let mut receiver = shared.terminal_event_tx.subscribe();
23521 let address = terminal_address(1);
23522 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23523
23524 for sequence in 1..=count {
23525 shared.publish_terminal_frame_candidates(vec![(
23526 address.clone(),
23527 terminal_frame(sequence),
23528 )]);
23529 }
23530
23531 let history = shared
23532 .history
23533 .lock()
23534 .unwrap_or_else(|poisoned| poisoned.into_inner());
23535 assert!(history.events.is_empty());
23536 assert_eq!(history.last_sequence, count);
23537 assert_eq!(history.replay_floor_sequence, 1);
23538 drop(history);
23539 let NodeResponse::Resync {
23540 event_sequence,
23541 oldest_available_sequence,
23542 events,
23543 ..
23544 } = shared.resync(0) else {
23545 unreachable!("resync helper returned another response")
23546 };
23547 assert_eq!(event_sequence, count);
23548 assert_eq!(oldest_available_sequence, 1);
23549 assert!(events.is_empty());
23550 assert!(matches!(
23551 resync_required_event(&shared).event,
23552 NodeEvent::ResyncRequired {
23553 oldest_available_sequence: 1,
23554 }
23555 ));
23556 assert!(matches!(
23557 receiver.try_recv(),
23558 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23559 ));
23560 let latest = receiver.try_recv().unwrap();
23561 assert_eq!(latest.len(), 1);
23562 assert_eq!(latest[0].sequence, count);
23563 }
23564
23565 #[test]
23566 fn real_durable_eviction_advances_authoritative_replay_floor() {
23567 let shared = terminal_test_shared();
23568 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23569 shared.publish(NodeEvent::ControllerChanged { controller: None });
23570 }
23571
23572 let NodeResponse::Resync {
23573 event_sequence,
23574 oldest_available_sequence,
23575 events,
23576 ..
23577 } = shared.resync(0) else {
23578 unreachable!("resync helper returned another response")
23579 };
23580 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23581 assert_eq!(oldest_available_sequence, 2);
23582 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23583 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23584 assert!(matches!(
23585 resync_required_event(&shared).event,
23586 NodeEvent::ResyncRequired {
23587 oldest_available_sequence: 2,
23588 }
23589 ));
23590 }
23591
23592 #[test]
23593 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23594 let shared = terminal_test_shared();
23595 let without_capability = terminal_event_subscription(&shared, false);
23596 let with_capability = terminal_event_subscription(&shared, true);
23597 assert!(without_capability.is_none());
23598 assert!(with_capability.is_some());
23599 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23600 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23601 }));
23602 }
23603
23604 #[test]
23605 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23606 let mut pending = Vec::new();
23607 let mut event_sequence = 0_u64;
23608 for frame_sequence in 1..=4 {
23609 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23610 event_sequence += 1;
23611 let mut address = terminal_address(1);
23612 address.session.instance_id = AgentInstanceId(instance);
23613 queue_connection_event(
23614 &mut pending,
23615 NodeEventEnvelope {
23616 sequence: event_sequence,
23617 event: NodeEvent::TerminalFrame {
23618 address,
23619 frame: terminal_frame(frame_sequence),
23620 },
23621 },
23622 0,
23623 );
23624 }
23625 }
23626 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23627 pending.sort_unstable_by_key(|event| event.sequence);
23628
23629 let mut uninterrupted = pending.clone();
23630 let first_burst = connection_event_burst_len(uninterrupted.len());
23631 uninterrupted.drain(..first_burst);
23632 let second_burst = connection_event_burst_len(uninterrupted.len());
23633 uninterrupted.drain(..second_burst);
23634 assert!(uninterrupted.is_empty());
23635
23636 let (frame_tx, mut frame_rx) =
23637 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23638 frame_tx
23639 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23640 request_id: 77,
23641 request: NodeRequest::Snapshot,
23642 })))
23643 .unwrap();
23644 let burst_len = connection_event_burst_len(pending.len());
23645 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23646 pending.drain(..burst_len);
23647 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23648 assert!(matches!(
23649 frame_rx.try_recv(),
23650 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23651 request_id: 77,
23652 request: NodeRequest::Snapshot,
23653 })))
23654 ));
23655 }
23656
23657 #[test]
23658 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23659 let mut pending = Vec::new();
23660 for sequence in [7_u64, 8] {
23661 let mut address = terminal_address(1);
23662 address.session.instance_id = AgentInstanceId(sequence);
23663 queue_connection_event(
23664 &mut pending,
23665 NodeEventEnvelope {
23666 sequence,
23667 event: NodeEvent::TerminalFrame {
23668 address,
23669 frame: terminal_frame(sequence),
23670 },
23671 },
23672 0,
23673 );
23674 }
23675 let mut discard_through = 0;
23676 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23677 assert!(pending.is_empty());
23678
23679 let replayed = NodeEventEnvelope {
23680 sequence: 8,
23681 event: NodeEvent::TerminalFrame {
23682 address: terminal_address(1),
23683 frame: terminal_frame(8),
23684 },
23685 };
23686 let advanced = NodeEventEnvelope {
23687 sequence: 9,
23688 event: NodeEvent::TerminalFrame {
23689 address: terminal_address(2),
23690 frame: terminal_frame(1),
23691 },
23692 };
23693 queue_connection_event_batch(
23694 &mut pending,
23695 &[replayed, advanced.clone()],
23696 discard_through,
23697 );
23698 assert_eq!(pending, vec![advanced]);
23699 }
23700
23701 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23702 ManagedSessionRecord {
23703 record_id: SessionRecordId::new(record_id).unwrap(),
23704 display_name: format!("{provider} record"),
23705 provider: agent(provider),
23706 mode: SessionMode::Pty,
23707 state: ManagedSessionState::Unavailable,
23708 workspace_id: WorkspaceId::new("primary").unwrap(),
23709 canonical_root: opaque_windows_path(
23710 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23711 ),
23712 provider_session: None,
23713 active_session: None,
23714 environment_profile: None,
23715 bundle: None,
23716 context_id: None,
23717 context: None,
23718 exported_context: None,
23719 task_binding: None,
23720 created_at_unix_ms: 1,
23721 updated_at_unix_ms: 1,
23722 last_error: None,
23723 }
23724 }
23725
23726 fn retention_test_record(
23727 workspace_id: &str,
23728 record_id: &str,
23729 state: ManagedSessionState,
23730 updated_at_unix_ms: u64,
23731 ) -> ManagedSessionRecord {
23732 ManagedSessionRecord {
23733 record_id: SessionRecordId::new(record_id).unwrap(),
23734 display_name: format!("{record_id} display"),
23735 provider: agent("claude"),
23736 mode: SessionMode::Pty,
23737 state,
23738 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23739 canonical_root: opaque_windows_path(
23740 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23741 ),
23742 provider_session: None,
23743 active_session: None,
23744 environment_profile: None,
23745 bundle: None,
23746 context_id: None,
23747 context: None,
23748 exported_context: None,
23749 task_binding: None,
23750 created_at_unix_ms: updated_at_unix_ms,
23751 updated_at_unix_ms,
23752 last_error: None,
23753 }
23754 }
23755
23756 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23764 vec![
23765 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23766 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23767 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23768 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23769 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23770 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23771 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23772 retention_test_record(
23773 "workspace-a",
23774 "decoy-pending",
23775 ManagedSessionState::IdentityPending,
23776 now - 500_000,
23777 ),
23778 retention_test_record(
23779 "workspace-a",
23780 "decoy-dormant",
23781 ManagedSessionState::Dormant,
23782 now - 500_000,
23783 ),
23784 ]
23785 }
23786
23787 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23788 let root = temporary_workspace_root("session-record-retention");
23789 std::fs::create_dir_all(&root).unwrap();
23790 let catalog = active_registry().unwrap();
23791 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23792 let workspace_a =
23793 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23794 let workspace_b =
23795 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23796 let mut shared = NodeShared::new(
23797 handle,
23798 "fixture-token".to_owned(),
23799 NodeId::new("node-retention-test").unwrap(),
23800 vec![workspace_a, workspace_b],
23801 vec![agent("claude")],
23802 );
23803 shared.state_path = Some(root.join("node-state-v10.json"));
23804 let now = unix_time_ms();
23805 for record in retention_fixture_records(now) {
23806 shared.insert_record(record).unwrap();
23807 }
23808 shared
23809 .persist_state_locked()
23810 .expect("fixture-only, empty-history persist against a fresh temp state path");
23811 (shared, runtime, root, now)
23812 }
23813
23814 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23815 shared
23816 .history
23817 .lock()
23818 .unwrap_or_else(|poisoned| poisoned.into_inner())
23819 .events
23820 .iter()
23821 .filter_map(|envelope| match &envelope.event {
23822 NodeEvent::SessionRecordRemoved { record_id } => {
23823 Some(record_id.as_str().to_owned())
23824 }
23825 _ => None,
23826 })
23827 .collect()
23828 }
23829
23830 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23831 shared
23832 .session_records
23833 .lock()
23834 .unwrap_or_else(|poisoned| poisoned.into_inner())
23835 .records
23836 .keys()
23837 .map(|record_id| record_id.as_str().to_owned())
23838 .collect()
23839 }
23840
23841 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23842 values.iter().map(|value| (*value).to_owned()).collect()
23843 }
23844
23845 #[tokio::test]
23846 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23847 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23848 shared.session_record_retention = SessionRecordRetentionConfig {
23849 age_ms: 0,
23850 keep_per_workspace: 1,
23851 };
23852 shared.retire_unavailable_session_records().await;
23853
23854 assert_eq!(
23855 retention_remaining_record_ids(&shared),
23856 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23857 );
23858 assert_eq!(
23859 retention_removed_record_ids(&shared),
23860 owned(&["a1", "a2", "b1", "b2"]),
23861 );
23862 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23863
23864 drop(shared);
23865 drop(runtime);
23866 std::fs::remove_dir_all(root).unwrap();
23867 }
23868
23869 #[tokio::test]
23875 async fn session_record_retention_both_set_retires_only_the_intersection() {
23876 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23877 shared.session_record_retention = SessionRecordRetentionConfig {
23878 age_ms: 250_000,
23879 keep_per_workspace: 1,
23880 };
23881 shared.retire_unavailable_session_records().await;
23882
23883 assert_eq!(
23884 retention_remaining_record_ids(&shared),
23885 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23886 );
23887 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23888 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23889
23890 drop(shared);
23891 drop(runtime);
23892 std::fs::remove_dir_all(root).unwrap();
23893 }
23894
23895 #[tokio::test]
23896 async fn session_record_retention_both_disabled_is_a_no_op() {
23897 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23898 shared.session_record_retention = SessionRecordRetentionConfig::default();
23899 shared.retire_unavailable_session_records().await;
23900
23901 assert_eq!(
23902 retention_remaining_record_ids(&shared),
23903 owned(&[
23904 "a1", "a2", "a3", "b1", "b2", "b3",
23905 "decoy-live", "decoy-pending", "decoy-dormant",
23906 ]),
23907 );
23908 assert!(retention_removed_record_ids(&shared).is_empty());
23909 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23910
23911 drop(shared);
23912 drop(runtime);
23913 std::fs::remove_dir_all(root).unwrap();
23914 }
23915
23916 #[test]
23917 fn session_record_context_export_selects_one_exact_history_identity() {
23918 let candidates = vec![
23919 HistoryCandidateSummary {
23920 id: "candidate-other".to_owned(),
23921 session_id_hint: "provider-other".to_owned(),
23922 modified_at_unix_ms: Some(1),
23923 },
23924 HistoryCandidateSummary {
23925 id: "candidate-exact".to_owned(),
23926 session_id_hint: "provider-exact".to_owned(),
23927 modified_at_unix_ms: Some(2),
23928 },
23929 ];
23930 assert_eq!(
23931 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23932 "candidate-exact",
23933 );
23934 }
23935
23936 #[test]
23937 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23938 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23939 id: id.to_owned(),
23940 session_id_hint: hint.to_owned(),
23941 modified_at_unix_ms: None,
23942 };
23943 let missing = select_history_candidate(
23944 &[candidate("candidate-other", "provider-other")],
23945 Some("provider-exact"),
23946 )
23947 .unwrap_err();
23948 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23949 let ambiguous = select_history_candidate(
23950 &[
23951 candidate("candidate-a", "provider-exact"),
23952 candidate("candidate-b", "provider-exact"),
23953 ],
23954 Some("provider-exact"),
23955 )
23956 .unwrap_err();
23957 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23958 }
23959
23960 #[test]
23968 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23969 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23970 id: id.to_owned(),
23971 session_id_hint: hint.to_owned(),
23972 modified_at_unix_ms: None,
23973 };
23974 assert_eq!(
23975 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23976 .unwrap(),
23977 "candidate-sole",
23978 );
23979 let missing = select_history_candidate(&[], None).unwrap_err();
23980 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23981 let ambiguous = select_history_candidate(
23982 &[
23983 candidate("candidate-a", "sidecar-session-a"),
23984 candidate("candidate-b", "sidecar-session-b"),
23985 ],
23986 None,
23987 )
23988 .unwrap_err();
23989 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23990 }
23991
23992 #[test]
23993 fn session_record_context_export_revalidation_rejects_record_change() {
23994 let session = terminal_address(1);
23995 let identity = ProviderSessionIdentity {
23996 key: ProviderSessionKey::SessionId,
23997 id: "provider-exact".to_owned(),
23998 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23999 };
24000 let mut expected = record("claude", "record-context-export");
24001 expected.state = ManagedSessionState::Live;
24002 expected.provider_session = Some(identity.clone());
24003 expected.active_session = Some(session.clone());
24004 let binding = SessionBinding {
24005 workspace_id: session.workspace_id.clone(),
24006 generation: session.session.generation,
24007 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24008 pending_resume: None,
24009 record_id: Some(expected.record_id.clone()),
24010 managed_worktree_lease_id: None,
24011 environment_profile: None,
24012 bundle: None,
24013 context: None,
24014 materialization_id: None,
24015 };
24016 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24017 assert!(session_record_context_export_binding_is_exact(
24018 &expected,
24019 &expected,
24020 Some(&identity),
24021 &session,
24022 &binding,
24023 &expected.provider,
24024 ¤t_root,
24025 false,
24026 ));
24027 let mut changed = expected.clone();
24028 changed.updated_at_unix_ms += 1;
24029 assert!(!session_record_context_export_binding_is_exact(
24030 &expected,
24031 &changed,
24032 Some(&identity),
24033 &session,
24034 &binding,
24035 &expected.provider,
24036 ¤t_root,
24037 false,
24038 ));
24039 assert!(!session_record_context_export_binding_is_exact(
24044 &expected,
24045 &changed,
24046 Some(&identity),
24047 &session,
24048 &binding,
24049 &expected.provider,
24050 ¤t_root,
24051 true,
24052 ));
24053 let mut detached = expected.clone();
24054 detached.state = ManagedSessionState::Dormant;
24055 detached.active_session = None;
24056 detached.updated_at_unix_ms += 1;
24057 assert!(!session_record_context_export_binding_is_exact(
24058 &expected,
24059 &detached,
24060 Some(&identity),
24061 &session,
24062 &binding,
24063 &expected.provider,
24064 ¤t_root,
24065 false,
24066 ));
24067 assert!(session_record_context_export_binding_is_exact(
24068 &expected,
24069 &detached,
24070 Some(&identity),
24071 &session,
24072 &binding,
24073 &expected.provider,
24074 ¤t_root,
24075 true,
24076 ));
24077 }
24078
24079 #[test]
24086 fn session_record_context_export_binding_is_exact_without_identity() {
24087 let session = terminal_address(1);
24088 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24089 expected.state = ManagedSessionState::Live;
24090 expected.active_session = Some(session.clone());
24091 let binding = SessionBinding {
24092 workspace_id: session.workspace_id.clone(),
24093 generation: session.session.generation,
24094 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24095 pending_resume: None,
24096 record_id: Some(expected.record_id.clone()),
24097 managed_worktree_lease_id: None,
24098 environment_profile: None,
24099 bundle: None,
24100 context: None,
24101 materialization_id: None,
24102 };
24103 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24104 assert!(session_record_context_export_binding_is_exact(
24105 &expected,
24106 &expected,
24107 None,
24108 &session,
24109 &binding,
24110 &expected.provider,
24111 ¤t_root,
24112 false,
24113 ));
24114 let mut drifted = expected.clone();
24118 drifted.provider_session = Some(ProviderSessionIdentity {
24119 key: ProviderSessionKey::SessionId,
24120 id: "unexpected-identity".to_owned(),
24121 transcript_path: None,
24122 });
24123 assert!(!session_record_context_export_binding_is_exact(
24124 &expected,
24125 &drifted,
24126 None,
24127 &session,
24128 &binding,
24129 &expected.provider,
24130 ¤t_root,
24131 false,
24132 ));
24133 }
24134
24135 #[test]
24136 fn session_record_context_export_preflight_requires_live_usable_source() {
24137 let mut source = record("claude", "record-context-export-preflight");
24138 source.state = ManagedSessionState::Live;
24139 assert!(session_record_context_export_source_is_usable(
24140 &source,
24141 &SessionStatus::Running,
24142 false,
24143 ));
24144 assert!(session_record_context_export_source_is_usable(
24145 &source,
24146 &SessionStatus::Exited { exit_code: Some(0) },
24147 false,
24148 ));
24149
24150 source.state = ManagedSessionState::Dormant;
24151 assert!(!session_record_context_export_source_is_usable(
24152 &source,
24153 &SessionStatus::Running,
24154 false,
24155 ));
24156 assert!(session_record_context_export_source_is_usable(
24160 &source,
24161 &SessionStatus::Exited { exit_code: Some(0) },
24162 true,
24163 ));
24164 assert!(!session_record_context_export_source_is_usable(
24165 &source,
24166 &SessionStatus::Exited { exit_code: Some(0) },
24167 false,
24168 ));
24169 source.state = ManagedSessionState::Live;
24170 assert!(!session_record_context_export_source_is_usable(
24171 &source,
24172 &SessionStatus::Starting,
24173 false,
24174 ));
24175 }
24176
24177 #[test]
24178 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24179 let catalog = active_registry().unwrap();
24180 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24181 let workspace = WorkspaceConfig::new(
24182 WorkspaceId::new("primary").unwrap(),
24183 std::env::current_dir().unwrap(),
24184 )
24185 .unwrap();
24186 let shared = NodeShared::new(
24187 handle,
24188 "fixture-token".to_owned(),
24189 NodeId::new("node-context-export-conflict").unwrap(),
24190 vec![workspace],
24191 vec![agent("claude")],
24192 );
24193 let history = HistorySessionRecord {
24194 session_id: "provider-exact".to_owned(),
24195 title: None,
24196 cwd: None,
24197 model: None,
24198 message_count: 1,
24199 completed_turn_count: None,
24200 total_tokens: 0,
24201 messages: vec![gate4agent_types::HistoryMessageRecord {
24202 role: gate4agent_types::HistoryMessageRole::User,
24203 text: "bounded context".to_owned(),
24204 }],
24205 };
24206 let pack = NodeContextPack::export(
24207 ContextPackLineageReceipt {
24208 source_node_id: shared.node_id.clone(),
24209 source_session: terminal_address(1),
24210 source_provider: agent("claude"),
24211 },
24212 &history,
24213 )
24214 .unwrap();
24215 let context_id = pack.receipt().id.clone();
24216 let session = terminal_address(1);
24217 let identity = ProviderSessionIdentity {
24218 key: ProviderSessionKey::SessionId,
24219 id: history.session_id.clone(),
24220 transcript_path: None,
24221 };
24222 let mut expected = record("claude", "record-context-export-final-conflict");
24223 expected.state = ManagedSessionState::Live;
24224 expected.provider_session = Some(identity.clone());
24225 expected.active_session = Some(session.clone());
24226 let mut changed = expected.clone();
24227 changed.updated_at_unix_ms += 1;
24228 shared.insert_record(changed).unwrap();
24229
24230 let error = shared
24231 .commit_context_pack_for_session_record(
24232 &expected,
24233 Some(&identity),
24234 &session,
24235 "candidate-exact",
24236 &history,
24237 pack,
24238 false,
24239 )
24240 .unwrap_err();
24241
24242 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24243 assert!(shared
24244 .context_catalog
24245 .read()
24246 .unwrap_or_else(|poisoned| poisoned.into_inner())
24247 .get(&context_id)
24248 .is_none());
24249 }
24250
24251 #[tokio::test]
24252 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24253 let root = temporary_workspace_root("session-task-binding");
24254 std::fs::create_dir_all(&root).unwrap();
24255 let state_path = root.join("node-state.json");
24256 let catalog = active_registry().unwrap();
24257 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24258 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24259 let node_id = NodeId::new("node-1").unwrap();
24260 let mut shared = NodeShared::new(
24261 handle,
24262 "fixture-token".to_owned(),
24263 node_id.clone(),
24264 vec![workspace],
24265 vec![agent("claude")],
24266 );
24267 shared.state_path = Some(state_path.clone());
24268 let record = record("claude", "sr-task");
24269 let record_id = record.record_id.clone();
24270 shared.insert_record(record).unwrap();
24271 shared.persist_state().unwrap();
24272 shared
24273 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24274 .unwrap();
24275
24276 let minted = process_request_inner(
24277 &shared,
24278 77,
24279 ClientRole::Operator,
24280 NodeRequest::SetSessionTask {
24281 record_id: record_id.clone(),
24282 expected_revision: 0,
24283 target: SessionTaskTargetV1::New,
24284 },
24285 )
24286 .await
24287 .unwrap();
24288 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24289 panic!("session task mutation returned another response");
24290 };
24291 let minted_binding = minted.task_binding.clone().unwrap();
24292 let minted_task_id = minted_binding.task_id.clone().unwrap();
24293 assert_eq!(minted_binding.revision, 1);
24294 assert!(minted_task_id.as_str().starts_with("task-"));
24295 assert_eq!(minted_task_id.as_str().len(), 29);
24296
24297 let event_count = shared.current_sequence();
24298 let repeated = process_request_inner(
24299 &shared,
24300 77,
24301 ClientRole::Operator,
24302 NodeRequest::SetSessionTask {
24303 record_id: record_id.clone(),
24304 expected_revision: 1,
24305 target: SessionTaskTargetV1::Existing {
24306 task_id: minted_task_id.clone(),
24307 },
24308 },
24309 )
24310 .await
24311 .unwrap();
24312 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24313 assert_eq!(shared.current_sequence(), event_count);
24314
24315 let stale = process_request_inner(
24316 &shared,
24317 77,
24318 ClientRole::Operator,
24319 NodeRequest::SetSessionTask {
24320 record_id: record_id.clone(),
24321 expected_revision: 0,
24322 target: SessionTaskTargetV1::Existing {
24323 task_id: minted_task_id,
24324 },
24325 },
24326 )
24327 .await
24328 .unwrap_err();
24329 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24330
24331 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24332 let assigned = process_request_inner(
24333 &shared,
24334 77,
24335 ClientRole::Operator,
24336 NodeRequest::SetSessionTask {
24337 record_id: record_id.clone(),
24338 expected_revision: 1,
24339 target: SessionTaskTargetV1::Existing {
24340 task_id: assigned_id.clone(),
24341 },
24342 },
24343 )
24344 .await
24345 .unwrap();
24346 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24347 panic!("session task assignment returned another response");
24348 };
24349 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24350 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24351
24352 let cleared = process_request_inner(
24353 &shared,
24354 77,
24355 ClientRole::Operator,
24356 NodeRequest::SetSessionTask {
24357 record_id: record_id.clone(),
24358 expected_revision: 2,
24359 target: SessionTaskTargetV1::Clear,
24360 },
24361 )
24362 .await
24363 .unwrap();
24364 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24365 panic!("session task clear returned another response");
24366 };
24367 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24368 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24369 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24370 assert_eq!(
24371 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24372 cleared.updated_at_unix_ms,
24373 );
24374
24375 let persisted = std::fs::read(&state_path).unwrap();
24376 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24377 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24378 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24379 std::fs::remove_dir_all(root).unwrap();
24380 }
24381
24382 #[test]
24383 fn session_task_capability_strips_snapshots_events_and_replies() {
24384 let mut bound = record("claude", "sr-task-projection");
24385 bound.updated_at_unix_ms = 2;
24386 bound.task_binding = Some(SessionTaskBindingV1 {
24387 revision: 1,
24388 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24389 changed_at_unix_ms: 2,
24390 });
24391 let request = NodeRequest::SetSessionTask {
24392 record_id: bound.record_id.clone(),
24393 expected_revision: 1,
24394 target: SessionTaskTargetV1::Clear,
24395 };
24396 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24397 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24398 }));
24399 assert!(!request_uses_unnegotiated_capability(
24400 &request,
24401 &baseline_capabilities().unwrap(),
24402 ));
24403 assert!(request_uses_unnegotiated_capability(&request, &[]));
24404
24405 let mut snapshot = terminal_test_shared().snapshot();
24406 snapshot.session_records.push(bound.clone());
24407 let mut reply = ResponseEnvelope {
24408 request_id: 1,
24409 result: Ok(NodeResponse::Resync {
24410 event_sequence: 1,
24411 oldest_available_sequence: 1,
24412 snapshot,
24413 events: vec![NodeEventEnvelope {
24414 sequence: 1,
24415 event: NodeEvent::SessionRecordUpserted {
24416 record: bound.clone(),
24417 },
24418 }],
24419 }),
24420 };
24421 project_response_without_session_task_binding(&mut reply);
24422 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24423 panic!("task projection changed the response kind");
24424 };
24425 assert!(snapshot.session_records[0].task_binding.is_none());
24426 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24427 panic!("task projection changed the event kind");
24428 };
24429 assert!(record.task_binding.is_none());
24430
24431 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24432 sequence: 2,
24433 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24434 });
24435 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24436 panic!("task event projection changed the event kind");
24437 };
24438 assert!(record.task_binding.is_none());
24439
24440 let mut direct = ResponseEnvelope {
24441 request_id: 2,
24442 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24443 };
24444 project_response_without_session_task_binding(&mut direct);
24445 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24446 panic!("task reply projection changed the response kind");
24447 };
24448 assert!(record.task_binding.is_none());
24449 }
24450
24451 #[test]
24452 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24453 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24454 let workspace = WorkspaceConfig::new(
24455 WorkspaceId::new("primary").unwrap(),
24456 std::env::current_dir().unwrap(),
24457 )
24458 .unwrap();
24459 let config = NodeServerConfig::new(
24460 DEFAULT_NODE_ENDPOINT,
24461 "fixture-token",
24462 NodeId::new("node-1").unwrap(),
24463 [workspace],
24464 )
24465 .unwrap();
24466 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24467 }
24468
24469 #[cfg(all(feature = "fixture", windows))]
24470 #[test]
24471 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24472 let root = temporary_workspace_root("provider-bundle-argv-hold");
24473 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24474 std::fs::create_dir_all(&root).unwrap();
24475 std::fs::create_dir_all(&outside).unwrap();
24476 let proof_path = root.join("provider.proof");
24477 let release_signal = root.join("release.signal");
24478 let config = NodeServerConfig::new(
24479 format!(
24480 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24481 std::process::id(),
24482 ),
24483 "fixture-token",
24484 NodeId::new("provider-bundle-hold-node").unwrap(),
24485 [WorkspaceConfig::new(
24486 WorkspaceId::new("primary").unwrap(),
24487 std::env::current_dir().unwrap(),
24488 )
24489 .unwrap()],
24490 )
24491 .unwrap()
24492 .with_spawn_profiles(SpawnProfileRegistry::new([
24493 crate::protocol::SpawnProfileDefaults {
24494 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24495 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24496 .unwrap(),
24497 provider: agent("kimi"),
24498 mode: SessionMode::Pty,
24499 terminal_size: gate4agent_types::TerminalSize {
24500 rows: 24,
24501 columns: 80,
24502 },
24503 prompt: None,
24504 bundle_id: None,
24505 context_id: None,
24506 environment_profile_id: None,
24507 },
24508 ])
24509 .unwrap());
24510 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24511 config,
24512 agent("kimi"),
24513 proof_path.clone(),
24514 release_signal.clone(),
24515 )
24516 .unwrap();
24517 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24518
24519 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24520 agent("kimi"),
24521 proof_path.clone(),
24522 Some(release_signal.clone()),
24523 )
24524 .unwrap();
24525 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24526 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24527 let release_wait = wrapper
24528 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24529 .unwrap();
24530 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24531 assert!(proof_write < release_wait);
24532 assert!(release_wait < fixture_ready);
24533 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24534 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24535 assert_eq!(
24536 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24537 &[
24538 proof_path.to_string_lossy().into_owned(),
24539 release_signal.to_string_lossy().into_owned(),
24540 ],
24541 );
24542
24543 let error = NodeServer::provider_bundle_argv_fixture_spec(
24544 agent("kimi"),
24545 proof_path,
24546 Some(outside.join("release.signal")),
24547 )
24548 .unwrap_err();
24549 assert!(matches!(
24550 error,
24551 NodeServerError::Registry(message)
24552 if message == "provider bundle argv release path must share the proof parent"
24553 ));
24554 drop(server);
24555 std::fs::remove_dir_all(root).unwrap();
24556 std::fs::remove_dir_all(outside).unwrap();
24557 }
24558
24559 #[cfg(feature = "fixture")]
24560 #[test]
24561 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24562 let root = temporary_workspace_root("context-fixture-registry");
24563 std::fs::create_dir_all(&root).unwrap();
24564 let proof_path = root.join("context.proof");
24565 let config = NodeServerConfig::new(
24566 format!(
24567 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24568 std::process::id(),
24569 ),
24570 "fixture-token",
24571 NodeId::new("context-fixture-node").unwrap(),
24572 [WorkspaceConfig::new(
24573 WorkspaceId::new("primary").unwrap(),
24574 std::env::current_dir().unwrap(),
24575 )
24576 .unwrap()],
24577 )
24578 .unwrap();
24579 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24580 let mut enabled = server
24581 .shared
24582 .enabled_providers
24583 .iter()
24584 .map(AgentId::as_str)
24585 .collect::<Vec<_>>();
24586 enabled.sort_unstable();
24587 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24588 for provider in &server.shared.enabled_providers {
24589 assert!(server
24590 .shared
24591 .provider_adapter_contracts
24592 .iter()
24593 .any(|contract| {
24594 &contract.provider == provider
24595 && contract.family == AdapterFamily::History
24596 }));
24597 }
24598 drop(server);
24599 std::fs::remove_dir_all(root).unwrap();
24600 }
24601
24602 #[cfg(feature = "fixture")]
24603 #[test]
24604 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24605 let root = temporary_workspace_root("monitoring-context-fixture");
24606 std::fs::create_dir_all(&root).unwrap();
24607 let proof_path = root.join("context.proof");
24608 let standard = NodeServer::context_pack_fixture_catalog(
24609 proof_path.clone(),
24610 None,
24611 false,
24612 ).unwrap();
24613 let monitoring = NodeServer::context_pack_fixture_catalog(
24614 proof_path.clone(),
24615 None,
24616 true,
24617 ).unwrap();
24618 let claude = AgentId::new("claude").unwrap();
24619 let standard_claude = standard.get(&claude).unwrap();
24620 let monitoring_claude = monitoring.get(&claude).unwrap();
24621 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24625 assert_eq!(
24626 monitoring_claude.capabilities.adapters.history,
24627 standard_claude.capabilities.adapters.history,
24628 );
24629 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24630 let codex = AgentId::new("codex").unwrap();
24631 let standard_codex = standard.get(&codex).unwrap();
24632 let monitoring_codex = monitoring.get(&codex).unwrap();
24633 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24634 assert_eq!(
24635 monitoring_codex.capabilities.adapters.history,
24636 standard_codex.capabilities.adapters.history,
24637 );
24638 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24639 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24640 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24641 .unwrap();
24642 for required in [
24643 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24644 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24645 "@('role', 'text')",
24646 "g4a-private-provider-session-canary",
24647 "private-provider-login-identity",
24648 "$rawProof",
24649 "$env:GATE4AGENT_HOOK_URL",
24650 "g4a-private-provider-session-canary",
24651 ] {
24652 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24653 }
24654 for provider in ["grok", "kimi"] {
24655 let provider = AgentId::new(provider).unwrap();
24656 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24657 }
24658
24659 let config = NodeServerConfig::new(
24660 format!(
24661 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24662 std::process::id(),
24663 ),
24664 "fixture-token",
24665 NodeId::new("monitoring-context-fixture-node").unwrap(),
24666 [WorkspaceConfig::new(
24667 WorkspaceId::new("primary").unwrap(),
24668 std::env::current_dir().unwrap(),
24669 )
24670 .unwrap()],
24671 )
24672 .unwrap();
24673 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24674 assert!(server.shared.fixture_semantic_hook_policy);
24675 drop(server);
24676 std::fs::remove_dir_all(root).unwrap();
24677 }
24678
24679 #[cfg(feature = "fixture")]
24680 #[test]
24681 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24682 let root = temporary_workspace_root("kimi-context-only-fixture");
24683 std::fs::create_dir_all(&root).unwrap();
24684 let proof_path = root.join("context.proof");
24685 let standard = NodeServer::context_pack_fixture_catalog(
24686 proof_path.clone(),
24687 None,
24688 false,
24689 ).unwrap();
24690 let kimi = AgentId::new("kimi").unwrap();
24691 let context_only = NodeServer::context_pack_fixture_catalog(
24692 proof_path,
24693 Some(&kimi),
24694 false,
24695 )
24696 .unwrap();
24697
24698 let codex = AgentId::new("codex").unwrap();
24699 assert_eq!(
24700 standard.get(&codex).unwrap().launch.fixed_args,
24701 context_only.get(&codex).unwrap().launch.fixed_args,
24702 );
24703 let script = context_only
24704 .get(&kimi)
24705 .unwrap()
24706 .launch
24707 .fixed_args
24708 .iter()
24709 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24710 .expect("Kimi context-only validation script is missing");
24711 for required in [
24712 "$bundleArgs.Count -ne 0",
24713 "GATE4AGENT_CONTEXT_ROOT",
24714 "context-pack.json",
24715 "$entries.Count -ne 1",
24716 "g4a-context-pack-v1",
24717 "$document.PSObject.Properties['cwd']",
24718 "'claude', 'codex', 'grok', 'kimi'",
24719 "$hasUser",
24720 "$hasAssistant",
24721 "SHA256",
24722 "context-only",
24723 ] {
24724 assert!(script.contains(required), "missing validation: {required}");
24725 }
24726 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24727 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24728 }
24729 assert!(!standard
24730 .get(&kimi)
24731 .unwrap()
24732 .launch
24733 .fixed_args
24734 .iter()
24735 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24736
24737 std::fs::remove_dir_all(root).unwrap();
24738 }
24739
24740 #[cfg(all(feature = "fixture", windows))]
24741 #[tokio::test]
24742 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24743 let root = temporary_workspace_root("kimi-context-only-spawn");
24744 let workspace_root = root.join("workspace");
24745 std::fs::create_dir_all(&workspace_root).unwrap();
24746 let materialization_root = root.join("private-materializations");
24747 let proof_path = root.join("context.proof");
24748 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24749 let workspace_id = WorkspaceId::new("primary").unwrap();
24750 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24751 let endpoint = format!(
24752 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24753 std::process::id(),
24754 unix_time_ms(),
24755 );
24756 let spawn_profiles = SpawnProfileRegistry::new([
24757 crate::protocol::SpawnProfileDefaults {
24758 profile_id: profile_id.clone(),
24759 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24760 provider: agent("kimi"),
24761 mode: SessionMode::Pty,
24762 terminal_size: gate4agent_types::TerminalSize {
24763 rows: 30,
24764 columns: 120,
24765 },
24766 prompt: None,
24767 bundle_id: None,
24768 context_id: None,
24769 environment_profile_id: None,
24770 },
24771 ])
24772 .unwrap();
24773 let mut config = NodeServerConfig::new(
24774 &endpoint,
24775 "fixture-token",
24776 node_id.clone(),
24777 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24778 )
24779 .unwrap()
24780 .with_spawn_profiles(spawn_profiles)
24781 .with_session_environment_materialization(
24782 materialization_root,
24783 Arc::new(FixtureSecretResolver {
24784 deny: Arc::new(AtomicBool::new(false)),
24785 }),
24786 )
24787 .unwrap();
24788 config.fixture_raw_pty_runtime = true;
24789 let server = NodeServer::new_context_only_proof_fixture(
24790 config,
24791 agent("kimi"),
24792 proof_path.clone(),
24793 )
24794 .unwrap();
24795 let shared = Arc::clone(&server.shared);
24796 let shutdown = server.shutdown_handle();
24797
24798 let history = HistorySessionRecord {
24799 session_id: "context-only-source-history".to_owned(),
24800 title: None,
24801 cwd: None,
24802 model: None,
24803 message_count: 2,
24804 completed_turn_count: None,
24805 total_tokens: 0,
24806 messages: vec![
24807 gate4agent_types::HistoryMessageRecord {
24808 role: gate4agent_types::HistoryMessageRole::User,
24809 text: "continue the bounded parent run through native C2".to_owned(),
24810 },
24811 gate4agent_types::HistoryMessageRecord {
24812 role: gate4agent_types::HistoryMessageRole::Assistant,
24813 text: "the exact parent context is ready for the child run".to_owned(),
24814 },
24815 ],
24816 };
24817 let pack = NodeContextPack::export(
24818 ContextPackLineageReceipt {
24819 source_node_id: node_id.clone(),
24820 source_session: SessionAddress {
24821 workspace_id: workspace_id.clone(),
24822 session: SessionKey {
24823 instance_id: AgentInstanceId(71),
24824 generation: SessionGeneration(1),
24825 },
24826 },
24827 source_provider: agent("codex"),
24828 },
24829 &history,
24830 )
24831 .unwrap();
24832 let expected_context_hash = digest(&SHA256, pack.bytes())
24833 .as_ref()
24834 .iter()
24835 .map(|byte| format!("{byte:02x}"))
24836 .collect::<String>();
24837 let context = shared
24838 .context_catalog
24839 .write()
24840 .unwrap_or_else(|poisoned| poisoned.into_inner())
24841 .insert(pack)
24842 .unwrap();
24843 let spec = SpawnSpec {
24844 target: crate::protocol::SpawnTarget {
24845 node_id,
24846 workspace_id: workspace_id.clone(),
24847 worktree_id: None,
24848 },
24849 profile_id,
24850 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24851 "target-kimi-r1",
24852 ).unwrap(),
24853 overrides: crate::protocol::SpawnOverrides {
24854 context_id: crate::protocol::SpawnOverride::Set {
24855 value: context.id.clone(),
24856 },
24857 ..crate::protocol::SpawnOverrides::default()
24858 },
24859 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24860 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24861 required_capabilities: SpawnRequiredCapabilities::default(),
24862 };
24863 let resolved = match shared.resolve_spawn_spec(&spec) {
24864 Ok(resolved) => resolved,
24865 Err(error) => panic!(
24866 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24867 error.code,
24868 error.message,
24869 ),
24870 };
24871 let resolved_context = match shared.resolve_context(&resolved) {
24872 Ok(Some(resolved_context)) => resolved_context,
24873 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24874 Err(error) => panic!(
24875 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24876 error.code,
24877 error.message,
24878 ),
24879 };
24880 assert_eq!(resolved_context, context);
24881 let runtime_policy = match shared
24882 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24883 .await
24884 {
24885 Ok(runtime_policy) => runtime_policy,
24886 Err(error) => panic!(
24887 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24888 error.code,
24889 error.message,
24890 ),
24891 };
24892 assert!(runtime_policy.raw_pty_lifecycle);
24893
24894 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24895 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24896 agent("kimi"),
24897 TransportKind::Pty,
24898 vec![EnvMutation {
24899 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24900 value: Some(OsString::from("must-remain-rejected")),
24901 }],
24902 )
24903 .unwrap();
24904 assert_eq!(
24905 launch_control
24906 .install_native_launch_environment_overlay(
24907 AgentInstanceId(72),
24908 generic_overlay,
24909 )
24910 .unwrap_err(),
24911 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24912 );
24913
24914 let diagnostic_address = SessionAddress {
24915 workspace_id: workspace_id.clone(),
24916 session: SessionKey {
24917 instance_id: AgentInstanceId(73),
24918 generation: SessionGeneration(1),
24919 },
24920 };
24921 let (diagnostic_overlay, diagnostic_materialization) = match shared
24922 .prepare_session_materialization(
24923 &diagnostic_address,
24924 &agent("kimi"),
24925 SessionMode::Pty,
24926 None,
24927 None,
24928 Some(&resolved_context),
24929 None,
24930 )
24931 {
24932 Ok(Some(prepared)) => prepared,
24933 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24934 Err(error) => panic!(
24935 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24936 error.code,
24937 error.message,
24938 ),
24939 };
24940 let diagnostic_overlay = diagnostic_overlay
24941 .expect("Kimi context-only materialization omitted its environment overlay");
24942 assert!(matches!(
24943 &diagnostic_overlay,
24944 PreparedNativeLaunchOverlay::Environment(_),
24945 ));
24946 match shared.install_prepared_launch_overlay(
24947 diagnostic_address.session.instance_id,
24948 diagnostic_overlay,
24949 ) {
24950 Ok(None) => {}
24951 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24952 Err(error) => panic!(
24953 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24954 error.code,
24955 error.message,
24956 ),
24957 }
24958 assert!(launch_control.clear_native_instance_launch_overlay(
24959 diagnostic_address.session.instance_id,
24960 ));
24961 drop(diagnostic_materialization);
24962 assert!(shared
24963 .materializations
24964 .lock()
24965 .unwrap_or_else(|poisoned| poisoned.into_inner())
24966 .is_empty());
24967
24968 let node_task = tokio::spawn(server.run());
24969 let ready_client = timeout(Duration::from_secs(5), async {
24970 loop {
24971 match gate4agent_node_wire::LocalNodeClient::connect(
24972 &endpoint,
24973 &shared.node_id,
24974 ClientRole::Observer,
24975 "fixture-token",
24976 )
24977 .await
24978 {
24979 Ok(client) => break client,
24980 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24981 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24982 }
24983 }
24984 })
24985 .await
24986 .expect("Kimi context-only Node did not become ready");
24987 assert_eq!(
24988 ready_client
24989 .hello()
24990 .snapshot
24991 .provider_runtime_statuses
24992 .iter()
24993 .find(|status| status.provider().as_str() == "kimi")
24994 .expect("Kimi runtime status is missing")
24995 .mode(),
24996 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24997 );
24998 drop(ready_client);
24999
25000 let receipt = match shared.spawn_from_spec(spec).await {
25001 Ok(receipt) => receipt,
25002 Err(error) => panic!(
25003 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25004 error.code,
25005 error.message,
25006 ),
25007 };
25008 assert_eq!(receipt.provider, agent("kimi"));
25009 assert_eq!(receipt.context.as_ref(), Some(&context));
25010 assert!(receipt.context_binding_is_valid());
25011 timeout(Duration::from_secs(5), async {
25012 while !proof_path.is_file() {
25013 sleep(Duration::from_millis(10)).await;
25014 }
25015 })
25016 .await
25017 .expect("Kimi context-only fixture did not write its proof");
25018
25019 let proof = std::fs::read_to_string(&proof_path).unwrap();
25020 let lines = proof.lines().collect::<Vec<_>>();
25021 assert_eq!(lines.len(), 7);
25022 assert_eq!(lines[0], "context-only");
25023 assert!(Path::new(lines[1]).is_absolute());
25024 assert_ne!(
25025 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25026 std::fs::canonicalize(&workspace_root).unwrap(),
25027 );
25028 assert_eq!(
25029 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25030 std::fs::canonicalize(&workspace_root).unwrap(),
25031 );
25032 assert_eq!(lines[3], expected_context_hash);
25033 assert_eq!(lines[4], "g4a-context-pack-v1");
25034 assert_eq!(lines[5], "codex");
25035 assert_eq!(lines[6], "2");
25036 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25037 session.instance_id == receipt.session.session.instance_id
25038 && session.agent_id.as_str() == "kimi"
25039 }));
25040
25041 shutdown.request_shutdown().await.unwrap();
25042 timeout(Duration::from_secs(5), node_task)
25043 .await
25044 .unwrap()
25045 .unwrap()
25046 .unwrap();
25047 drop(shutdown);
25048 drop(shared);
25049 std::fs::remove_dir_all(root).unwrap();
25050 }
25051
25052 #[cfg(feature = "fixture")]
25053 #[test]
25054 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25055 let root = temporary_workspace_root("clean-exit-constructor");
25056 std::fs::create_dir_all(&root).unwrap();
25057 let config = construction_test_config("clean-exit-constructor")
25058 .with_state_path(root.join("node-state.json"))
25059 .unwrap();
25060 let server = NodeServer::new_clean_exit_fixture(
25061 config,
25062 root.clone(),
25063 root.join("started.marker"),
25064 root.join("release.signal"),
25065 )
25066 .unwrap();
25067 assert_eq!(
25068 server
25069 .shared
25070 .enabled_providers
25071 .iter()
25072 .map(AgentId::as_str)
25073 .collect::<Vec<_>>(),
25074 ["claude"],
25075 );
25076 drop(server);
25077 std::fs::remove_dir_all(root).unwrap();
25078 }
25079
25080 #[test]
25081 fn production_registry_provider_contract_manifest_is_exact() {
25082 let registry = active_registry().unwrap();
25083 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25084 assert_eq!(
25085 providers
25086 .iter()
25087 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25088 .collect::<Vec<_>>(),
25089 vec![
25090 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25091 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25092 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25093 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25094 ]
25095 );
25096 assert_eq!(
25097 adapters
25098 .iter()
25099 .map(|contract| (
25100 contract.provider.as_str(),
25101 contract.family,
25102 contract.adapter_id.as_str(),
25103 contract.revision.as_str(),
25104 ))
25105 .collect::<Vec<_>>(),
25106 vec![
25107 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25108 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25109 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25110 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25111 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25112 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25113 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25114 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25115 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25116 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25117 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25118 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25119 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25120 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25121 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25122 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25123 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25124 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25125 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25126 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25127 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25128 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25129 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25130 ]
25131 );
25132 }
25133
25134 fn construction_test_config(name: &str) -> NodeServerConfig {
25135 let workspace = WorkspaceConfig::new(
25136 WorkspaceId::new("primary").unwrap(),
25137 std::env::current_dir().unwrap(),
25138 )
25139 .unwrap();
25140 NodeServerConfig::new(
25141 format!(r"\\.\pipe\gate4agent-{name}"),
25142 "fixture-token",
25143 NodeId::new(name).unwrap(),
25144 [workspace],
25145 )
25146 .unwrap()
25147 }
25148
25149 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25150 let adapter = AdapterBinding::new(
25151 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25152 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25153 gate4agent_types::AdapterVerification::SyntheticFixture,
25154 )
25155 .unwrap();
25156 let agent_ids = ["claude", "codex", "kimi"]
25157 .into_iter()
25158 .map(|agent_id| AgentId::new(agent_id).unwrap())
25159 .collect::<Vec<_>>();
25160 let adapters = gate4agent_catalog::AdapterRegistry::new(
25161 [
25162 AdapterFamily::PtySemantic,
25163 AdapterFamily::Pipe,
25164 AdapterFamily::OneShot,
25165 AdapterFamily::Acp,
25166 AdapterFamily::Hook,
25167 AdapterFamily::ManagedHook,
25168 AdapterFamily::History,
25169 AdapterFamily::Resume,
25170 AdapterFamily::SessionOptions,
25171 AdapterFamily::CapabilityProbe,
25172 ]
25173 .into_iter()
25174 .map(|family| gate4agent_catalog::AdapterDescriptor {
25175 family,
25176 binding: adapter.clone(),
25177 agents: agent_ids.clone(),
25178 }),
25179 )
25180 .unwrap();
25181 let specs = agent_ids
25182 .iter()
25183 .map(|agent_id| {
25184 let agent_id = agent_id.as_str();
25185 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25186 spec.revision =
25187 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25188 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25189 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25190 adapter: adapter.clone(),
25191 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25192 launch_override: None,
25193 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25194 });
25195 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25196 adapter: adapter.clone(),
25197 launch_override: None,
25198 });
25199 spec.capabilities.adapters.hook = Some(adapter.clone());
25200 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25201 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25202 spec.capabilities.adapters.history = Some(adapter.clone());
25203 spec.capabilities.adapters.resume = Some(adapter.clone());
25204 spec.capabilities.adapters.session_options = Some(adapter.clone());
25205 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25206 spec
25207 })
25208 .collect::<Vec<_>>();
25209 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25210 }
25211
25212 #[test]
25220 fn node_server_construction_accepts_the_production_manifest() {
25221 let registry = active_registry().unwrap();
25222 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25223 assert_eq!(providers.len(), registry.iter().count());
25224 assert!(!providers.is_empty());
25225 let server = NodeServer::new_with_registry(
25226 construction_test_config("manifest-production-bound"),
25227 registry,
25228 )
25229 .unwrap();
25230 drop(server);
25231 }
25232
25233 #[test]
25234 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25235 let registry = maximum_size_provider_manifest_registry();
25236 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25237 assert_eq!(providers.len(), 3);
25238 assert_eq!(adapters.len(), 30);
25239 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25240 let error = match NodeServer::new_with_registry(
25241 construction_test_config("manifest-handshake-overflow"),
25242 registry,
25243 ) {
25244 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25245 Err(error) => error,
25246 };
25247 assert!(matches!(
25248 error,
25249 NodeServerError::ProviderContractManifest(message)
25250 if message.contains("authentication binding length")
25251 && message.contains("8192-byte limit")
25252 ));
25253 }
25254
25255 #[test]
25256 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25257 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25258 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25259 let registry = AgentRegistry::new([spec]).unwrap();
25260 assert!(matches!(
25261 provider_contract_manifest(®istry),
25262 Err(NodeServerError::ProviderContractManifest(message))
25263 if message.contains("exceeds the 128-byte limit")
25264 ));
25265 }
25266
25267 #[test]
25268 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25269 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25270 spec.revision = "invalid revision".to_owned();
25271 let registry = AgentRegistry::new([spec]).unwrap();
25272 assert!(matches!(
25273 provider_contract_manifest(®istry),
25274 Err(NodeServerError::ProviderContractManifest(message))
25275 if message.contains("bounded lowercase ASCII")
25276 ));
25277 }
25278
25279 #[test]
25280 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25281 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25282 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25283 let conflicting_binding = builtin_registry()
25284 .get_by_id("codex")
25285 .unwrap()
25286 .capabilities
25287 .adapters
25288 .one_shot
25289 .clone()
25290 .unwrap();
25291 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25292 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25293 pipe.adapter = transport_binding;
25294 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25295 let registry = AgentRegistry::new([spec]).unwrap();
25296 assert!(matches!(
25297 provider_contract_manifest(®istry),
25298 Err(NodeServerError::ProviderContractManifest(message))
25299 if message.contains("conflicting OneShot adapter bindings")
25300 ));
25301 }
25302
25303 #[test]
25304 fn public_node_failures_expose_only_fixed_categories() {
25305 let provider = failure(
25306 NodeFailureCode::BackendOperationFailed,
25307 r"provider bearer-secret failed at C:\private\session.jsonl",
25308 );
25309 assert_eq!(provider.message, "backend-operation-failed");
25310 assert!(!provider.message.contains("bearer-secret"));
25311 assert!(!provider.message.contains(r"C:\private"));
25312 let persistence = persistence_failure(io::Error::new(
25313 io::ErrorKind::Other,
25314 r"state write failed at C:\private\state-v1.json",
25315 ));
25316 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25317 assert!(!persistence.message.contains(r"C:\private"));
25318
25319 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25320 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25321 assert_eq!(
25322 generic.to_string(),
25323 "node durable state failed: durable-state-load-failed",
25324 );
25325 }
25326 }
25327
25328 #[test]
25329 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25330 let sequence = b"\x1b[1;5D".to_vec();
25331 assert_eq!(
25332 terminal_bytes_action(sequence.clone()).unwrap(),
25333 InputAction::TerminalBytes(sequence),
25334 );
25335 assert_eq!(
25336 terminal_bytes_action(Vec::new()).unwrap_err().code,
25337 NodeFailureCode::InvalidRequest,
25338 );
25339 assert_eq!(
25340 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25341 .unwrap_err()
25342 .code,
25343 NodeFailureCode::InvalidRequest,
25344 );
25345 }
25346
25347 #[test]
25348 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25349 let root = temporary_workspace_root("exclusive-state-owner");
25350 std::fs::create_dir_all(&root).unwrap();
25351 let workspace = WorkspaceConfig::new(
25352 WorkspaceId::new("primary").unwrap(),
25353 &root,
25354 )
25355 .unwrap();
25356 let state_path = root.join("state-v1.json");
25357 let endpoint = format!(
25358 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25359 std::process::id(),
25360 std::time::SystemTime::now()
25361 .duration_since(std::time::UNIX_EPOCH)
25362 .unwrap()
25363 .as_nanos(),
25364 );
25365 let config = NodeServerConfig::new(
25366 endpoint,
25367 "fixture-token",
25368 NodeId::new("node-1").unwrap(),
25369 [workspace],
25370 )
25371 .unwrap()
25372 .with_state_path(&state_path)
25373 .unwrap();
25374
25375 let first = NodeServer::new(config.clone()).unwrap();
25376 let error = match NodeServer::new(config.clone()) {
25377 Ok(_) => panic!("a second node server acquired the same durable state path"),
25378 Err(error) => error,
25379 };
25380 assert!(matches!(
25381 &error,
25382 NodeServerError::DurableState(error)
25383 if error.kind() == io::ErrorKind::WouldBlock
25384 ));
25385 assert_eq!(
25386 error.to_string(),
25387 "node durable state failed: durable-state-lock-failed",
25388 );
25389 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25390 drop(first);
25391 let second = NodeServer::new(config).unwrap();
25392 drop(second);
25393 std::fs::remove_dir_all(root).unwrap();
25394 }
25395
25396 #[test]
25397 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25398 let catalog = active_registry().unwrap();
25399 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25400 let workspace_id = WorkspaceId::new("primary").unwrap();
25401 let workspace = WorkspaceConfig::new(
25402 workspace_id.clone(),
25403 std::env::current_dir().unwrap(),
25404 )
25405 .unwrap();
25406 let shared = NodeShared::new(
25407 handle,
25408 "fixture-token".to_owned(),
25409 NodeId::new("node-1").unwrap(),
25410 vec![workspace.clone()],
25411 vec![agent("claude"), agent("codex"), agent("kimi")],
25412 );
25413 let instance_id = AgentInstanceId(17);
25414 let generation = SessionGeneration(3);
25415 assert!(shared.address_for(instance_id, generation).is_none());
25416 shared.bind_session(instance_id, workspace_id.clone(), generation);
25417 let address = SessionAddress {
25418 workspace_id,
25419 session: SessionKey { instance_id, generation },
25420 };
25421 assert_eq!(
25422 shared.workspace_root(&address.workspace_id).unwrap(),
25423 workspace.canonical_root,
25424 );
25425 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25426 shared.remove_binding(&address);
25427 assert!(shared.address_for(instance_id, generation).is_none());
25428 }
25429
25430 #[test]
25431 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25432 let address = SessionAddress {
25433 workspace_id: WorkspaceId::new("primary").unwrap(),
25434 session: SessionKey {
25435 instance_id: AgentInstanceId(7),
25436 generation: SessionGeneration(2),
25437 },
25438 };
25439 let record_id = SessionRecordId::new("record-a").unwrap();
25440 let size = gate4agent_types::TerminalSize {
25441 rows: 24,
25442 columns: 80,
25443 };
25444 let address_requests = [
25445 NodeRequest::Resume {
25446 session: address.clone(),
25447 terminal_size: size,
25448 initial_prompt: None,
25449 },
25450 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25451 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25452 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25453 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25454 NodeRequest::TerminalControl {
25455 session: address.clone(),
25456 control: TerminalControl::Enter,
25457 },
25458 NodeRequest::Resize { session: address.clone(), size },
25459 NodeRequest::Interrupt { session: address.clone() },
25460 NodeRequest::Stop { session: address.clone(), force: false },
25461 NodeRequest::Remove { session: address },
25462 ];
25463 for request in &address_requests {
25464 assert!(request_requires_open_provider_ids_with(
25465 request,
25466 |_| Some(agent("third-party-agent")),
25467 |_| Some(agent("claude")),
25468 ));
25469 assert!(!request_requires_open_provider_ids_with(
25470 request,
25471 |_| Some(agent("claude")),
25472 |_| Some(agent("claude")),
25473 ));
25474 assert!(request_requires_open_provider_ids_with(
25475 request,
25476 |_| None,
25477 |_| Some(agent("claude")),
25478 ));
25479 }
25480
25481 let record_requests = [
25482 NodeRequest::RenameSessionRecord {
25483 record_id: record_id.clone(),
25484 display_name: "renamed".to_owned(),
25485 },
25486 NodeRequest::ResumeSessionRecord {
25487 record_id: record_id.clone(),
25488 terminal_size: size,
25489 initial_prompt: None,
25490 },
25491 NodeRequest::ForgetSessionRecord { record_id },
25492 ];
25493 for request in &record_requests {
25494 assert!(request_requires_open_provider_ids_with(
25495 request,
25496 |_| Some(agent("claude")),
25497 |_| Some(agent("grok")),
25498 ));
25499 assert!(!request_requires_open_provider_ids_with(
25500 request,
25501 |_| Some(agent("claude")),
25502 |_| Some(agent("kimi")),
25503 ));
25504 assert!(request_requires_open_provider_ids_with(
25505 request,
25506 |_| Some(agent("claude")),
25507 |_| None,
25508 ));
25509 }
25510
25511 assert!(request_requires_open_provider_ids_with(
25512 &NodeRequest::Spawn {
25513 workspace_id: WorkspaceId::new("primary").unwrap(),
25514 provider: agent("third-party-agent"),
25515 mode: SessionMode::Pty,
25516 terminal_size: size,
25517 initial_prompt: None,
25518 },
25519 |_| None,
25520 |_| None,
25521 ));
25522 assert!(!request_requires_open_provider_ids_with(
25523 &NodeRequest::Snapshot,
25524 |_| None,
25525 |_| None,
25526 ));
25527 assert!(request_requires_open_provider_ids_with(
25528 &NodeRequest::ForgetContextPack {
25529 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25530 },
25531 |_| None,
25532 |_| None,
25533 ));
25534 }
25535
25536 #[test]
25537 fn legacy_projection_filters_open_record_replies_and_event_history() {
25538 let catalog = active_registry().unwrap();
25539 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25540 let workspace = WorkspaceConfig::new(
25541 WorkspaceId::new("primary").unwrap(),
25542 std::env::current_dir().unwrap(),
25543 )
25544 .unwrap();
25545 let shared = NodeShared::new(
25546 handle,
25547 "fixture-token".to_owned(),
25548 NodeId::new("node-1").unwrap(),
25549 vec![workspace],
25550 vec![agent("claude"), agent("grok")],
25551 );
25552 let legacy = record("claude", "legacy-record");
25553 let open = record("grok", "open-record");
25554 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25555 record: legacy.clone(),
25556 });
25557 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25558 record: open.clone(),
25559 });
25560 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25561 record_id: legacy.record_id.clone(),
25562 });
25563 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25564 record_id: open.record_id.clone(),
25565 });
25566 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25567 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25568 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25569 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25570
25571 let mut record_reply = ResponseEnvelope {
25572 request_id: 1,
25573 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25574 };
25575 project_response_legacy_provider_ids(&shared, &mut record_reply);
25576 assert!(matches!(
25577 record_reply.result,
25578 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25579 ));
25580
25581 let mut resync = ResponseEnvelope {
25582 request_id: 2,
25583 result: Ok(NodeResponse::Resync {
25584 event_sequence: open_removed.sequence,
25585 oldest_available_sequence: 1,
25586 snapshot: shared.snapshot(),
25587 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25588 }),
25589 };
25590 project_response_legacy_provider_ids(&shared, &mut resync);
25591 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25592 panic!("legacy projection changed the resync response kind");
25593 };
25594 assert!(snapshot.session_records.iter().all(|item| {
25595 provider_id_is_legacy(&item.provider)
25596 }));
25597 assert_eq!(events.len(), 2);
25598 assert!(events.iter().all(|event| matches!(
25599 &event.event,
25600 NodeEvent::SessionRecordUpserted { record }
25601 if provider_id_is_legacy(&record.provider)
25602 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25603 }
25604
25605 #[test]
25606 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25607 let catalog = active_registry().unwrap();
25608 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25609 let workspace_id = WorkspaceId::new("primary").unwrap();
25610 let workspace = WorkspaceConfig::new(
25611 workspace_id.clone(),
25612 std::env::current_dir().unwrap(),
25613 )
25614 .unwrap();
25615 let shared = NodeShared::new(
25616 handle,
25617 "fixture-token".to_owned(),
25618 NodeId::new("node-1").unwrap(),
25619 vec![workspace],
25620 vec![agent("claude")],
25621 );
25622 for index in 0..CONTROL_SESSIONS_MAX {
25623 shared.bind_session(
25624 AgentInstanceId((index + 1) as u64),
25625 workspace_id.clone(),
25626 SessionGeneration(1),
25627 );
25628 }
25629 let error = shared.ensure_binding_capacity().unwrap_err();
25630 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25631 }
25632
25633 #[tokio::test]
25634 async fn provider_spawn_admission_rejects_before_session_mutation() {
25635 let catalog = active_registry().unwrap();
25636 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25637 let workspace = WorkspaceConfig::new(
25638 WorkspaceId::new("primary").unwrap(),
25639 std::env::current_dir().unwrap(),
25640 )
25641 .unwrap();
25642 let mut shared = NodeShared::new(
25643 handle,
25644 "fixture-token".to_owned(),
25645 NodeId::new("node-provider-admission").unwrap(),
25646 vec![workspace],
25647 vec![agent("claude")],
25648 );
25649 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25650 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25651 ])
25652 .unwrap();
25653 let terminal_size = gate4agent_types::TerminalSize {
25654 rows: 24,
25655 columns: 80,
25656 };
25657
25658 let unavailable = shared
25659 .spawn_session(
25660 WorkspaceId::new("primary").unwrap(),
25661 agent("claude"),
25662 SessionMode::Pty,
25663 terminal_size,
25664 None,
25665 )
25666 .await
25667 .unwrap_err();
25668 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25669 assert_eq!(unavailable.message, "backend-operation-failed");
25670
25671 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25672 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25673 agent("claude"),
25674 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25675 ),
25676 ])
25677 .unwrap();
25678 let semantic = shared
25679 .spawn_session(
25680 WorkspaceId::new("primary").unwrap(),
25681 agent("claude"),
25682 SessionMode::Inline,
25683 terminal_size,
25684 None,
25685 )
25686 .await
25687 .unwrap_err();
25688 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25689 assert_eq!(semantic.message, "unsupported-capability");
25690
25691 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25692 assert!(shared
25693 .session_bindings
25694 .lock()
25695 .unwrap_or_else(|poisoned| poisoned.into_inner())
25696 .is_empty());
25697 assert!(shared
25698 .session_records
25699 .lock()
25700 .unwrap_or_else(|poisoned| poisoned.into_inner())
25701 .records
25702 .is_empty());
25703 assert!(shared.handle.snapshot().sessions.is_empty());
25704 }
25705
25706 #[tokio::test]
25707 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25708 let catalog = active_registry().unwrap();
25709 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25710 let workspace_id = WorkspaceId::new("primary").unwrap();
25711 let workspace = WorkspaceConfig::new(
25712 workspace_id.clone(),
25713 std::env::current_dir().unwrap(),
25714 )
25715 .unwrap();
25716 let shared = NodeShared::new(
25717 handle,
25718 "fixture-token".to_owned(),
25719 NodeId::new("node-raw-spawn-record").unwrap(),
25720 vec![workspace],
25721 vec![agent("claude")],
25722 );
25723 let address = SessionAddress {
25724 workspace_id,
25725 session: SessionKey {
25726 instance_id: AgentInstanceId(71),
25727 generation: SessionGeneration::default(),
25728 },
25729 };
25730
25731 let record_id = shared
25732 .bind_spawn_session(
25733 &address,
25734 agent("claude"),
25735 SessionMode::Pty,
25736 ProviderRuntimePolicy::raw_pty(),
25737 SpawnRecordPolicy::Always,
25738 None,
25739 )
25740 .unwrap()
25741 .unwrap();
25742 shared
25743 .handle
25744 .dispatch(shared.prepare_command(ControlCommand::Register {
25745 instance_id: address.session.instance_id,
25746 agent_id: agent("claude"),
25747 transport: TransportKind::Pty,
25748 }))
25749 .unwrap();
25750 runtime.tick().await;
25751
25752 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25753 session.instance_id == address.session.instance_id
25754 && session.generation == address.session.generation
25755 }));
25756 let record = shared.record(&record_id).unwrap();
25757 assert_eq!(record.display_name, "claude #1");
25758 assert_eq!(record.state, ManagedSessionState::Live);
25759 assert_eq!(record.active_session.as_ref(), Some(&address));
25760 assert!(record.provider_session.is_none());
25761 let binding_record_id = shared
25762 .session_bindings
25763 .lock()
25764 .unwrap_or_else(|poisoned| poisoned.into_inner())
25765 .get(&address.session.instance_id)
25766 .and_then(|binding| binding.record_id.clone());
25767 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25768
25769 let renamed = shared
25770 .rename_session_record(&record_id, "raw review".to_owned())
25771 .unwrap();
25772 assert_eq!(renamed.record_id, record_id);
25773 assert_eq!(renamed.display_name, "raw review");
25774 shared.reconcile_managed_record(
25775 &record_id,
25776 &address,
25777 &ControlEvent {
25778 sequence: 1,
25779 command_id: None,
25780 instance_id: address.session.instance_id,
25781 generation: address.session.generation,
25782 event: ControlEventKind::Running { process_id: None },
25783 },
25784 false,
25785 false,
25786 );
25787 let running = shared.record(&record_id).unwrap();
25788 assert_eq!(running.state, ManagedSessionState::Live);
25789 assert_eq!(running.active_session.as_ref(), Some(&address));
25790 assert_eq!(running.display_name, "raw review");
25791
25792 shared.reconcile_managed_record(
25793 &record_id,
25794 &address,
25795 &ControlEvent {
25796 sequence: 2,
25797 command_id: None,
25798 instance_id: address.session.instance_id,
25799 generation: address.session.generation,
25800 event: ControlEventKind::Exited {
25801 exit_code: Some(0),
25802 forced: false,
25803 },
25804 },
25805 false,
25806 false,
25807 );
25808 let detached = shared.record(&record_id).unwrap();
25809 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25810 assert!(detached.active_session.is_none());
25811 assert_eq!(detached.display_name, "raw review");
25812 let renamed_detached = shared
25813 .rename_session_record(&record_id, "raw review detached".to_owned())
25814 .unwrap();
25815 assert_eq!(renamed_detached.record_id, record_id);
25816 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25817 assert!(renamed_detached.active_session.is_none());
25818 assert_eq!(renamed_detached.display_name, "raw review detached");
25819 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25820 }
25821
25822 #[test]
25823 fn legacy_raw_spawn_binding_remains_untracked() {
25824 let catalog = active_registry().unwrap();
25825 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25826 let workspace_id = WorkspaceId::new("primary").unwrap();
25827 let workspace = WorkspaceConfig::new(
25828 workspace_id.clone(),
25829 std::env::current_dir().unwrap(),
25830 )
25831 .unwrap();
25832 let shared = NodeShared::new(
25833 handle,
25834 "fixture-token".to_owned(),
25835 NodeId::new("node-legacy-raw-spawn").unwrap(),
25836 vec![workspace],
25837 vec![agent("claude")],
25838 );
25839 let address = SessionAddress {
25840 workspace_id,
25841 session: SessionKey {
25842 instance_id: AgentInstanceId(72),
25843 generation: SessionGeneration::default(),
25844 },
25845 };
25846
25847 let record_id = shared
25848 .bind_spawn_session(
25849 &address,
25850 agent("claude"),
25851 SessionMode::Pty,
25852 ProviderRuntimePolicy::raw_pty(),
25853 SpawnRecordPolicy::ProviderIdentityOnly,
25854 None,
25855 )
25856 .unwrap();
25857
25858 assert!(record_id.is_none());
25859 let binding_record_id = shared
25860 .session_bindings
25861 .lock()
25862 .unwrap_or_else(|poisoned| poisoned.into_inner())
25863 .get(&address.session.instance_id)
25864 .and_then(|binding| binding.record_id.clone());
25865 assert!(binding_record_id.is_none());
25866 assert!(shared.snapshot().session_records.is_empty());
25867 }
25868
25869 #[tokio::test]
25870 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25871 let catalog = active_registry().unwrap();
25872 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25873 let workspace_id = WorkspaceId::new("primary").unwrap();
25874 let workspace = WorkspaceConfig::new(
25875 workspace_id.clone(),
25876 std::env::current_dir().unwrap(),
25877 )
25878 .unwrap();
25879 let mut shared = NodeShared::new(
25880 handle,
25881 "fixture-token".to_owned(),
25882 NodeId::new("node-raw-semantic-gate").unwrap(),
25883 vec![workspace],
25884 vec![agent("claude")],
25885 );
25886 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25887 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25888 ])
25889 .unwrap();
25890 shared
25891 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25892 .unwrap();
25893 let address = SessionAddress {
25894 workspace_id,
25895 session: SessionKey {
25896 instance_id: AgentInstanceId(72),
25897 generation: SessionGeneration::default(),
25898 },
25899 };
25900 shared.bind_session_with_policy(
25901 &address,
25902 ProviderRuntimePolicy::raw_pty(),
25903 None,
25904 );
25905 shared
25906 .handle
25907 .dispatch(shared.prepare_command(ControlCommand::Register {
25908 instance_id: address.session.instance_id,
25909 agent_id: agent("claude"),
25910 transport: TransportKind::Pty,
25911 }))
25912 .unwrap();
25913 runtime.tick().await;
25914 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25915 let revision_before = shared.handle.snapshot().revision;
25916 let history_sequence_before = shared.current_sequence();
25917 let terminal_size = gate4agent_types::TerminalSize {
25918 rows: 24,
25919 columns: 80,
25920 };
25921
25922 for request in [
25923 NodeRequest::Prompt {
25924 session: address.clone(),
25925 text: "prompt".to_owned(),
25926 },
25927 NodeRequest::Paste {
25928 session: address.clone(),
25929 text: "paste".to_owned(),
25930 },
25931 NodeRequest::Resume {
25937 session: address.clone(),
25938 terminal_size,
25939 initial_prompt: Some("resume".to_owned()),
25940 },
25941 ] {
25942 let error = process_request_inner(
25943 &shared,
25944 77,
25945 ClientRole::Operator,
25946 request,
25947 )
25948 .await
25949 .unwrap_err();
25950 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25951 }
25952
25953 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25954 assert_eq!(shared.handle.snapshot().revision, revision_before);
25955 assert_eq!(shared.current_sequence(), history_sequence_before);
25956 let bindings = shared
25957 .session_bindings
25958 .lock()
25959 .unwrap_or_else(|poisoned| poisoned.into_inner());
25960 let binding = bindings.get(&address.session.instance_id).unwrap();
25961 assert_eq!(binding.generation, address.session.generation);
25962 assert!(binding.pending_resume.is_none());
25963 assert!(binding.record_id.is_none());
25964 }
25965
25966 #[tokio::test]
25967 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25968 let catalog = active_registry().unwrap();
25969 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25970 let primary = WorkspaceConfig::new(
25971 WorkspaceId::new("primary").unwrap(),
25972 std::env::current_dir().unwrap(),
25973 )
25974 .unwrap();
25975 let shared = NodeShared::new(
25976 handle,
25977 "fixture-token".to_owned(),
25978 NodeId::new("node-1").unwrap(),
25979 vec![primary],
25980 vec![agent("claude")],
25981 );
25982 shared
25983 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25984 .unwrap();
25985 let before = shared.snapshot();
25986 let error = process_request_inner(
25987 &shared,
25988 77,
25989 ClientRole::Operator,
25990 NodeRequest::RegisterWorkspace {
25991 workspace_id: WorkspaceId::new("foreign").unwrap(),
25992 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25993 },
25994 )
25995 .await
25996 .unwrap_err();
25997 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25998 assert_eq!(error.message, "invalid-request");
25999 assert_eq!(shared.snapshot().workspaces, before.workspaces);
26000 }
26001
26002 #[tokio::test]
26003 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26004 let catalog = active_registry().unwrap();
26005 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26006 let primary_id = WorkspaceId::new("primary").unwrap();
26007 let primary = WorkspaceConfig::new(
26008 primary_id.clone(),
26009 std::env::current_dir().unwrap(),
26010 )
26011 .unwrap();
26012 let shared = NodeShared::new(
26013 handle,
26014 "fixture-token".to_owned(),
26015 NodeId::new("node-1").unwrap(),
26016 vec![primary.clone()],
26017 vec![agent("claude")],
26018 );
26019 let secondary_id = WorkspaceId::new("secondary").unwrap();
26020 let secondary = shared
26021 .register_workspace(
26022 secondary_id.clone(),
26023 std::env::temp_dir().to_string_lossy().into_owned(),
26024 )
26025 .await
26026 .unwrap();
26027 assert_eq!(secondary.workspace_id, secondary_id);
26028 assert!(secondary.sessions.is_empty());
26029 assert_eq!(shared.snapshot().workspaces.len(), 2);
26030
26031 let duplicate_id = shared
26032 .register_workspace(
26033 secondary_id.clone(),
26034 primary.canonical_root().to_owned(),
26035 )
26036 .await
26037 .unwrap_err();
26038 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26039 let duplicate_root = shared
26040 .register_workspace(
26041 WorkspaceId::new("duplicate-root").unwrap(),
26042 windows_path_text(&secondary.canonical_root).to_owned(),
26043 )
26044 .await
26045 .unwrap_err();
26046 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26047
26048 let address = SessionAddress {
26049 workspace_id: secondary_id.clone(),
26050 session: SessionKey {
26051 instance_id: AgentInstanceId(41),
26052 generation: SessionGeneration(1),
26053 },
26054 };
26055 shared.bind_session(
26056 address.session.instance_id,
26057 secondary_id.clone(),
26058 address.session.generation,
26059 );
26060 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26061 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26062 shared.remove_binding(&address);
26063 shared.unregister_workspace(&secondary_id).unwrap();
26064 assert_eq!(shared.snapshot().workspaces.len(), 1);
26065
26066 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26067 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26068 let history = shared
26069 .history
26070 .lock()
26071 .unwrap_or_else(|poisoned| poisoned.into_inner());
26072 assert!(matches!(
26073 &history.events[0],
26074 NodeEventEnvelope {
26075 sequence: 1,
26076 event: NodeEvent::WorkspaceAdded { workspace },
26077 } if workspace.workspace_id == secondary_id
26078 ));
26079 assert!(matches!(
26080 &history.events[1],
26081 NodeEventEnvelope {
26082 sequence: 2,
26083 event: NodeEvent::WorkspaceRemoved { workspace_id },
26084 } if workspace_id == &secondary_id
26085 ));
26086 }
26087
26088 #[tokio::test]
26089 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26090 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26091 std::fs::create_dir_all(&secondary_root).unwrap();
26092 let catalog = active_registry().unwrap();
26093 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26094 let primary_id = WorkspaceId::new("primary").unwrap();
26095 let secondary_id = WorkspaceId::new("secondary").unwrap();
26096 let shared = NodeShared::new(
26097 handle,
26098 "fixture-token".to_owned(),
26099 NodeId::new("node-session-reference-index").unwrap(),
26100 vec![
26101 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26102 .unwrap(),
26103 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26104 ],
26105 vec![agent("claude"), agent("codex")],
26106 );
26107 let identity = ProviderSessionIdentity {
26108 key: gate4agent_types::ProviderSessionKey::SessionId,
26109 id: "provider-session-42".to_owned(),
26110 transcript_path: None,
26111 };
26112 let index_request = |workspace_id: WorkspaceId,
26113 provider: AgentId,
26114 identity: ProviderSessionIdentity,
26115 display_name: &str| {
26116 NodeRequest::IndexProviderSession {
26117 workspace_id,
26118 provider,
26119 identity,
26120 display_name: display_name.to_owned(),
26121 }
26122 };
26123 let request = index_request(
26124 primary_id.clone(),
26125 agent("claude"),
26126 identity.clone(),
26127 "release shepherd",
26128 );
26129 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26130 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26131 }));
26132 let observer = process_request_inner(
26133 &shared,
26134 77,
26135 ClientRole::Observer,
26136 request.clone(),
26137 )
26138 .await
26139 .unwrap_err();
26140 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26141 let uncontrolled = process_request_inner(
26142 &shared,
26143 77,
26144 ClientRole::Operator,
26145 request.clone(),
26146 )
26147 .await
26148 .unwrap_err();
26149 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26150 shared
26151 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26152 .unwrap();
26153
26154 let indexed = process_request_inner(
26155 &shared,
26156 77,
26157 ClientRole::Operator,
26158 request.clone(),
26159 )
26160 .await
26161 .unwrap();
26162 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26163 panic!("provider session reference index returned another response");
26164 };
26165 assert_eq!(record.state, ManagedSessionState::Dormant);
26166 assert_eq!(record.mode, SessionMode::Pty);
26167 assert_eq!(record.workspace_id, primary_id);
26168 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26169 assert!(record.active_session.is_none());
26170 assert!(record.environment_profile.is_none());
26171 assert!(record.bundle.is_none());
26172 assert!(record.context.is_none());
26173
26174 let repeated = process_request_inner(
26175 &shared,
26176 77,
26177 ClientRole::Operator,
26178 index_request(
26179 primary_id.clone(),
26180 agent("claude"),
26181 identity.clone(),
26182 "ignored replacement",
26183 ),
26184 )
26185 .await
26186 .unwrap();
26187 assert!(matches!(
26188 repeated,
26189 NodeResponse::ProviderSessionIndexed { record: ref existing }
26190 if existing.record_id == record.record_id
26191 && existing.display_name == "release shepherd"
26192 ));
26193 let conflicting_workspace = process_request_inner(
26194 &shared,
26195 77,
26196 ClientRole::Operator,
26197 index_request(
26198 secondary_id,
26199 agent("claude"),
26200 identity.clone(),
26201 "release shepherd",
26202 ),
26203 )
26204 .await
26205 .unwrap_err();
26206 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26207 let conflicting_provider = process_request_inner(
26208 &shared,
26209 77,
26210 ClientRole::Operator,
26211 index_request(
26212 primary_id.clone(),
26213 agent("codex"),
26214 identity.clone(),
26215 "release shepherd",
26216 ),
26217 )
26218 .await
26219 .unwrap_err();
26220 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26221 let transcript_rejected = process_request_inner(
26222 &shared,
26223 77,
26224 ClientRole::Operator,
26225 index_request(
26226 primary_id,
26227 agent("claude"),
26228 ProviderSessionIdentity {
26229 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26230 ..identity
26231 },
26232 "release shepherd",
26233 ),
26234 )
26235 .await
26236 .unwrap_err();
26237 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26238 let upserts = shared
26239 .history
26240 .lock()
26241 .unwrap_or_else(|poisoned| poisoned.into_inner())
26242 .events
26243 .iter()
26244 .filter(|event| matches!(
26245 &event.event,
26246 NodeEvent::SessionRecordUpserted { record: published }
26247 if published.record_id == record.record_id
26248 ))
26249 .count();
26250 assert_eq!(upserts, 1);
26251 std::fs::remove_dir_all(secondary_root).unwrap();
26252 }
26253
26254 #[tokio::test]
26255 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26256 let fixture_root = temporary_workspace_root("standalone-independent");
26257 let target = fixture_root.join("independent");
26258 std::fs::create_dir_all(&fixture_root).unwrap();
26259 let catalog = active_registry().unwrap();
26260 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26261 let selected = WorkspaceConfig::new(
26262 WorkspaceId::new("primary").unwrap(),
26263 std::env::current_dir().unwrap(),
26264 )
26265 .unwrap();
26266 let selected_root = selected.canonical_root().to_owned();
26267 let shared = NodeShared::new(
26268 handle,
26269 "fixture-token".to_owned(),
26270 NodeId::new("node-standalone").unwrap(),
26271 vec![selected],
26272 vec![agent("claude")],
26273 );
26274 let workspace_id = WorkspaceId::new("independent").unwrap();
26275 let request = NodeRequest::CreateStandaloneWorkspace {
26276 workspace_id: workspace_id.clone(),
26277 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26278 initial_branch: Some("main".to_owned()),
26279 };
26280 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26281 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26282 }));
26283 assert!(!request_uses_unnegotiated_capability(
26284 &request,
26285 &baseline_capabilities().unwrap(),
26286 ));
26287 assert!(request_uses_unnegotiated_capability(&request, &[]));
26288
26289 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26290 .await
26291 .unwrap_err();
26292 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26293 assert!(!target.exists());
26294 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26295 .await
26296 .unwrap_err();
26297 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26298 assert!(!target.exists());
26299 shared
26300 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26301 .unwrap();
26302
26303 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26304 .await
26305 .unwrap();
26306 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26307 panic!("standalone workspace creation returned another response");
26308 };
26309 assert_eq!(workspace.workspace_id, workspace_id);
26310 assert!(target.join(".git").is_dir());
26311 let branch = run_git_bounded(
26312 target.to_str().unwrap(),
26313 &["branch", "--show-current"],
26314 GIT_OUTPUT_MAX_BYTES,
26315 )
26316 .await
26317 .unwrap();
26318 assert!(branch.success);
26319 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26320 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26321 assert_eq!(worktrees.len(), 1);
26322 let listed_root = WorkspaceConfig::new(
26323 WorkspaceId::new("listed-independent").unwrap(),
26324 &worktrees[0].path,
26325 )
26326 .unwrap();
26327 assert!(platform::roots_equal(
26328 listed_root.canonical_root(),
26329 windows_path_text(&workspace.canonical_root),
26330 ));
26331 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26332 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26333 snapshot.workspace_id == workspace_id
26334 && snapshot.canonical_root == workspace.canonical_root
26335 }));
26336 assert!(shared
26337 .history
26338 .lock()
26339 .unwrap_or_else(|poisoned| poisoned.into_inner())
26340 .events
26341 .iter()
26342 .any(|event| matches!(
26343 &event.event,
26344 NodeEvent::WorkspaceAdded { workspace }
26345 if workspace.workspace_id == workspace_id
26346 )));
26347 std::fs::remove_dir_all(fixture_root).unwrap();
26348 }
26349
26350 #[tokio::test]
26351 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26352 let fixture_root = temporary_workspace_root("standalone-compensation");
26353 let created_target = fixture_root.join("created-target");
26354 let existing_target = fixture_root.join("existing-target");
26355 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26356 let invalid_state_path = invalid_state_parent.join("state.json");
26357 std::fs::create_dir_all(&existing_target).unwrap();
26358 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26359 let catalog = active_registry().unwrap();
26360 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26361 let primary = WorkspaceConfig::new(
26362 WorkspaceId::new("primary").unwrap(),
26363 std::env::current_dir().unwrap(),
26364 )
26365 .unwrap();
26366 let mut shared = NodeShared::new(
26367 handle,
26368 "fixture-token".to_owned(),
26369 NodeId::new("node-standalone-compensation").unwrap(),
26370 vec![primary],
26371 vec![agent("claude")],
26372 );
26373 shared.state_path = Some(invalid_state_path);
26374
26375 let created_recovery = shared
26376 .create_standalone_workspace(
26377 WorkspaceId::new("compensated").unwrap(),
26378 created_target.to_string_lossy().into_owned(),
26379 Some("main".to_owned()),
26380 )
26381 .await
26382 .unwrap_err();
26383 assert_eq!(
26384 created_recovery.code,
26385 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26386 );
26387 assert!(created_target.join(".git").is_dir());
26388 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26389 workspace.workspace_id.as_str() != "compensated"
26390 }));
26391
26392 let existing_recovery = shared
26393 .create_standalone_workspace(
26394 WorkspaceId::new("restored").unwrap(),
26395 existing_target.to_string_lossy().into_owned(),
26396 None,
26397 )
26398 .await
26399 .unwrap_err();
26400 assert_eq!(
26401 existing_recovery.code,
26402 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26403 );
26404 assert!(existing_target.is_dir());
26405 assert!(existing_target.join(".git").is_dir());
26406 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26407 workspace.workspace_id.as_str() != "restored"
26408 }));
26409
26410 let registered_empty = fixture_root.join("registered-empty");
26411 std::fs::create_dir(®istered_empty).unwrap();
26412 let catalog = active_registry().unwrap();
26413 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26414 let registered = WorkspaceConfig::new(
26415 WorkspaceId::new("registered-empty").unwrap(),
26416 ®istered_empty,
26417 )
26418 .unwrap();
26419 let duplicate_shared = NodeShared::new(
26420 handle,
26421 "fixture-token".to_owned(),
26422 NodeId::new("node-standalone-duplicate-root").unwrap(),
26423 vec![registered],
26424 vec![agent("claude")],
26425 );
26426 let duplicate = duplicate_shared
26427 .create_standalone_workspace(
26428 WorkspaceId::new("duplicate-root").unwrap(),
26429 registered_empty.to_string_lossy().into_owned(),
26430 None,
26431 )
26432 .await
26433 .unwrap_err();
26434 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26435 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26436 std::fs::remove_dir_all(fixture_root).unwrap();
26437 }
26438
26439 #[test]
26440 fn managed_record_id_collision_preserves_the_existing_record() {
26441 let catalog = active_registry().unwrap();
26442 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26443 let workspace = WorkspaceConfig::new(
26444 WorkspaceId::new("primary").unwrap(),
26445 std::env::current_dir().unwrap(),
26446 )
26447 .unwrap();
26448 let shared = NodeShared::new(
26449 handle,
26450 "fixture-token".to_owned(),
26451 NodeId::new("node-1").unwrap(),
26452 vec![workspace.clone()],
26453 vec![agent("claude")],
26454 );
26455 let record_id = SessionRecordId::new("sr-collision").unwrap();
26456 let original = ManagedSessionRecord {
26457 record_id: record_id.clone(),
26458 display_name: "original".to_owned(),
26459 provider: agent("claude"),
26460 mode: SessionMode::Pty,
26461 state: ManagedSessionState::Dormant,
26462 workspace_id: workspace.workspace_id,
26463 canonical_root: opaque_windows_path(workspace.canonical_root),
26464 provider_session: None,
26465 active_session: None,
26466 environment_profile: None,
26467 bundle: None,
26468 context_id: None,
26469 context: None,
26470 exported_context: None,
26471 task_binding: None,
26472 created_at_unix_ms: 1,
26473 updated_at_unix_ms: 1,
26474 last_error: None,
26475 };
26476 shared.insert_record(original.clone()).unwrap();
26477 let mut collision = original.clone();
26478 collision.display_name = "replacement".to_owned();
26479 let error = shared.insert_record(collision).unwrap_err();
26480 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26481 assert_eq!(shared.record(&record_id).unwrap(), original);
26482 }
26483
26484 #[test]
26485 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26486 let catalog = active_registry().unwrap();
26487 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26488 let workspace = WorkspaceConfig::new(
26489 WorkspaceId::new("primary").unwrap(),
26490 std::env::current_dir().unwrap(),
26491 )
26492 .unwrap();
26493 let shared = NodeShared::new(
26494 handle,
26495 "fixture-token".to_owned(),
26496 NodeId::new("node-1").unwrap(),
26497 vec![workspace.clone()],
26498 vec![agent("claude")],
26499 );
26500 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26501 shared
26502 .insert_record(ManagedSessionRecord {
26503 record_id: record_id.clone(),
26504 display_name: "runtime error".to_owned(),
26505 provider: agent("claude"),
26506 mode: SessionMode::Pty,
26507 state: ManagedSessionState::IdentityPending,
26508 workspace_id: workspace.workspace_id,
26509 canonical_root: opaque_windows_path(workspace.canonical_root),
26510 provider_session: None,
26511 active_session: None,
26512 environment_profile: None,
26513 bundle: None,
26514 context_id: None,
26515 context: None,
26516 exported_context: None,
26517 task_binding: None,
26518 created_at_unix_ms: 1,
26519 updated_at_unix_ms: 1,
26520 last_error: None,
26521 })
26522 .unwrap();
26523
26524 shared
26525 .mark_record_error(
26526 &record_id,
26527 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26528 )
26529 .unwrap();
26530 let record = shared.record(&record_id).unwrap();
26531 assert_eq!(record.state, ManagedSessionState::Unavailable);
26532 assert_eq!(
26533 record.last_error.as_deref(),
26534 Some("provider-runtime-failed"),
26535 );
26536 let snapshot = shared.snapshot();
26537 assert_eq!(snapshot.session_records, vec![record]);
26538 }
26539
26540 #[test]
26549 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26550 let catalog = active_registry().unwrap();
26551 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26552 let workspace = WorkspaceConfig::new(
26553 WorkspaceId::new("primary").unwrap(),
26554 std::env::current_dir().unwrap(),
26555 )
26556 .unwrap();
26557 let shared = NodeShared::new(
26558 handle,
26559 "fixture-token".to_owned(),
26560 NodeId::new("node-1").unwrap(),
26561 vec![workspace],
26562 vec![agent("claude")],
26563 );
26564
26565 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26566
26567 shared.record_rejected_command(
26568 CommandId(1),
26569 "agent 'claude' does not support transport Acp".to_owned(),
26570 true,
26571 );
26572 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26574
26575 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26576 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26577 assert!(reason.unsupported_transport);
26578
26579 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26581
26582 shared.record_rejected_command(
26587 CommandId(3),
26588 "backend busy".to_owned(),
26589 false,
26590 );
26591 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26592 assert!(!unclassified.unsupported_transport);
26593 }
26594
26595 #[test]
26596 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26597 let catalog = active_registry().unwrap();
26598 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26599 let workspace = WorkspaceConfig::new(
26600 WorkspaceId::new("primary").unwrap(),
26601 std::env::current_dir().unwrap(),
26602 )
26603 .unwrap();
26604 let shared = NodeShared::new(
26605 handle,
26606 "fixture-token".to_owned(),
26607 NodeId::new("node-identity-policy").unwrap(),
26608 vec![workspace.clone()],
26609 vec![agent("claude")],
26610 );
26611 let address = SessionAddress {
26612 workspace_id: workspace.workspace_id.clone(),
26613 session: SessionKey {
26614 instance_id: AgentInstanceId(90),
26615 generation: SessionGeneration(1),
26616 },
26617 };
26618 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26619 shared
26620 .insert_record(ManagedSessionRecord {
26621 record_id: record_id.clone(),
26622 display_name: "policy denied".to_owned(),
26623 provider: agent("claude"),
26624 mode: SessionMode::Pty,
26625 state: ManagedSessionState::IdentityPending,
26626 workspace_id: workspace.workspace_id,
26627 canonical_root: opaque_windows_path(workspace.canonical_root),
26628 provider_session: None,
26629 active_session: Some(address.clone()),
26630 environment_profile: None,
26631 bundle: None,
26632 context_id: None,
26633 context: None,
26634 exported_context: None,
26635 task_binding: None,
26636 created_at_unix_ms: 1,
26637 updated_at_unix_ms: 1,
26638 last_error: None,
26639 })
26640 .unwrap();
26641 let event = ControlEvent {
26642 sequence: 1,
26643 command_id: None,
26644 instance_id: address.session.instance_id,
26645 generation: address.session.generation,
26646 event: ControlEventKind::ProviderEvent {
26647 sequence: 1,
26648 source: gate4agent_types::ProviderSource {
26649 family: AdapterFamily::PtySemantic,
26650 binding: AdapterBinding::new(
26651 gate4agent_types::AdapterId::new("claude").unwrap(),
26652 "fixture/v1",
26653 gate4agent_types::AdapterVerification::SyntheticFixture,
26654 )
26655 .unwrap(),
26656 },
26657 source_sequence: 1,
26658 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26659 identity: gate4agent_types::ProviderSessionIdentity {
26660 key: gate4agent_types::ProviderSessionKey::SessionId,
26661 id: "provider-session".to_owned(),
26662 transcript_path: None,
26663 },
26664 },
26665 },
26666 };
26667
26668 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26669
26670 let record = shared.record(&record_id).unwrap();
26671 assert!(record.provider_session.is_none());
26672 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26673 assert_eq!(record.active_session.as_ref(), Some(&address));
26674 }
26675
26676 #[test]
26677 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26678 let secondary_root = temporary_workspace_root("identity-scope");
26679 std::fs::create_dir_all(&secondary_root).unwrap();
26680 let catalog = active_registry().unwrap();
26681 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26682 let primary = WorkspaceConfig::new(
26683 WorkspaceId::new("primary").unwrap(),
26684 std::env::current_dir().unwrap(),
26685 )
26686 .unwrap();
26687 let secondary = WorkspaceConfig::new(
26688 WorkspaceId::new("secondary").unwrap(),
26689 &secondary_root,
26690 )
26691 .unwrap();
26692 let shared = NodeShared::new(
26693 handle,
26694 "fixture-token".to_owned(),
26695 NodeId::new("node-1").unwrap(),
26696 vec![primary.clone(), secondary.clone()],
26697 vec![agent("claude")],
26698 );
26699 let identity = gate4agent_types::ProviderSessionIdentity {
26700 key: gate4agent_types::ProviderSessionKey::SessionId,
26701 id: "provider-session".to_owned(),
26702 transcript_path: None,
26703 };
26704 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26705 shared
26706 .insert_record(ManagedSessionRecord {
26707 record_id: dormant_id.clone(),
26708 display_name: "dormant".to_owned(),
26709 provider: agent("claude"),
26710 mode: SessionMode::Pty,
26711 state: ManagedSessionState::Dormant,
26712 workspace_id: primary.workspace_id,
26713 canonical_root: opaque_windows_path(primary.canonical_root),
26714 provider_session: Some(identity.clone()),
26715 active_session: None,
26716 environment_profile: None,
26717 bundle: None,
26718 context_id: None,
26719 context: None,
26720 exported_context: None,
26721 task_binding: None,
26722 created_at_unix_ms: 1,
26723 updated_at_unix_ms: 1,
26724 last_error: None,
26725 })
26726 .unwrap();
26727 let address = SessionAddress {
26728 workspace_id: secondary.workspace_id.clone(),
26729 session: SessionKey {
26730 instance_id: AgentInstanceId(91),
26731 generation: SessionGeneration(1),
26732 },
26733 };
26734 let current_id = SessionRecordId::new("sr-current").unwrap();
26735 shared
26736 .insert_record(ManagedSessionRecord {
26737 record_id: current_id.clone(),
26738 display_name: "current".to_owned(),
26739 provider: agent("claude"),
26740 mode: SessionMode::Pty,
26741 state: ManagedSessionState::IdentityPending,
26742 workspace_id: secondary.workspace_id,
26743 canonical_root: opaque_windows_path(secondary.canonical_root),
26744 provider_session: None,
26745 active_session: Some(address.clone()),
26746 environment_profile: None,
26747 bundle: None,
26748 context_id: None,
26749 context: None,
26750 exported_context: None,
26751 task_binding: None,
26752 created_at_unix_ms: 2,
26753 updated_at_unix_ms: 2,
26754 last_error: None,
26755 })
26756 .unwrap();
26757 shared.bind_managed_session(
26758 &address,
26759 current_id.clone(),
26760 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26761 None,
26762 );
26763 let event = ControlEvent {
26764 sequence: 1,
26765 command_id: None,
26766 instance_id: address.session.instance_id,
26767 generation: address.session.generation,
26768 event: ControlEventKind::ProviderEvent {
26769 sequence: 1,
26770 source: gate4agent_types::ProviderSource {
26771 family: gate4agent_types::AdapterFamily::PtySemantic,
26772 binding: gate4agent_types::AdapterBinding::new(
26773 gate4agent_types::AdapterId::new("claude").unwrap(),
26774 "fixture/v1",
26775 gate4agent_types::AdapterVerification::SyntheticFixture,
26776 )
26777 .unwrap(),
26778 },
26779 source_sequence: 1,
26780 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26781 identity,
26782 },
26783 },
26784 };
26785 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26786
26787 let binding = shared
26788 .session_bindings
26789 .lock()
26790 .unwrap_or_else(|poisoned| poisoned.into_inner())
26791 .get(&address.session.instance_id)
26792 .cloned()
26793 .unwrap();
26794 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26795 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26796 let current = shared.record(¤t_id).unwrap();
26797 assert_eq!(current.state, ManagedSessionState::Unavailable);
26798 assert_eq!(current.active_session.as_ref(), Some(&address));
26799 assert_eq!(
26800 current.last_error.as_deref(),
26801 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26802 );
26803 std::fs::remove_dir_all(secondary_root).unwrap();
26804 }
26805
26806 #[tokio::test]
26807 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26808 let root = temporary_workspace_root("semantic-provider-session");
26809 std::fs::create_dir_all(&root).unwrap();
26810 let state_path = root.join("state-v1.json");
26811 let catalog = active_registry().unwrap();
26812 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26813 let workspace = WorkspaceConfig::new(
26814 WorkspaceId::new("primary").unwrap(),
26815 &root,
26816 )
26817 .unwrap();
26818 let node_id = NodeId::new("node-semantic-identity").unwrap();
26819 let shared = NodeShared::new_with_incarnation(
26820 handle,
26821 "fixture-token".to_owned(),
26822 node_id.clone(),
26823 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26824 vec![workspace.clone()],
26825 vec![agent("claude")],
26826 ProviderRuntimeStatuses::default(),
26827 None,
26828 Vec::new(),
26829 Vec::new(),
26830 SpawnProfileRegistry::default(),
26831 None,
26832 None,
26833 None,
26834 Some(state_path.clone()),
26835 Vec::new(),
26836 Vec::new(),
26837 Vec::new(),
26838 Vec::new(),
26839 None,
26840 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26841 );
26842 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26843 key: gate4agent_types::ProviderSessionKey::SessionId,
26844 id: "provider-session".to_owned(),
26845 transcript_path: None,
26846 };
26847 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26848 shared
26849 .insert_record(ManagedSessionRecord {
26850 record_id: durable_id.clone(),
26851 display_name: "durable".to_owned(),
26852 provider: agent("claude"),
26853 mode: SessionMode::Pty,
26854 state: ManagedSessionState::Dormant,
26855 workspace_id: workspace.workspace_id.clone(),
26856 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26857 provider_session: Some(persisted_identity.clone()),
26858 active_session: None,
26859 environment_profile: None,
26860 bundle: None,
26861 context_id: None,
26862 context: None,
26863 exported_context: None,
26864 task_binding: None,
26865 created_at_unix_ms: 1,
26866 updated_at_unix_ms: 1,
26867 last_error: None,
26868 })
26869 .unwrap();
26870 let address = SessionAddress {
26871 workspace_id: workspace.workspace_id.clone(),
26872 session: SessionKey {
26873 instance_id: AgentInstanceId(92),
26874 generation: SessionGeneration(2),
26875 },
26876 };
26877 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26878 shared
26879 .insert_record(ManagedSessionRecord {
26880 record_id: pending_id.clone(),
26881 display_name: "pending".to_owned(),
26882 provider: agent("claude"),
26883 mode: SessionMode::Pty,
26884 state: ManagedSessionState::IdentityPending,
26885 workspace_id: workspace.workspace_id.clone(),
26886 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26887 provider_session: None,
26888 active_session: Some(address.clone()),
26889 environment_profile: None,
26890 bundle: None,
26891 context_id: None,
26892 context: None,
26893 exported_context: None,
26894 task_binding: None,
26895 created_at_unix_ms: 2,
26896 updated_at_unix_ms: 2,
26897 last_error: None,
26898 })
26899 .unwrap();
26900 shared.bind_managed_session(
26901 &address,
26902 pending_id.clone(),
26903 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26904 None,
26905 );
26906 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26907 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26908 ..persisted_identity
26909 };
26910 let event = ControlEvent {
26911 sequence: 1,
26912 command_id: None,
26913 instance_id: address.session.instance_id,
26914 generation: address.session.generation,
26915 event: ControlEventKind::ProviderEvent {
26916 sequence: 1,
26917 source: gate4agent_types::ProviderSource {
26918 family: gate4agent_types::AdapterFamily::PtySemantic,
26919 binding: gate4agent_types::AdapterBinding::new(
26920 gate4agent_types::AdapterId::new("claude").unwrap(),
26921 "fixture/v1",
26922 gate4agent_types::AdapterVerification::SyntheticFixture,
26923 )
26924 .unwrap(),
26925 },
26926 source_sequence: 1,
26927 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26928 identity: observed_identity.clone(),
26929 },
26930 },
26931 };
26932 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26933
26934 let snapshot = shared.snapshot();
26935 assert_eq!(snapshot.session_records.len(), 1);
26936 let live = &snapshot.session_records[0];
26937 assert_eq!(live.record_id, durable_id);
26938 assert_eq!(live.state, ManagedSessionState::Live);
26939 assert_eq!(live.active_session.as_ref(), Some(&address));
26940 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26941 let binding = shared
26942 .session_bindings
26943 .lock()
26944 .unwrap_or_else(|poisoned| poisoned.into_inner())
26945 .get(&address.session.instance_id)
26946 .cloned()
26947 .unwrap();
26948 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26949
26950 let mut duplicate = live.clone();
26951 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26952 duplicate.display_name = "duplicate".to_owned();
26953 duplicate.state = ManagedSessionState::Dormant;
26954 duplicate.active_session = None;
26955 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26956 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26957 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26958 assert_eq!(shared.snapshot().session_records.len(), 1);
26959
26960 let terminal_size = gate4agent_types::TerminalSize {
26961 rows: 24,
26962 columns: 80,
26963 };
26964 let (first_busy, second_busy) = tokio::join!(
26965 shared.resume_session_record(&durable_id, terminal_size, None),
26966 shared.resume_session_record(&durable_id, terminal_size, None),
26967 );
26968 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26969 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26970 assert_eq!(busy.message, "session-record-busy");
26971 }
26972
26973 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26974 assert_eq!(loaded.records.len(), 1);
26975 assert_eq!(loaded.records[0].record_id, durable_id);
26976 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26977 assert_eq!(
26978 loaded.records[0]
26979 .provider_session
26980 .as_ref()
26981 .unwrap()
26982 .transcript_path,
26983 None,
26984 );
26985 let catalog = active_registry().unwrap();
26986 let (reloaded_handle, _reloaded_runtime) =
26987 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26988 let reloaded = NodeShared::new_with_incarnation(
26989 reloaded_handle,
26990 "fixture-token".to_owned(),
26991 node_id,
26992 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26993 vec![workspace],
26994 vec![agent("claude")],
26995 ProviderRuntimeStatuses::default(),
26996 None,
26997 Vec::new(),
26998 Vec::new(),
26999 SpawnProfileRegistry::default(),
27000 None,
27001 None,
27002 None,
27003 None,
27004 loaded.records,
27005 loaded.managed_worktrees,
27006 loaded.managed_worktree_tombstones,
27007 loaded.materializations,
27008 loaded.warning,
27009 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27010 );
27011 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27012 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27013 std::fs::remove_dir_all(root).unwrap();
27014 }
27015
27016 #[tokio::test]
27017 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27018 let secondary_root = temporary_workspace_root("workspace-restore");
27019 let other_root = temporary_workspace_root("workspace-conflict");
27020 std::fs::create_dir_all(&secondary_root).unwrap();
27021 std::fs::create_dir_all(&other_root).unwrap();
27022 let catalog = active_registry().unwrap();
27023 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27024 let primary = WorkspaceConfig::new(
27025 WorkspaceId::new("primary").unwrap(),
27026 std::env::current_dir().unwrap(),
27027 )
27028 .unwrap();
27029 let secondary = WorkspaceConfig::new(
27030 WorkspaceId::new("secondary").unwrap(),
27031 &secondary_root,
27032 )
27033 .unwrap();
27034 let shared = NodeShared::new(
27035 handle,
27036 "fixture-token".to_owned(),
27037 NodeId::new("node-1").unwrap(),
27038 vec![primary, secondary.clone()],
27039 vec![agent("claude")],
27040 );
27041 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27042 shared
27043 .insert_record(ManagedSessionRecord {
27044 record_id: record_id.clone(),
27045 display_name: "workspace record".to_owned(),
27046 provider: agent("claude"),
27047 mode: SessionMode::Pty,
27048 state: ManagedSessionState::Dormant,
27049 workspace_id: secondary.workspace_id.clone(),
27050 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27051 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27052 key: gate4agent_types::ProviderSessionKey::SessionId,
27053 id: "provider-session".to_owned(),
27054 transcript_path: None,
27055 }),
27056 active_session: None,
27057 environment_profile: None,
27058 bundle: None,
27059 context_id: None,
27060 context: None,
27061 exported_context: None,
27062 task_binding: None,
27063 created_at_unix_ms: 1,
27064 updated_at_unix_ms: 1,
27065 last_error: None,
27066 })
27067 .unwrap();
27068 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27069 let unavailable = shared.record(&record_id).unwrap();
27070 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27071 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27072
27073 let conflict = shared
27074 .register_workspace(
27075 secondary.workspace_id.clone(),
27076 other_root.to_string_lossy().into_owned(),
27077 )
27078 .await
27079 .unwrap_err();
27080 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27081 shared
27082 .register_workspace(
27083 secondary.workspace_id,
27084 secondary_root.to_string_lossy().into_owned(),
27085 )
27086 .await
27087 .unwrap();
27088 let restored = shared.record(&record_id).unwrap();
27089 assert_eq!(restored.state, ManagedSessionState::Dormant);
27090 assert!(restored.last_error.is_none());
27091 std::fs::remove_dir_all(secondary_root).unwrap();
27092 std::fs::remove_dir_all(other_root).unwrap();
27093 }
27094
27095 #[test]
27096 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27097 let error = validate_workspace_request_root(
27098 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27099 )
27100 .unwrap_err();
27101 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27102 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27103 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27104 }
27105
27106 #[test]
27107 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27108 let catalog = active_registry().unwrap();
27109 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27110 let workspace = WorkspaceConfig::new(
27111 WorkspaceId::new("primary").unwrap(),
27112 std::env::current_dir().unwrap(),
27113 )
27114 .unwrap();
27115 let shared = NodeShared::new(
27116 handle,
27117 "fixture-token".to_owned(),
27118 NodeId::new("node-1").unwrap(),
27119 vec![workspace],
27120 vec![agent("claude")],
27121 );
27122 let publishers = 4_u64;
27123 let events_per_publisher = 256_u64;
27124 let start = std::sync::Barrier::new(publishers as usize + 1);
27125 let finished = AtomicU64::new(0);
27126
27127 std::thread::scope(|scope| {
27128 for _ in 0..publishers {
27129 scope.spawn(|| {
27130 start.wait();
27131 for _ in 0..events_per_publisher {
27132 shared.publish(NodeEvent::ControllerChanged { controller: None });
27133 }
27134 finished.fetch_add(1, Ordering::Release);
27135 });
27136 }
27137 start.wait();
27138 while finished.load(Ordering::Acquire) != publishers {
27139 assert_resync_history_is_linearized(shared.resync(0));
27140 std::thread::yield_now();
27141 }
27142 });
27143 let response = shared.resync(0);
27144 assert_resync_history_is_linearized(response.clone());
27145 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27146 unreachable!("resync helper returned another response");
27147 };
27148 assert_eq!(event_sequence, publishers * events_per_publisher);
27149 assert_eq!(events.len() as u64, event_sequence);
27150 }
27151
27152 fn assert_resync_history_is_linearized(response: NodeResponse) {
27153 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27154 unreachable!("resync helper returned another response");
27155 };
27156 assert!(events.windows(2).all(|pair| {
27157 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27158 }));
27159 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27160 }
27161
27162 #[test]
27163 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27164 assert_eq!(
27165 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27166 r"C:\repo\worktree",
27167 );
27168 assert_eq!(
27169 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27170 r"\\server\share\repo",
27171 );
27172 assert!(validate_workspace_root(
27173 &WorkspaceId::new("network").unwrap(),
27174 r"\\server\share\repo",
27175 )
27176 .is_err());
27177 let workspace = WorkspaceConfig::new(
27178 WorkspaceId::new("primary").unwrap(),
27179 std::env::current_dir().unwrap(),
27180 )
27181 .unwrap();
27182 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27183 }
27184
27185 #[test]
27186 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27187 let catalog = active_registry().unwrap();
27188 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27189 let workspace_id = WorkspaceId::new("primary").unwrap();
27190 let workspace = WorkspaceConfig::new(
27191 workspace_id.clone(),
27192 std::env::current_dir().unwrap(),
27193 )
27194 .unwrap();
27195 let shared = NodeShared::new(
27196 handle,
27197 "fixture-token".to_owned(),
27198 NodeId::new("node-1").unwrap(),
27199 vec![workspace],
27200 vec![agent("claude")],
27201 );
27202 let instance_id = AgentInstanceId(17);
27203 let original = SessionAddress {
27204 workspace_id,
27205 session: SessionKey {
27206 instance_id,
27207 generation: SessionGeneration(3),
27208 },
27209 };
27210 shared.bind_session(
27211 instance_id,
27212 original.workspace_id.clone(),
27213 original.session.generation,
27214 );
27215 shared
27216 .arm_resume(
27217 &original,
27218 CommandId(41),
27219 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27220 )
27221 .unwrap();
27222
27223 shared.publish_control(ControlEvent {
27224 sequence: 1,
27225 command_id: None,
27226 instance_id,
27227 generation: SessionGeneration(4),
27228 event: ControlEventKind::Running { process_id: None },
27229 });
27230 assert_eq!(
27231 shared.address_for(instance_id, SessionGeneration(3)),
27232 Some(original.clone()),
27233 );
27234 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27235
27236 shared.publish_control(ControlEvent {
27237 sequence: 2,
27238 command_id: Some(CommandId(42)),
27239 instance_id,
27240 generation: SessionGeneration(3),
27241 event: ControlEventKind::CommandRejected {
27242 message: "unrelated command".to_owned(),
27243 },
27244 });
27245
27246 shared.publish_control(ControlEvent {
27247 sequence: 3,
27248 command_id: None,
27249 instance_id,
27250 generation: SessionGeneration(4),
27251 event: ControlEventKind::ResumeAuthorized {
27252 session: gate4agent_types::ResumeSessionSummary {
27253 key: gate4agent_types::ProviderSessionKey::SessionId,
27254 id: "fixture-session".to_owned(),
27255 },
27256 },
27257 });
27258 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27259 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27260 assert_eq!(rebound.workspace_id, original.workspace_id);
27261 let history = shared
27262 .history
27263 .lock()
27264 .unwrap_or_else(|poisoned| poisoned.into_inner());
27265 assert!(history.events.iter().any(|envelope| matches!(
27266 &envelope.event,
27267 NodeEvent::Control { address, event }
27268 if address == &rebound
27269 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27270 )));
27271 }
27272
27273 #[test]
27274 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27275 for agent in ["claude", "codex", "kimi"] {
27276 assert_eq!(
27277 prompt_framing(&AgentId::new(agent).unwrap()),
27278 PromptFraming::BracketedPaste
27279 );
27280 }
27281 }
27282
27283 fn generous_walk_deadline() -> Instant {
27284 Instant::now() + Duration::from_secs(30)
27285 }
27286
27287 #[test]
27288 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27289 let root = temporary_workspace_root("tree");
27290 std::fs::create_dir_all(root.join("src")).unwrap();
27291 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27292 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27293 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27294 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27295 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27296 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27302 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27303 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27307 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27308
27309 let (entries, truncated, _budget) =
27310 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27311 assert!(!truncated);
27312 assert!(entries.iter().any(|entry| {
27313 entry.relative_path.as_utf8() == Some("src")
27314 && entry.kind == WorkspaceEntryKind::Directory
27315 }));
27316 assert!(entries.iter().any(|entry| {
27317 entry.relative_path.as_utf8() == Some("src/lib.rs")
27318 && entry.kind == WorkspaceEntryKind::File
27319 }));
27320 assert!(entries.iter().any(|entry| {
27321 entry.relative_path.as_utf8() == Some("target-notes")
27322 && entry.kind == WorkspaceEntryKind::Directory
27323 }));
27324 assert!(entries.iter().any(|entry| {
27325 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27326 && entry.kind == WorkspaceEntryKind::File
27327 }));
27328 assert!(entries.iter().all(|entry| {
27329 let path = entry.relative_path.as_utf8().unwrap();
27330 !path.starts_with(".git")
27331 && !(path == "target" || path.starts_with("target/"))
27332 && !(path == "target-a2" || path.starts_with("target-a2/"))
27333 && !path.starts_with("node_modules")
27334 }));
27335
27336 std::fs::remove_dir_all(&root).unwrap();
27337 }
27338
27339 #[test]
27340 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27341 let root = temporary_workspace_root("tree-entry-cap");
27342 std::fs::create_dir_all(&root).unwrap();
27343 for index in 0..20 {
27344 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27345 }
27346
27347 let (entries, truncated, budget) =
27348 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27349 assert!(truncated);
27350 assert!(budget.entry_cap_exceeded);
27351 assert!(!budget.time_budget_exceeded);
27352 assert!(entries.len() <= 5);
27353
27354 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27355 .expect("an entry-cap-truncated walk must report a truncation marker");
27356 assert!(inspection_truncation.walk_entry_cap_exceeded);
27357 assert!(!inspection_truncation.walk_time_budget_exceeded);
27358 assert!(!inspection_truncation.git_time_budget_exceeded);
27359 assert!(inspection_truncation.entries_visited >= 5);
27360
27361 std::fs::remove_dir_all(&root).unwrap();
27362 }
27363
27364 #[test]
27365 fn workspace_walk_within_budget_reports_no_truncation() {
27366 let root = temporary_workspace_root("tree-no-truncation");
27367 std::fs::create_dir_all(&root).unwrap();
27368 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27369
27370 let (_entries, truncated, budget) =
27371 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27372 assert!(!truncated);
27373 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27374
27375 std::fs::remove_dir_all(&root).unwrap();
27376 }
27377
27378 #[test]
27379 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27380 let root = temporary_workspace_root("tree-prefix-siblings");
27381 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27382 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27383 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27384 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27385
27386 let (entries, truncated, _budget) =
27387 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27388 assert!(!truncated);
27389 let paths = entries
27390 .iter()
27391 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27392 .collect::<Vec<_>>();
27393 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27394 assert_eq!(
27395 paths,
27396 vec![
27397 "gate4agent-c2".to_owned(),
27398 "gate4agent-c2-client".to_owned(),
27399 "gate4agent-c2-client/lib.rs".to_owned(),
27400 "gate4agent-c2/src".to_owned(),
27401 "gate4agent-c2/src/lib.rs".to_owned(),
27402 ],
27403 );
27404
27405 std::fs::remove_dir_all(&root).unwrap();
27406 }
27407
27408 #[test]
27409 fn git_parsers_cap_status_and_commit_results() {
27410 let mut status_output = Vec::new();
27411 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27412 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27413 }
27414 let mut snapshot = git_snapshot_for_parser();
27415 parse_git_status(&status_output, &mut snapshot);
27416 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27417 assert!(snapshot.truncated);
27418 assert_eq!(snapshot.status[0].index_status, " ");
27419 assert_eq!(snapshot.status[0].worktree_status, "M");
27420
27421 snapshot.truncated = false;
27422 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27423 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27424 .collect::<String>();
27425 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27426 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27427 assert!(snapshot.truncated);
27428 }
27429
27430 #[test]
27431 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27432 let mut snapshot = git_snapshot_for_parser();
27433 parse_git_status(
27434 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27435 &mut snapshot,
27436 );
27437
27438 assert!(!snapshot.truncated);
27439 assert_eq!(snapshot.status.len(), 3);
27440 assert_eq!(snapshot.status[0].index_status, "R");
27441 assert_eq!(snapshot.status[0].worktree_status, " ");
27442 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27443 assert_eq!(
27444 snapshot.status[0]
27445 .previous_path
27446 .as_ref()
27447 .and_then(RepositoryPath::as_utf8),
27448 Some("src/previous name.rs"),
27449 );
27450 assert_eq!(snapshot.status[1].index_status, " ");
27451 assert_eq!(snapshot.status[1].worktree_status, "C");
27452 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27453 assert_eq!(
27454 snapshot.status[1]
27455 .previous_path
27456 .as_ref()
27457 .and_then(RepositoryPath::as_utf8),
27458 Some("src/original.rs"),
27459 );
27460 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27461 assert!(snapshot.status[2].previous_path.is_none());
27462 }
27463
27464 #[test]
27465 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27466 let mut snapshot = git_snapshot_for_parser();
27467 parse_git_status(
27468 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27469 &mut snapshot,
27470 );
27471
27472 assert!(snapshot.truncated);
27473 assert_eq!(snapshot.status.len(), 1);
27474 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27475 assert!(snapshot.status[0].previous_path.is_none());
27476 }
27477
27478 #[tokio::test]
27479 async fn observer_can_browse_host_directories_without_controller() {
27480 let root = temporary_workspace_root("host-directory-observer");
27481 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27482 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27483 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27484 let workspace_id = WorkspaceId::new("primary").unwrap();
27485 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27486 let catalog = active_registry().unwrap();
27487 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27488 let shared = NodeShared::new(
27489 handle,
27490 "fixture-token".to_owned(),
27491 NodeId::new("node-1").unwrap(),
27492 vec![workspace],
27493 vec![agent("claude")],
27494 );
27495 let request = NodeRequest::BrowseHostDirectories {
27496 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27497 after: None,
27498 };
27499 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27500 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27501 }));
27502 assert!(!request_uses_unnegotiated_capability(
27503 &request,
27504 &baseline_capabilities().unwrap(),
27505 ));
27506 assert!(request_uses_unnegotiated_capability(&request, &[]));
27507
27508 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27509 .await
27510 .unwrap();
27511 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27512 panic!("host directory browse returned another response");
27513 };
27514 assert_eq!(
27515 listing
27516 .entries
27517 .iter()
27518 .map(|entry| entry.display_name.as_str())
27519 .collect::<Vec<_>>(),
27520 vec!["directory-a", "directory-b"],
27521 );
27522 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27523 assert!(!listing.incomplete);
27524 std::fs::remove_dir_all(root).unwrap();
27525 }
27526
27527 #[tokio::test]
27528 async fn observer_can_inspect_only_a_registered_workspace() {
27529 let root = temporary_workspace_root("observer");
27530 std::fs::create_dir_all(root.join("src")).unwrap();
27531 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27532 let workspace_id = WorkspaceId::new("primary").unwrap();
27533 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27534 let catalog = active_registry().unwrap();
27535 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27536 let shared = NodeShared::new(
27537 handle,
27538 "fixture-token".to_owned(),
27539 NodeId::new("node-1").unwrap(),
27540 vec![workspace],
27541 vec![agent("claude")],
27542 );
27543
27544 let response = process_request_inner(
27545 &shared,
27546 77,
27547 ClientRole::Observer,
27548 NodeRequest::InspectWorkspace {
27549 workspace_id: workspace_id.clone(),
27550 },
27551 )
27552 .await
27553 .unwrap();
27554 let NodeResponse::WorkspaceInspected { inspection } = response else {
27555 panic!("workspace inspection returned another response");
27556 };
27557 assert_eq!(inspection.workspace_id, workspace_id);
27558 assert!(inspection.entries.iter().any(|entry| {
27559 entry.relative_path.as_utf8() == Some("src/main.rs")
27560 }));
27561
27562 let unknown = process_request_inner(
27563 &shared,
27564 77,
27565 ClientRole::Observer,
27566 NodeRequest::InspectWorkspace {
27567 workspace_id: WorkspaceId::new("unknown").unwrap(),
27568 },
27569 )
27570 .await
27571 .unwrap_err();
27572 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27573 std::fs::remove_dir_all(&root).unwrap();
27574 }
27575
27576 #[test]
27577 fn context_pack_source_accepts_only_running_or_successful_exit() {
27578 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27579 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27580 exit_code: Some(0),
27581 }));
27582 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27583 exit_code: Some(1),
27584 }));
27585 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27586 exit_code: None,
27587 }));
27588 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27589 message: "provider failed".to_owned(),
27590 }));
27591 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27592 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27593 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27594 }
27595
27596 #[test]
27597 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27598 let git = git_snapshot_for_parser();
27599 let first = ContextPackRepositoryObservation {
27600 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27601 git: git.clone(),
27602 files: Vec::new(),
27603 };
27604 let second = ContextPackRepositoryObservation {
27605 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27606 git,
27607 files: Vec::new(),
27608 };
27609
27610 let error = stable_context_pack_repository(first, second).unwrap_err();
27611
27612 assert_eq!(
27613 error.code,
27614 NodeFailureCode::ContextPackMaterializationFailed,
27615 );
27616 }
27617
27618 #[test]
27619 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27620 let git = git_snapshot_for_parser();
27621 let first = ContextPackRepositoryObservation {
27622 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27623 git: git.clone(),
27624 files: vec![ContextPackRepositoryFileSource::utf8(
27625 "README.md",
27626 "first\n".to_owned(),
27627 6,
27628 )],
27629 };
27630 let second = ContextPackRepositoryObservation {
27631 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27632 git,
27633 files: vec![ContextPackRepositoryFileSource::utf8(
27634 "README.md",
27635 "other\n".to_owned(),
27636 6,
27637 )],
27638 };
27639
27640 let error = stable_context_pack_repository(first, second).unwrap_err();
27641
27642 assert_eq!(
27643 error.code,
27644 NodeFailureCode::ContextPackMaterializationFailed,
27645 );
27646 }
27647
27648 #[tokio::test]
27649 async fn native_session_catalog_zero_active_session_fixture() {
27650 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27651 let history_root = temporary_workspace_root("native-catalog-history");
27652 let outside_root = temporary_workspace_root("native-catalog-outside");
27653 std::fs::create_dir_all(&workspace_root).unwrap();
27654 std::fs::create_dir_all(&history_root).unwrap();
27655 std::fs::create_dir_all(&outside_root).unwrap();
27656 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27657 [
27658 serde_json::json!({
27659 "type": "user",
27660 "sessionId": session_id,
27661 "cwd": cwd.to_str().unwrap(),
27662 "message": { "content": "raw user text must not cross the catalog wire" }
27663 }),
27664 serde_json::json!({
27665 "type": "assistant",
27666 "sessionId": session_id,
27667 "cwd": cwd.to_str().unwrap(),
27668 "message": { "model": "claude-sonnet", "content": "raw answer" }
27669 }),
27670 serde_json::json!({
27671 "type": "ai-title",
27672 "sessionId": session_id,
27673 "aiTitle": title
27674 }),
27675 ]
27676 .into_iter()
27677 .map(|record| record.to_string())
27678 .collect::<Vec<_>>()
27679 .join("\n")
27680 };
27681 std::fs::write(
27682 history_root.join("stable-session.jsonl"),
27683 transcript("stable-session", &workspace_root, "Workspace review"),
27684 )
27685 .unwrap();
27686 std::fs::write(
27687 history_root.join("outside-session.jsonl"),
27688 transcript("outside-session", &outside_root, "Outside review"),
27689 )
27690 .unwrap();
27691 let history = NativeHistoryConfig::new(vec![
27692 NativeHistoryRoot::new(
27693 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27694 HistorySourceLayout::SingleNdjson,
27695 history_root.clone(),
27696 )
27697 .unwrap(),
27698 ])
27699 .unwrap();
27700 let catalog = active_registry().unwrap();
27701 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27702 assert!(handle.snapshot().sessions.is_empty());
27703 let workspace_id = WorkspaceId::new("primary").unwrap();
27704 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27705 let mut shared = NodeShared::new(
27706 handle,
27707 "fixture-token".to_owned(),
27708 NodeId::new("node-native-catalog").unwrap(),
27709 vec![workspace],
27710 vec![agent("claude")],
27711 );
27712 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27713 NativeSessionCatalogAuthority::new(history),
27714 )));
27715 let route = NativeSessionCatalogRoute::workspace(
27716 workspace_id.clone(),
27717 agent("claude"),
27718 );
27719 let request = NodeRequest::CatalogNativeSessions {
27720 route: route.clone(),
27721 limit: 2,
27722 };
27723 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27724 .await
27725 .unwrap_err();
27726 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27727 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27728 .await
27729 .unwrap();
27730 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27731 panic!("native catalog returned a different response");
27732 };
27733 let summary = summary.expect("node must emit native session catalog summary metadata");
27734 assert_eq!(entries.len(), 1);
27735 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27736 assert_eq!(entries[0].external_group, None);
27737 assert_eq!(entries[0].record_id, None);
27738 assert_eq!(entries[0].title, None);
27739 assert_eq!(entries[0].model, None);
27740 assert_eq!(entries[0].message_count, 0);
27741 let encoded_entries = serde_json::to_string(&entries).unwrap();
27742 assert!(!encoded_entries.contains("stable-session"));
27743 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27744 let page_request = NodeRequest::PageNativeSessions {
27745 route: route.clone(),
27746 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27747 catalog_revision: summary.catalog_revision,
27748 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27749 after_selection_id: None,
27750 limit: 2,
27751 };
27752 let observer = process_request_inner(
27753 &shared,
27754 7,
27755 ClientRole::Observer,
27756 page_request.clone(),
27757 )
27758 .await
27759 .unwrap_err();
27760 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27761 let page_response = process_request_inner(
27762 &shared,
27763 7,
27764 ClientRole::Operator,
27765 page_request.clone(),
27766 )
27767 .await
27768 .unwrap();
27769 assert!(matches!(
27770 page_response,
27771 NodeResponse::NativeSessionsPaged { page, .. }
27772 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27773 ));
27774 let stale = process_request_inner(
27775 &shared,
27776 7,
27777 ClientRole::Operator,
27778 NodeRequest::PageNativeSessions {
27779 route: route.clone(),
27780 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27781 catalog_revision: summary.catalog_revision.wrapping_add(1),
27782 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27783 after_selection_id: None,
27784 limit: 2,
27785 },
27786 )
27787 .await
27788 .unwrap_err();
27789 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27790 let selection = NativeSessionSelection {
27791 route: route.clone(),
27792 catalog_revision: summary.catalog_revision,
27793 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27794 selection_id: entries[0].selection_id.clone(),
27795 };
27796 let preview_response = process_request_inner(
27797 &shared,
27798 7,
27799 ClientRole::Operator,
27800 NodeRequest::PreviewNativeSession {
27801 selection: selection.clone(),
27802 message_limit: 1,
27803 },
27804 )
27805 .await
27806 .unwrap();
27807 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27808 panic!("native preview returned a different response");
27809 };
27810 assert_eq!(preview.messages.len(), 1);
27811 assert!(preview.message_count_exact);
27812 assert_eq!(preview.messages[0].text, "raw answer");
27813 assert!(preview.truncated);
27814
27815 let index_request = NodeRequest::IndexNativeSession {
27816 selection: selection.clone(),
27817 display_name: "Saved Claude".to_owned(),
27818 };
27819 let observer = process_request_inner(
27820 &shared,
27821 7,
27822 ClientRole::Observer,
27823 index_request.clone(),
27824 )
27825 .await
27826 .unwrap_err();
27827 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27828 let without_controller = process_request_inner(
27829 &shared,
27830 7,
27831 ClientRole::Operator,
27832 index_request.clone(),
27833 )
27834 .await
27835 .unwrap_err();
27836 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27837 let acquired = process_request_inner(
27838 &shared,
27839 7,
27840 ClientRole::Operator,
27841 NodeRequest::AcquireController { lease_ms: 5_000 },
27842 )
27843 .await
27844 .unwrap();
27845 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27846 let indexed = process_request_inner(
27847 &shared,
27848 7,
27849 ClientRole::Operator,
27850 index_request,
27851 )
27852 .await
27853 .unwrap();
27854 assert!(indexed.native_session_index_contract_is_valid());
27855 let NodeResponse::NativeSessionIndexed {
27856 selection: echoed_selection,
27857 record,
27858 } = indexed else {
27859 panic!("native session index returned a different response");
27860 };
27861 assert_eq!(echoed_selection, selection);
27862 let recataloged = process_request_inner(
27863 &shared,
27864 7,
27865 ClientRole::Operator,
27866 NodeRequest::CatalogNativeSessions {
27867 route,
27868 limit: 2,
27869 },
27870 )
27871 .await
27872 .unwrap();
27873 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27874 panic!("native catalog returned a different response after indexing");
27875 };
27876 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27877
27878 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27879 let external_catalog = process_request_inner(
27880 &shared,
27881 7,
27882 ClientRole::Operator,
27883 NodeRequest::CatalogNativeSessions {
27884 route: unregistered_route.clone(),
27885 limit: 2,
27886 },
27887 )
27888 .await
27889 .unwrap();
27890 let external_json = serde_json::to_string(&external_catalog).unwrap();
27891 assert!(!external_json.contains("outside-session"));
27892 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27893 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27894 assert!(!external_json.contains("\"session_id\""));
27895 assert!(!external_json.contains("\"cwd\""));
27896 assert!(!external_json.contains("\"path\""));
27897 let NodeResponse::NativeSessionsCataloged {
27898 route: echoed_route,
27899 entries: external_entries,
27900 summary: Some(external_summary),
27901 } = external_catalog
27902 else {
27903 panic!("unregistered native catalog returned a different response");
27904 };
27905 assert_eq!(echoed_route, unregistered_route);
27906 assert_eq!(external_entries.len(), 1);
27907 assert_eq!(external_entries[0].record_id, None);
27908 let external_group = external_entries[0]
27909 .external_group
27910 .as_ref()
27911 .expect("unregistered session must have an external group");
27912 assert_eq!(
27913 external_group.kind,
27914 gate4agent_types::NativeSessionExternalGroupKind::Project,
27915 );
27916 assert!(external_group.group_id.starts_with("external-"));
27917 assert_eq!(
27918 external_group.display_name,
27919 outside_root.file_name().unwrap().to_string_lossy(),
27920 );
27921 let external_selection = NativeSessionSelection {
27922 route: unregistered_route,
27923 catalog_revision: external_summary.catalog_revision,
27924 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27925 selection_id: external_entries[0].selection_id.clone(),
27926 };
27927 let external_preview = process_request_inner(
27928 &shared,
27929 7,
27930 ClientRole::Operator,
27931 NodeRequest::PreviewNativeSession {
27932 selection: external_selection.clone(),
27933 message_limit: 1,
27934 },
27935 )
27936 .await
27937 .unwrap();
27938 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27939 assert!(!external_preview_json.contains("outside-session"));
27940 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27941 assert!(!external_preview_json.contains("\"session_id\""));
27942 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27943 panic!("unregistered native preview returned a different response");
27944 };
27945 assert_eq!(preview.messages.len(), 1);
27946 let external_index = process_request_inner(
27947 &shared,
27948 7,
27949 ClientRole::Operator,
27950 NodeRequest::IndexNativeSession {
27951 selection: external_selection,
27952 display_name: "External Claude".to_owned(),
27953 },
27954 )
27955 .await
27956 .unwrap_err();
27957 assert_eq!(
27958 external_index.code,
27959 NodeFailureCode::WorkspaceRegistrationRequired,
27960 );
27961 let record_response = process_request_inner(
27962 &shared,
27963 7,
27964 ClientRole::Operator,
27965 NodeRequest::PreviewSessionRecord {
27966 record_id: record.record_id.clone(),
27967 message_limit: 2,
27968 },
27969 )
27970 .await
27971 .unwrap();
27972 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27973 panic!("record preview returned a different response");
27974 };
27975 assert_eq!(record_id, record.record_id);
27976 assert_eq!(preview.messages.len(), 2);
27977 let encoded = serde_json::to_string(&preview).unwrap();
27978 assert!(!encoded.contains("stable-session"));
27979 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27980 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27981 panic!("record preview history summary returned a different response");
27982 };
27983 let summaries = events
27984 .iter()
27985 .filter_map(|envelope| match &envelope.event {
27986 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27987 if record_id == &record.record_id =>
27988 {
27989 Some(*summary)
27990 }
27991 _ => None,
27992 })
27993 .collect::<Vec<_>>();
27994 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27995 let summary_json = serde_json::to_string(&summaries).unwrap();
27996 for private in [
27997 "stable-session",
27998 "raw user text must not cross the catalog wire",
27999 "raw answer",
28000 "Workspace review",
28001 "claude-sonnet",
28002 ] {
28003 assert!(!summary_json.contains(private), "{private}");
28004 }
28005 let identity = record.provider_session.clone().unwrap();
28006 assert_eq!(
28007 shared.revalidate_session_record_preview(&record, &identity),
28008 Ok(())
28009 );
28010 shared.remove_record_memory(&record.record_id);
28011 assert_eq!(
28012 shared
28013 .revalidate_session_record_preview(&record, &identity)
28014 .unwrap_err()
28015 .code,
28016 NodeFailureCode::UnknownSessionRecord
28017 );
28018 let released = process_request_inner(
28019 &shared,
28020 7,
28021 ClientRole::Operator,
28022 NodeRequest::ReleaseController,
28023 )
28024 .await
28025 .unwrap();
28026 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28027 assert!(shared.controller_state().is_none());
28028 std::fs::remove_dir_all(workspace_root).unwrap();
28029 std::fs::remove_dir_all(history_root).unwrap();
28030 std::fs::remove_dir_all(outside_root).unwrap();
28031 }
28032
28033 #[tokio::test]
28034 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28035 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28036 std::fs::create_dir_all(&history_root).unwrap();
28037 let history = NativeHistoryConfig::new(vec![
28038 NativeHistoryRoot::new(
28039 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28040 HistorySourceLayout::SingleNdjson,
28041 history_root.clone(),
28042 )
28043 .unwrap(),
28044 ])
28045 .unwrap();
28046 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28047 let held = catalog.lock().unwrap();
28048 let result = timeout(
28049 Duration::from_secs(1),
28050 run_native_session_catalog_operation(
28051 Arc::clone(&catalog),
28052 "native session catalog",
28053 |_| Ok::<(), std::convert::Infallible>(()),
28054 ),
28055 )
28056 .await
28057 .expect("concurrent native catalog request must not wait for the operation deadline")
28058 .unwrap_err();
28059 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28060 drop(held);
28061 std::fs::remove_dir_all(history_root).unwrap();
28062 }
28063
28064 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28065 let unique = std::time::SystemTime::now()
28066 .duration_since(std::time::UNIX_EPOCH)
28067 .unwrap()
28068 .as_nanos();
28069 std::env::temp_dir().join(format!(
28070 "gate4agent-node-{label}-{}-{unique}",
28071 std::process::id(),
28072 ))
28073 }
28074
28075 fn git_snapshot_for_parser() -> GitSnapshot {
28076 GitSnapshot {
28077 is_repository: true,
28078 branch: Some("main".to_owned()),
28079 status: Vec::new(),
28080 recent_commits: Vec::new(),
28081 worktrees: Vec::new(),
28082 managed_worktree: None,
28083 truncated: false,
28084 diagnostic: None,
28085 }
28086 }
28087
28088 #[tokio::test]
28089 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28090 let root = temporary_workspace_root("managed-git-scope");
28091 std::fs::create_dir_all(&root).unwrap();
28092 run_test_git(&root, &["init"]);
28093 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28094 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28095 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28096 run_test_git(&root, &["add", "seed.txt"]);
28097 run_test_git(&root, &["commit", "-m", "seed"]);
28098 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28099
28100 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28101 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28102 let catalog = active_registry().unwrap();
28103 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28104 let shared = NodeShared::new(
28105 handle,
28106 "fixture-token".to_owned(),
28107 NodeId::new("node-1").unwrap(),
28108 vec![workspace],
28109 vec![agent("claude")],
28110 );
28111 let mut lease = ManagedWorktreeLeaseRecord {
28112 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28113 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28114 workspace_id: workspace_id.clone(),
28115 profile_id: WorktreeProfileId::new("default").unwrap(),
28116 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28117 target_root: root.to_string_lossy().into_owned(),
28118 branch: "gate4agent/a".to_owned(),
28119 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28120 expected_head: None,
28121 retention: ManagedWorktreeRetention::Retain,
28122 state: ManagedWorktreeLeaseState::InUse,
28123 session_holders: vec![ManagedWorktreeSessionHolder {
28124 incarnation_id: shared.incarnation_id,
28125 instance_id: AgentInstanceId(7),
28126 generation: SessionGeneration(1),
28127 }],
28128 record_holders: Vec::new(),
28129 cleanup_failure: None,
28130 created_at_unix_ms: 1,
28131 updated_at_unix_ms: 1,
28132 };
28133 *shared
28134 .managed_worktrees
28135 .lock()
28136 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28137 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28138
28139 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28140 let scope = inspection.git.managed_worktree.unwrap();
28141 assert_eq!(scope.lease_id, lease.lease_id);
28142 assert_eq!(scope.branch, "gate4agent/a");
28143 assert_eq!(scope.active_session_count, 1);
28144
28145 lease.branch = "gate4agent/b".to_owned();
28146 *shared
28147 .managed_worktrees
28148 .lock()
28149 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28150 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28151 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28152 assert!(mismatch.git.managed_worktree.is_none());
28153 std::fs::remove_dir_all(root).unwrap();
28154 }
28155
28156 #[tokio::test]
28157 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28158 let root = temporary_workspace_root("workspace-file-write-controller");
28159 std::fs::create_dir_all(root.join("src")).unwrap();
28160 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28161 let workspace_id = WorkspaceId::new("primary").unwrap();
28162 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28163 let catalog = active_registry().unwrap();
28164 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28165 let shared = NodeShared::new(
28166 handle,
28167 "fixture-token".to_owned(),
28168 NodeId::new("node-1").unwrap(),
28169 vec![workspace],
28170 vec![agent("claude")],
28171 );
28172 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28173 let expected_revision = workspace_file_revision(b"old\n");
28174 let request = NodeRequest::WriteWorkspaceFile {
28175 workspace_id: workspace_id.clone(),
28176 path: path.clone(),
28177 expected_revision: expected_revision.clone(),
28178 text: "new\n".to_owned(),
28179 };
28180 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28181 .await
28182 .unwrap_err();
28183 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28184 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28185 .await
28186 .unwrap_err();
28187 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28188 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28189 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28190 let NodeResponse::WorkspaceFileWritten { file } = response else {
28191 panic!("workspace file write returned another response");
28192 };
28193 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28194 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28195 let stale = process_request_inner(
28196 &shared,
28197 77,
28198 ClientRole::Operator,
28199 NodeRequest::WriteWorkspaceFile {
28200 workspace_id,
28201 path,
28202 expected_revision,
28203 text: "lost update\n".to_owned(),
28204 },
28205 )
28206 .await
28207 .unwrap_err();
28208 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28209 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28210 std::fs::remove_dir_all(root).unwrap();
28211 }
28212
28213 #[tokio::test]
28214 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28215 let root = temporary_workspace_root("workspace-entry-create-controller");
28216 std::fs::create_dir_all(root.join("src")).unwrap();
28217 let workspace_id = WorkspaceId::new("primary").unwrap();
28218 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28219 let catalog = active_registry().unwrap();
28220 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28221 let shared = NodeShared::new(
28222 handle,
28223 "fixture-token".to_owned(),
28224 NodeId::new("node-1").unwrap(),
28225 vec![workspace],
28226 vec![agent("claude")],
28227 );
28228 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28229 let request = NodeRequest::CreateWorkspaceFile {
28230 workspace_id: workspace_id.clone(),
28231 path: path.clone(),
28232 };
28233 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28234 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28235 }));
28236 assert!(!request_uses_unnegotiated_capability(
28237 &request,
28238 &baseline_capabilities().unwrap(),
28239 ));
28240 assert!(request_uses_unnegotiated_capability(&request, &[]));
28241 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28242 .await
28243 .unwrap_err();
28244 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28245 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28246 .await
28247 .unwrap_err();
28248 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28249 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28250
28251 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28252 .await
28253 .unwrap();
28254 let NodeResponse::WorkspaceFileCreated { file } = response else {
28255 panic!("workspace file create returned another response");
28256 };
28257 assert_eq!(file.workspace_id, workspace_id);
28258 assert_eq!(file.path, path);
28259 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28260 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28261
28262 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28263 .await
28264 .unwrap_err();
28265 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28266
28267 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28268 let response = process_request_inner(
28269 &shared,
28270 77,
28271 ClientRole::Operator,
28272 NodeRequest::CreateWorkspaceDirectory {
28273 workspace_id: workspace_id.clone(),
28274 path: directory_path.clone(),
28275 },
28276 )
28277 .await
28278 .unwrap();
28279 assert_eq!(
28280 response,
28281 NodeResponse::WorkspaceDirectoryCreated {
28282 workspace_id: workspace_id.clone(),
28283 entry: WorkspaceEntry {
28284 relative_path: directory_path,
28285 kind: WorkspaceEntryKind::Directory,
28286 },
28287 },
28288 );
28289 assert!(root.join("src/nested").is_dir());
28290
28291 let missing_parent = process_request_inner(
28292 &shared,
28293 77,
28294 ClientRole::Operator,
28295 NodeRequest::CreateWorkspaceFile {
28296 workspace_id: workspace_id.clone(),
28297 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28298 },
28299 )
28300 .await
28301 .unwrap_err();
28302 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28303 let non_directory_parent = process_request_inner(
28304 &shared,
28305 77,
28306 ClientRole::Operator,
28307 NodeRequest::CreateWorkspaceFile {
28308 workspace_id,
28309 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28310 },
28311 )
28312 .await
28313 .unwrap_err();
28314 assert_eq!(
28315 non_directory_parent.code,
28316 NodeFailureCode::RepositoryParentNotDirectory,
28317 );
28318 std::fs::remove_dir_all(root).unwrap();
28319 }
28320
28321 #[tokio::test]
28322 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28323 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28324 std::fs::create_dir_all(root.join("src")).unwrap();
28325 #[cfg(windows)]
28326 let root = std::fs::canonicalize(root).unwrap();
28327 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28328 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28329 let barrier = Arc::new(std::sync::Barrier::new(2));
28330 let done = Arc::new(AtomicBool::new(false));
28331 let task_commit_state = Arc::clone(&commit_state);
28332 let task_barrier = Arc::clone(&barrier);
28333 let task_done = Arc::clone(&done);
28334 let task_root = root.clone();
28335 let task = tokio::task::spawn_blocking(move || {
28336 task_barrier.wait();
28337 let result = create_workspace_file_on_disk(
28338 &task_root,
28339 &path,
28340 &task_commit_state,
28341 );
28342 task_done.store(true, Ordering::Release);
28343 result
28344 });
28345
28346 let error = settle_workspace_entry_create(
28347 task,
28348 Arc::clone(&commit_state),
28349 Duration::from_millis(10),
28350 "fixture deadline",
28351 "fixture task failed",
28352 )
28353 .await
28354 .unwrap_err();
28355 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28356 assert_eq!(
28357 commit_state.load(Ordering::Acquire),
28358 WORKSPACE_ENTRY_CREATE_CANCELED,
28359 );
28360
28361 barrier.wait();
28362 timeout(Duration::from_secs(1), async {
28363 while !done.load(Ordering::Acquire) {
28364 tokio::task::yield_now().await;
28365 }
28366 })
28367 .await
28368 .expect("canceled blocking create did not settle");
28369 assert!(!root.join("src/late.rs").exists());
28370 std::fs::remove_dir_all(root).unwrap();
28371 }
28372}
28373
28374#[cfg(test)]
28375mod provisional_spawn_tests {
28376 use super::*;
28377 use std::sync::Arc;
28378
28379 fn shared() -> NodeShared {
28380 let catalog = active_registry().unwrap();
28381 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28382 let workspace = WorkspaceConfig::new(
28383 WorkspaceId::new("primary").unwrap(),
28384 std::env::current_dir().unwrap(),
28385 )
28386 .unwrap()
28387 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28388 NodeShared::new(
28389 handle,
28390 "fixture-token".to_owned(),
28391 NodeId::new("node-provisional-test").unwrap(),
28392 vec![workspace],
28393 vec![AgentId::new("claude").unwrap()],
28394 )
28395 }
28396
28397 #[test]
28400 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28401 let shared = shared();
28402 let ws = WorkspaceId::new("primary").unwrap();
28403 let mut ids = Vec::with_capacity(50);
28404 for i in 1u64..=50 {
28405 let id = AgentInstanceId(i);
28406 ids.push(id);
28407 let address = SessionAddress {
28408 workspace_id: ws.clone(),
28409 session: SessionKey {
28410 instance_id: id,
28411 generation: SessionGeneration(1),
28412 },
28413 };
28414 shared.mark_spawn_provisional(address);
28415 }
28416 assert_eq!(
28417 shared
28418 .provisional_spawns
28419 .lock()
28420 .unwrap_or_else(|p| p.into_inner())
28421 .len(),
28422 50
28423 );
28424 for id in ids.iter().step_by(2) {
28425 shared.sessionize_spawn(*id);
28426 }
28427 assert_eq!(
28428 shared
28429 .provisional_spawns
28430 .lock()
28431 .unwrap_or_else(|p| p.into_inner())
28432 .len(),
28433 25
28434 );
28435 assert!(shared.take_due_provisional_spawns().is_empty());
28436 {
28437 let mut map = shared
28438 .provisional_spawns
28439 .lock()
28440 .unwrap_or_else(|p| p.into_inner());
28441 let past = Instant::now() - Duration::from_secs(91);
28442 for (_, (_, at)) in map.iter_mut() {
28443 *at = past;
28444 }
28445 }
28446 let due = shared.take_due_provisional_spawns();
28447 assert_eq!(due.len(), 25);
28448 let due_ids: std::collections::BTreeSet<_> =
28449 due.iter().map(|a| a.session.instance_id.0).collect();
28450 let expected: std::collections::BTreeSet<_> =
28451 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28452 assert_eq!(due_ids, expected);
28453 assert!(shared
28454 .provisional_spawns
28455 .lock()
28456 .unwrap_or_else(|p| p.into_inner())
28457 .is_empty());
28458 }
28459
28460 #[test]
28463 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28464 let shared = Arc::new(shared());
28465 let ws = WorkspaceId::new("primary").unwrap();
28466 let mut handles = Vec::new();
28467 for i in 1u64..=20 {
28468 let shared = Arc::clone(&shared);
28469 let ws = ws.clone();
28470 handles.push(std::thread::spawn(move || {
28471 let id = AgentInstanceId(i);
28472 let address = SessionAddress {
28473 workspace_id: ws,
28474 session: SessionKey {
28475 instance_id: id,
28476 generation: SessionGeneration(1),
28477 },
28478 };
28479 shared.mark_spawn_provisional(address);
28480 id
28481 }));
28482 }
28483 let mut ids = Vec::new();
28484 for h in handles {
28485 ids.push(h.join().unwrap());
28486 }
28487 ids.sort_by_key(|id| id.0);
28488 assert_eq!(ids.len(), 20);
28489 ids.dedup();
28490 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28491 assert_eq!(
28492 shared
28493 .provisional_spawns
28494 .lock()
28495 .unwrap_or_else(|p| p.into_inner())
28496 .len(),
28497 20
28498 );
28499 shared.sessionize_spawn(AgentInstanceId(7));
28500 let left: Vec<_> = shared
28501 .provisional_spawns
28502 .lock()
28503 .unwrap_or_else(|p| p.into_inner())
28504 .keys()
28505 .map(|id| id.0)
28506 .collect();
28507 assert!(!left.contains(&7));
28508 assert_eq!(left.len(), 19);
28509 }
28510}
28511
28512
28513#[cfg(unix)]
28514extern "C" {
28515 #[link_name = "geteuid"]
28516 fn libc_geteuid() -> u32;
28517}
28518
28519#[cfg(test)]
28520mod call_home_link_tests {
28521 use super::*;
28522
28523 #[test]
28524 fn call_home_backoff_is_capped_and_monotonic() {
28525 let mut last = Duration::ZERO;
28526 for attempt in 1..20 {
28527 let next = call_home_backoff(attempt);
28528 assert!(next >= last);
28529 assert!(next <= Duration::from_secs(8));
28530 last = next;
28531 }
28532 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28533 }
28534
28535 #[tokio::test]
28536 async fn call_home_socket_gets_keepalive() {
28537 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28538 let addr = listener.local_addr().unwrap();
28539 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28540 let client = client.unwrap();
28541 enable_call_home_keepalive(&client).unwrap();
28542 let sock = socket2::SockRef::from(&client);
28543 assert!(sock.keepalive().unwrap());
28544 #[cfg(any(target_os = "linux", target_os = "macos"))]
28545 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28546 }
28547}
28548
28549fn provider_session_exists(provider: &str, id: &str) -> bool {
28551 if provider != "claude" { return false; }
28552 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28553 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28554 let name = format!("{id}.jsonl");
28555 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())))
28556}
28557
28558fn overlay_with_extra_args(
28560 provider: &AgentId,
28561 mode: SessionMode,
28562 overlay: Option<PreparedNativeLaunchOverlay>,
28563 args: Vec<OsString>,
28564) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28565 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28566 Ok(match (overlay, args) {
28567 (prepared, args) if args.is_empty() => prepared,
28568 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28569 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28570 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28571 provider.clone(),
28572 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28573 Vec::new(),
28574 args,
28575 ).map_err(|_| bad())?)),
28576 })
28577}